From 0afed2c3f626a6feeaf047edf6396cfcaaa16391 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Fri, 14 Aug 2026 20:06:07 +0200 Subject: [PATCH] fix: contain media server streams --- e2e/media-server-layout.spec.ts | 178 +++++++++ .../src/media-server/component.module.css | 46 ++- .../widgets/src/media-server/component.tsx | 373 ++++++++++-------- 3 files changed, 420 insertions(+), 177 deletions(-) create mode 100644 e2e/media-server-layout.spec.ts diff --git a/e2e/media-server-layout.spec.ts b/e2e/media-server-layout.spec.ts new file mode 100644 index 000000000..7349b9ed3 --- /dev/null +++ b/e2e/media-server-layout.spec.ts @@ -0,0 +1,178 @@ +import path from "node:path"; +import type { Browser } from "@playwright/test"; +import { chromium, expect } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import { eq } from "drizzle-orm"; +import { stringify } from "superjson"; +import { afterAll, beforeAll, describe, test } from "vitest"; + +import { + boards, + integrationItems, + integrations, + itemLayouts, + items, + layouts, + sections, + users, +} from "../packages/db/schema/sqlite"; +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; + +const credentials = { username: "owner", password: "Comp(exP4sswOrd" }; +const boardName = "media-server-layout-e2e"; + +describe("Media server widget layout", () => { + let browser: Browser; + let baseUrl: string; + let stopContainerAsync: (() => Promise) | undefined; + + beforeAll(async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId } = await seedAdminUserAsync(db, credentials); + const boardId = createId(); + const layoutId = createId(); + const sectionId = createId(); + const mediaItemId = createId(); + const clockItemId = createId(); + const integrationId = createId(); + + await db.insert(boards).values({ id: boardId, name: boardName, creatorId: userId, isPublic: false }); + await db.insert(layouts).values({ id: layoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId }); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(integrations).values({ + id: integrationId, + name: "Demo Integration", + url: "https://demo.homarr.dev", + kind: "mock", + appId: null, + }); + await db.insert(items).values([ + { + id: mediaItemId, + kind: "mediaServer", + boardId, + options: stringify({ showOnlyPlaying: true, showBitrate: true, showLocation: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: clockItemId, + kind: "clock", + boardId, + options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + ]); + await db.insert(integrationItems).values({ itemId: mediaItemId, integrationId }); + await db.insert(itemLayouts).values([ + { itemId: mediaItemId, sectionId, layoutId, xOffset: 0, yOffset: 0, width: 8, height: 2 }, + { itemId: clockItemId, sectionId, layoutId, xOffset: 0, yOffset: 2, width: 8, height: 2 }, + ]); + await db + .update(users) + .set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true }) + .where(eq(users.id, userId)); + + const container = await createHomarrContainer({ + environment: { AUTH_PROVIDERS: "credentials" }, + mounts: { "/appdata": localMountPath }, + }).start(); + stopContainerAsync = async () => { + await container.stop(); + }; + baseUrl = `http://${container.getHost()}:${container.getMappedPort(7575)}`; + browser = await chromium.launch(); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + await stopContainerAsync?.(); + }); + + test("clips overflowing sessions to the grid cell", async () => { + const context = await browser.newContext({ reducedMotion: "reduce", viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` }); + const entry = page.locator("[data-grid-item-id][data-kind='mediaServer']").first(); + const streamViewport = entry.locator("[data-media-server-streams]"); + await expect(streamViewport.getByText("Interstellar")).toBeVisible({ timeout: 15_000 }); + + const geometry = await entry.evaluate((element) => { + const card = element.querySelector("[data-grid-item-content]"); + const viewport = element.querySelector("[data-media-server-streams]"); + if (!card || !viewport) throw new Error("Media stream viewport did not render"); + + const entryBounds = element.getBoundingClientRect(); + const cardBounds = card.getBoundingClientRect(); + const viewportBounds = viewport.getBoundingClientRect(); + const elementBelow = document.elementFromPoint(entryBounds.left + 8, entryBounds.bottom + 8); + + return { + entryBottom: entryBounds.bottom, + cardBottom: cardBounds.bottom, + viewportBottom: viewportBounds.bottom, + viewportClientHeight: viewport.clientHeight, + viewportScrollHeight: viewport.scrollHeight, + leaksBelow: Boolean(elementBelow?.closest("[data-media-server-streams]")), + }; + }); + + expect(geometry.cardBottom).toBeLessThanOrEqual(geometry.entryBottom + 1); + expect(geometry.viewportBottom).toBeLessThanOrEqual(geometry.entryBottom + 1); + expect(geometry.viewportScrollHeight).toBeGreaterThan(geometry.viewportClientHeight); + expect(geometry.leaksBelow).toBe(false); + + const screenshotDirectory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim(); + if (screenshotDirectory) { + await page.screenshot({ path: path.join(screenshotDirectory, "media-server-layout.png"), fullPage: true }); + } + + const advancedTrigger = entry.getByRole("button", { name: "Open advanced view" }); + await advancedTrigger.focus(); + await page.keyboard.press("Shift+Enter"); + const advancedDialog = page.getByRole("dialog", { name: "Current media server streams advanced view" }); + await expect(advancedDialog).toBeVisible(); + await expect(advancedDialog.getByRole("textbox", { name: "Search for anything" })).toBeVisible(); + + const advancedGeometry = await advancedDialog.evaluate((element) => { + const viewport = element.querySelector("[data-media-server-streams]"); + if (!viewport) throw new Error("Advanced media stream viewport did not render"); + + const dialogBounds = element.getBoundingClientRect(); + const viewportBounds = viewport.getBoundingClientRect(); + return { + dialog: { + top: dialogBounds.top, + right: dialogBounds.right, + bottom: dialogBounds.bottom, + left: dialogBounds.left, + }, + viewport: { + top: viewportBounds.top, + right: viewportBounds.right, + bottom: viewportBounds.bottom, + left: viewportBounds.left, + }, + }; + }); + + expect(advancedGeometry.viewport.top).toBeGreaterThanOrEqual(advancedGeometry.dialog.top - 1); + expect(advancedGeometry.viewport.right).toBeLessThanOrEqual(advancedGeometry.dialog.right + 1); + expect(advancedGeometry.viewport.bottom).toBeLessThanOrEqual(advancedGeometry.dialog.bottom + 1); + expect(advancedGeometry.viewport.left).toBeGreaterThanOrEqual(advancedGeometry.dialog.left - 1); + + if (screenshotDirectory) { + await page.screenshot({ + path: path.join(screenshotDirectory, "media-server-advanced-layout.png"), + fullPage: true, + }); + } + } finally { + await context.close(); + } + }, 60_000); +}); diff --git a/packages/widgets/src/media-server/component.module.css b/packages/widgets/src/media-server/component.module.css index daeb987b2..1da83ffe3 100644 --- a/packages/widgets/src/media-server/component.module.css +++ b/packages/widgets/src/media-server/component.module.css @@ -1,25 +1,47 @@ .root { - min-width: 0; - min-height: 0; - overflow: hidden; -} - -.tablePaper { display: flex; - flex: 1 1 0; flex-direction: column; width: 100%; - max-height: 100%; + height: 100%; min-width: 0; min-height: 0; overflow: hidden; } -.tableContainer { - flex: 1; - height: 100%; - max-height: 100%; +.tableViewport { + flex: 1 1 0; + width: 100%; + min-width: 0; min-height: 0; + overflow: auto; + overscroll-behavior: contain; +} + +.tableViewport :global(table) { + min-width: 100%; +} + +.tableViewport :global(th), +.tableViewport :global(td) { + padding-block: 4px; + overflow: hidden; +} + +.tableViewport :global(th) { + background-color: var(--background-color); +} + +.sortButton { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 4px; +} + +.cellContent { + min-width: 0; + overflow: hidden; } .sessionRow { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index a3342cf35..533f889d2 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -11,28 +11,31 @@ import { Progress, SimpleGrid, Stack, + Table, Text, + TextInput, UnstyledButton, } from "@mantine/core"; import { + IconArrowsSort, + IconChevronDown, + IconChevronUp, IconDeviceTv, IconHeadphones, IconMovie, IconPlayerPause, + IconSearch, IconVideo, IconWifi, IconWorld, } from "@tabler/icons-react"; -import type { MRT_ColumnDef } from "mantine-react-table"; -import { MantineReactTable } from "mantine-react-table"; import { clientApi } from "@homarr/api/client"; import { objectEntries } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; -import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; @@ -43,6 +46,8 @@ import classes from "./component.module.css"; type TranscodingDecision = NonNullable["metadata"]>["transcoding"]; type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding"; +type SortColumn = "user" | "currentlyPlaying" | "status"; +type SortState = { column: SortColumn; descending: boolean } | null; export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ user: isAdvanced || width >= 300, @@ -72,6 +77,64 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null { return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; } +const getSessionSortValue = (session: StreamSession, column: SortColumn) => { + if (column === "user") return session.user?.username ?? session.sessionName; + if (column === "currentlyPlaying") return session.currentlyPlaying?.name ?? session.sessionName; + return getPlaybackStatus(session.currentlyPlaying?.metadata?.transcoding); +}; + +const filterAndSortSessions = (sessions: T[], query: string, sort: SortState): T[] => { + const normalizedQuery = query.trim().toLocaleLowerCase(); + const filtered = normalizedQuery + ? sessions.filter((session) => + [session.user?.username, session.sessionName, session.currentlyPlaying?.name] + .filter(Boolean) + .some((value) => value?.toLocaleLowerCase().includes(normalizedQuery)), + ) + : sessions; + + if (!sort) return filtered; + + return filtered.toSorted((left, right) => { + const comparison = getSessionSortValue(left, sort.column).localeCompare(getSessionSortValue(right, sort.column)); + return sort.descending ? -comparison : comparison; + }); +}; + +function StreamTableHeader({ + column, + label, + sortable, + sort, + onSort, +}: { + column: SortColumn; + label: string; + sortable: boolean; + sort: SortState; + onSort: (column: SortColumn) => void; +}) { + const active = sort?.column === column; + const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; + + return ( + + {sortable ? ( + onSort(column)}> + + {label} + + + + ) : ( + + {label} + + )} + + ); +} + export default function MediaServerWidget({ options, integrationIds, @@ -88,96 +151,14 @@ export default function MediaServerWidget({ const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]); const t = useScopedI18n("widget.mediaServer"); + const tGlobal = useI18n(); const isAdvanced = displayMode === "advanced"; const showLocation = isAdvanced || options.showLocation; const showBitrate = isAdvanced || options.showBitrate; const [selectedRowId, setSelectedRowId] = useState(null); - const columns = useMemo[]>(() => { - const result: MRT_ColumnDef[] = [ - { - id: "user", - accessorFn: (session) => session.user?.username, - header: t("items.user"), - size: 160, - - Cell: ({ row }) => ( - - - - - {row.original.user?.username ?? t("items.unknownUser")} - - - {row.original.sessionName} - - - - ), - }, - { - accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name - header: t("items.currentlyPlaying"), - - Cell: ({ row }) => { - return ( - setSelectedRowId(opened ? row.id : null)} - > - - - ); - }, - } satisfies MRT_ColumnDef, - { - id: "status", - header: t("items.status"), - size: 110, - - Cell: ({ row }) => { - const currentlyPlaying = row.original.currentlyPlaying; - if (!currentlyPlaying) return null; - - const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding); - const location = showLocation ? currentlyPlaying.location : null; - const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; - - return ( - - - {t(`items.${status}` as never)} - - {(location ?? bitrateLabel) && ( - - - {location && (location === "lan" ? : )} - {location && ( - - {location} - - )} - - {bitrateLabel && ( - - {bitrateLabel} - - )} - - )} - - ); - }, - }, - ]; - return result; - }, [selectedRowId, showBitrate, showLocation, t]); - const columnVisibility = getMediaServerColumnVisibility(width, isAdvanced); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState(null); // 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 @@ -194,82 +175,15 @@ export default function MediaServerWidget({ ), [currentStreams], ); + const visibleSessions = useMemo( + () => filterAndSortSessions(flatSessions, isAdvanced ? search : "", isAdvanced ? sort : null), + [flatSessions, isAdvanced, search, sort], + ); - const table = useTranslatedMantineReactTable({ - columns, - data: flatSessions, - enablePagination: false, - enableTopToolbar: isAdvanced, - enableBottomToolbar: false, - enableSorting: isAdvanced, - enableColumnActions: false, - enableStickyHeader: false, - enableColumnOrdering: false, - enableRowSelection: false, - enableFullScreenToggle: false, - enableGlobalFilter: isAdvanced, - enableDensityToggle: false, - enableFilters: false, - enableHiding: false, - enableColumnPinning: false, - initialState: { - density: "xs", - }, - state: { - columnVisibility, - }, - mantineTableHeadProps: { - fz: "xs", - }, - mantineTableHeadCellProps: { - py: 4, - }, - mantinePaperProps: { - flex: 1, - withBorder: false, - shadow: undefined, - className: classes.tablePaper, - }, - mantineTableProps: { - className: "media-server-widget-table", - style: { - tableLayout: "fixed", - }, - }, - mantineTableContainerProps: { - className: classes.tableContainer, - style: { - pointerEvents: isEditMode ? "none" : undefined, - }, - }, - mantineTableBodyRowProps: ({ row }) => { - 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, - "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(); - toggleDetails(); - }, - }; - }, - mantineTableBodyCellProps: () => ({ - py: 4, - style: { - overflowX: "hidden", - overflowY: "visible", - }, - }), - }); + const toggleSort = (column: SortColumn) => + setSort((current) => + current?.column === column ? { column, descending: !current.descending } : { column, descending: false }, + ); const uniqueIntegrations = currentStreams.map((stream) => ({ integrationId: stream.integrationId, @@ -305,6 +219,14 @@ export default function MediaServerWidget({ {t("summary.transcoding", { count: transcodingCount })} + setSearch(event.currentTarget.value)} + placeholder={tGlobal("search.placeholder")} + aria-label={tGlobal("search.placeholder")} + leftSection={} + /> @@ -316,7 +238,128 @@ export default function MediaServerWidget({ )} - +
+ + + + {columnVisibility.user && ( + + )} + + {columnVisibility.status && ( + + )} + + + + {visibleSessions.map((session) => { + const rowId = `${session.integrationId}:${session.sessionId}`; + const currentlyPlaying = session.currentlyPlaying; + const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); + const location = showLocation ? currentlyPlaying?.location : null; + const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; + const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); + + return ( + { + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleDetails(); + } + } + > + {columnVisibility.user && ( + + + + + + {session.user?.username ?? t("items.unknownUser")} + + + {session.sessionName} + + + + + )} + + setSelectedRowId(opened ? rowId : null)} + > + + + + {columnVisibility.status && ( + + {currentlyPlaying && ( + + + {t(`items.${status}` as never)} + + {(location ?? bitrateLabel) && ( + + + {location && (location === "lan" ? : )} + {location && ( + + {location} + + )} + + {bitrateLabel && ( + + {bitrateLabel} + + )} + + )} + + )} + + )} + + ); + })} + +
+
{(isAdvanced || height >= 144) && (