fix: contain media server streams
This commit is contained in:
3 files changed
+420
-177
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user