diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index 080164a92..07b83761e 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -24,7 +24,7 @@ import { plexIntegration } from "@site/docs/integrations/plex"; The media server widget shows active sessions across your media servers. It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. -Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. +Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable. Sessions without user metadata remain visible and are labeled as an unknown user. diff --git a/packages/widgets/src/media-server/component.module.css b/packages/widgets/src/media-server/component.module.css index 3987924f4..321f7ddfa 100644 --- a/packages/widgets/src/media-server/component.module.css +++ b/packages/widgets/src/media-server/component.module.css @@ -1,3 +1,23 @@ +.root { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tablePaper { + display: flex; + flex-direction: column; + width: 100%; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.tableContainer { + flex: 1; + min-height: 0; +} + .sessionRow { cursor: pointer; @@ -11,3 +31,16 @@ outline-offset: -2px; } } + +.detailsTarget { + width: 100%; + min-width: 0; + color: inherit; + text-align: left; + border-radius: var(--mantine-radius-xs); + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; + } +} diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx new file mode 100644 index 000000000..ee941eac1 --- /dev/null +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -0,0 +1,101 @@ +// @vitest-environment jsdom + +import { act, useState } from "react"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import type { StreamSession } from "@homarr/integrations"; + +import { SessionDetailsPopover } from "./component"; + +vi.mock("@homarr/translation/client", () => ({ + useScopedI18n: () => (key: string) => key, +})); + +const session = { + sessionId: "session-1", + sessionName: "Living room", + user: { username: "Alex", profilePictureUrl: null }, + currentlyPlaying: { + type: "movie", + name: "Example movie", + episodeName: null, + seasonName: null, + metadata: null, + }, +} as unknown as StreamSession; + +function ControlledPopover({ children }: { children: ReactNode }) { + const [opened, setOpened] = useState(false); + return ( + + {children} + + ); +} + +describe("SessionDetailsPopover", () => { + let host: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + vi.stubGlobal("matchMedia", () => ({ + matches: false, + media: "", + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + })); + + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + }); + + test("opens and closes anchored session details from the target", async () => { + await act(() => + root.render( + + Open session + , + ), + ); + + const target = host.querySelector("button"); + expect(target?.getAttribute("aria-expanded")).toBe("false"); + + await act(async () => { + target?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(target?.getAttribute("aria-expanded")).toBe("true"); + expect(document.body.textContent).toContain("Example movie"); + + await act(async () => { + target?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(target?.getAttribute("aria-expanded")).toBe("false"); + }); +}); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index a932091a1..f8406ef13 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -1,8 +1,19 @@ "use client"; import type { ReactNode } from "react"; -import { Fragment, useMemo } from "react"; -import { Avatar, Badge, Divider, Flex, Group, Progress, Stack, Text, Title } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { + Avatar, + Badge, + Divider, + Group, + Popover, + Progress, + SimpleGrid, + Stack, + Text, + UnstyledButton, +} from "@mantine/core"; import { IconDeviceTv, IconHeadphones, @@ -19,7 +30,6 @@ import { clientApi } from "@homarr/api/client"; import { objectEntries } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; -import { createModal, useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; @@ -74,6 +84,7 @@ export default function MediaServerWidget({ const t = useScopedI18n("widget.mediaServer"); const isAdvanced = displayMode === "advanced"; + const [selectedRowId, setSelectedRowId] = useState(null); const columns = useMemo[]>(() => { const result: MRT_ColumnDef[] = [ ...(width >= 300 || isAdvanced @@ -108,55 +119,14 @@ export default function MediaServerWidget({ header: t("items.currentlyPlaying"), Cell: ({ row }) => { - const currentlyPlaying = row.original.currentlyPlaying; - if (!currentlyPlaying) return null; - - const playback = currentlyPlaying.playback; - const isPaused = playback?.state === "paused"; - const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type]; - - const positionMs = playback?.positionMs ?? null; - const durationMs = playback?.durationMs ?? null; - const progressPercent = - positionMs !== null && durationMs !== null && durationMs > 0 - ? Math.min(100, Math.round((positionMs / durationMs) * 100)) - : null; - const remainingMinutes = - positionMs !== null && durationMs !== null - ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) - : null; - return ( - - - - - {currentlyPlaying.name} - - {isPaused && ( - - {t("items.paused")} - - )} - {!isPaused && remainingMinutes !== null && ( - - {t("items.remaining", { minutes: remainingMinutes.toString() })} - - )} - - {progressPercent !== null && ( - - )} - + setSelectedRowId(opened ? row.id : null)} + > + + ); }, } satisfies MRT_ColumnDef, @@ -205,7 +175,7 @@ export default function MediaServerWidget({ : []), ]; return result; - }, [isAdvanced, options.showBitrate, options.showLocation, t, width]); + }, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, t, width]); // Only render the flat list of sessions when the currentStreams change // Otherwise it will always create a new array reference and cause the table to re-render @@ -223,7 +193,6 @@ export default function MediaServerWidget({ [currentStreams], ); - const { openModal } = useModalAction(ItemInfoModal); const table = useTranslatedMantineReactTable({ columns, data: flatSessions, @@ -240,12 +209,9 @@ export default function MediaServerWidget({ enableDensityToggle: false, enableFilters: false, enableHiding: false, - enableColumnPinning: true, + enableColumnPinning: false, initialState: { density: "xs", - columnPinning: { - right: ["currentlyPlaying", "status"], - }, }, mantineTableHeadProps: { fz: "xs", @@ -257,6 +223,7 @@ export default function MediaServerWidget({ flex: 1, withBorder: false, shadow: undefined, + className: classes.tablePaper, }, mantineTableProps: { className: "media-server-widget-table", @@ -265,27 +232,28 @@ export default function MediaServerWidget({ }, }, mantineTableContainerProps: { + className: classes.tableContainer, style: { - height: "100%", pointerEvents: isEditMode ? "none" : undefined, }, }, mantineTableBodyRowProps: ({ row }) => { - const openDetails = () => { - openModal({ item: row.original }, { title: row.original.sessionName }); - }; + const toggleDetails = () => setSelectedRowId((current) => (current === row.id ? null : row.id)); return { className: isEditMode ? undefined : classes.sessionRow, tabIndex: isEditMode ? -1 : 0, "aria-label": row.original.sessionName, - onClick: isEditMode ? undefined : openDetails, + "aria-haspopup": isEditMode ? undefined : "dialog", + "aria-expanded": isEditMode ? undefined : selectedRowId === row.id, + onClick: isEditMode ? undefined : toggleDetails, onKeyDown: isEditMode ? undefined : (event) => { + if (event.currentTarget !== event.target) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); - openDetails(); + toggleDetails(); }, }; }, @@ -321,7 +289,7 @@ export default function MediaServerWidget({ const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error)); return ( - + {isAdvanced && ( {t("summary.sessions", { count: flatSessions.length })} @@ -384,89 +352,191 @@ export default function MediaServerWidget({ ); } -const ItemInfoModal = createModal<{ item: StreamSession }>(({ innerProps }) => { - const t = useScopedI18n("widget.mediaServer.items"); - const Icon = innerProps.item.currentlyPlaying ? mediaTypeIconMap[innerProps.item.currentlyPlaying.type] : null; +function CurrentlyPlaying({ item }: { item: StreamSession }) { + const t = useScopedI18n("widget.mediaServer"); + const currentlyPlaying = item.currentlyPlaying; + if (!currentlyPlaying) { + return ( + + {item.sessionName} + + ); + } - const metadata = useMemo(() => { - return innerProps.item.currentlyPlaying?.metadata - ? constructMetadata(innerProps.item.currentlyPlaying.metadata) + const playback = currentlyPlaying.playback; + const isPaused = playback?.state === "paused"; + const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type]; + const positionMs = playback?.positionMs ?? null; + const durationMs = playback?.durationMs ?? null; + const progressPercent = + positionMs !== null && durationMs !== null && durationMs > 0 + ? Math.min(100, Math.round((positionMs / durationMs) * 100)) : null; - }, [innerProps.item.currentlyPlaying?.metadata]); + const remainingMinutes = + positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; return ( - - - {Icon && innerProps.item.currentlyPlaying !== null && ( - - - {innerProps.item.currentlyPlaying.name} - + + + + + {currentlyPlaying.name} + + {isPaused ? ( + + {t("items.paused")} + + ) : ( + remainingMinutes !== null && ( + + {t("items.remaining", { minutes: remainingMinutes.toString() })} + + ) )} - {innerProps.item.currentlyPlaying?.episodeName && ( - - {innerProps.item.currentlyPlaying.episodeName} - {innerProps.item.currentlyPlaying.seasonName && ( - <> - {" - "} - {innerProps.item.currentlyPlaying.seasonName} - + + {progressPercent !== null && ( + + )} + + ); +} + +export function SessionDetailsPopover({ + item, + opened, + onChange, + children, +}: { + item: StreamSession; + opened: boolean; + onChange: (opened: boolean) => void; + children: ReactNode; +}) { + return ( + + + { + event.stopPropagation(); + onChange(!opened); + }} + > + {children} + + + + + + + ); +} + +function SessionDetails({ item }: { item: StreamSession }) { + const t = useScopedI18n("widget.mediaServer.items"); + const Icon = item.currentlyPlaying ? mediaTypeIconMap[item.currentlyPlaying.type] : null; + + const metadata = item.currentlyPlaying?.metadata ? constructMetadata(item.currentlyPlaying.metadata) : null; + + return ( + + {Icon && item.currentlyPlaying && ( + + + + + {item.currentlyPlaying.name} + + {item.currentlyPlaying.episodeName && ( + + {[item.currentlyPlaying.episodeName, item.currentlyPlaying.seasonName].filter(Boolean).join(" ยท ")} + )} - - )} - + + + )} - {" "} - {innerProps.item.user?.username ?? t("unknownUser")} + + + + {item.user?.username ?? t("unknownUser")} + } /> - {innerProps.item.sessionName}} /> - {innerProps.item.sessionId}} /> + + {item.sessionName} + + } + /> + + {item.sessionId} + + } + /> {metadata ? ( - - - - - {objectEntries(metadata).map(([key, value], index) => ( - - {index !== 0 && } - - {t(`metadata.${key}.title`)} - - {Object.entries(value) - .filter(([_, value]) => Boolean(value)) - .map(([innerKey, value]) => ( - - {t(`metadata.${key}.${innerKey}` as never)} - {value} - - ))} - - + + + + {objectEntries(metadata).map(([key, entries]) => ( + + + {t(`metadata.${key}.title`)} + + {Object.entries(entries) + .filter(([_, entryValue]) => Boolean(entryValue)) + .map(([innerKey, entryValue]) => ( + + + {t(`metadata.${key}.${innerKey}` as never)} + + + {entryValue} + + + ))} + ))} - + ) : null} ); -}).withOptions({ - defaultTitle() { - return ""; - }, - size: "lg", - centered: true, -}); +} const NormalizedLine = ({ itemKey, value }: { itemKey: string; value: ReactNode }) => { return ( - - {itemKey}: - {value} + + + {itemKey}: + + + {value} + ); };