fix: contain media server streams

This commit is contained in:
Thomas Camlong committed 2026-08-14 20:09:57 +02:00
1 parent efc0ee470b
commit 0afed2c3f6
3 files changed
+420 -177

No files matched your search

+178
View File
@@ -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<void>) | 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<HTMLElement>("[data-grid-item-content]");
const viewport = element.querySelector<HTMLElement>("[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<HTMLElement>("[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);
});
@@ -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 {
+208 -165
View File
@@ -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<NonNullable<StreamSession["currentlyPlaying"]>["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 = <T extends StreamSession>(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 (
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
{sortable ? (
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
<Text component="span" size="xs" fw={600} truncate>
{label}
</Text>
<SortIcon size={13} aria-hidden />
</UnstyledButton>
) : (
<Text size="xs" fw={600} truncate>
{label}
</Text>
)}
</Table.Th>
);
}
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<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
{
id: "user",
accessorFn: (session) => session.user?.username,
header: t("items.user"),
size: 160,
Cell: ({ row }) => (
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={row.original.user?.profilePictureUrl} />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="xs" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.original.user?.username ?? t("items.unknownUser")}
</Text>
<Text
size="10px"
c="dimmed"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{row.original.sessionName}
</Text>
</Stack>
</Group>
),
},
{
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 (
<SessionDetailsPopover
item={row.original}
opened={selectedRowId === row.id}
onChange={(opened) => setSelectedRowId(opened ? row.id : null)}
>
<CurrentlyPlaying item={row.original} />
</SessionDetailsPopover>
);
},
} satisfies MRT_ColumnDef<StreamSession>,
{
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 (
<Stack gap={4} align="flex-start">
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{(location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center">
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{location && (
<Text size="10px" c="dimmed" tt="uppercase">
{location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed">
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
);
},
},
];
return result;
}, [selectedRowId, showBitrate, showLocation, t]);
const columnVisibility = getMediaServerColumnVisibility(width, isAdvanced);
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortState>(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 })}
</Badge>
<Group ml="auto">
<TextInput
size="xs"
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={tGlobal("search.placeholder")}
aria-label={tGlobal("search.placeholder")}
leftSection={<IconSearch size={14} aria-hidden />}
/>
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
@@ -316,7 +238,128 @@ export default function MediaServerWidget({
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<MantineReactTable table={table} />
<div
className={classes.tableViewport}
data-media-server-streams
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
<Table stickyHeader highlightOnHover={!isEditMode} layout="fixed" className="media-server-widget-table">
<Table.Thead>
<Table.Tr>
{columnVisibility.user && (
<StreamTableHeader
column="user"
label={t("items.user")}
sortable={isAdvanced}
sort={sort}
onSort={toggleSort}
/>
)}
<StreamTableHeader
column="currentlyPlaying"
label={t("items.currentlyPlaying")}
sortable={isAdvanced}
sort={sort}
onSort={toggleSort}
/>
{columnVisibility.status && (
<StreamTableHeader
column="status"
label={t("items.status")}
sortable={isAdvanced}
sort={sort}
onSort={toggleSort}
/>
)}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{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 (
<Table.Tr
key={rowId}
className={isEditMode ? undefined : classes.sessionRow}
tabIndex={isEditMode ? -1 : 0}
aria-label={session.sessionName}
aria-haspopup={isEditMode ? undefined : "dialog"}
aria-expanded={isEditMode ? undefined : selectedRowId === rowId}
onClick={isEditMode ? undefined : toggleDetails}
onKeyDown={
isEditMode
? undefined
: (event) => {
if (event.currentTarget !== event.target) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
toggleDetails();
}
}
>
{columnVisibility.user && (
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Avatar size={28} src={session.user?.profilePictureUrl} />
<Stack gap={0} className={classes.cellContent}>
<Text size="xs" truncate>
{session.user?.username ?? t("items.unknownUser")}
</Text>
<Text size="10px" c="dimmed" truncate>
{session.sessionName}
</Text>
</Stack>
</Group>
</Table.Td>
)}
<Table.Td>
<SessionDetailsPopover
item={session}
opened={selectedRowId === rowId}
onChange={(opened) => setSelectedRowId(opened ? rowId : null)}
>
<CurrentlyPlaying item={session} />
</SessionDetailsPopover>
</Table.Td>
{columnVisibility.status && (
<Table.Td>
{currentlyPlaying && (
<Stack gap={4} align="flex-start" className={classes.cellContent}>
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
{t(`items.${status}` as never)}
</Badge>
{(location ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
<Group gap={4} align="center" wrap="nowrap">
{location && (location === "lan" ? <IconWifi size={12} /> : <IconWorld size={12} />)}
{location && (
<Text size="10px" c="dimmed" tt="uppercase">
{location}
</Text>
)}
</Group>
{bitrateLabel && (
<Text size="10px" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{bitrateLabel}
</Text>
)}
</Group>
)}
</Stack>
)}
</Table.Td>
)}
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</div>
{(isAdvanced || height >= 144) && (
<Group
gap="xs"