fix: stabilize media streams and edit hover
This commit is contained in:
6 files changed
+176
-71
No files matched your search
@@ -2,11 +2,11 @@
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transform: translateY(calc(-100% - 2px));
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
transform 150ms ease;
|
||||
@@ -15,7 +15,7 @@
|
||||
:global([data-type="item"]:not([data-kind="app"]):hover) .wrapper,
|
||||
:global([data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translateY(calc(-100% - 8px));
|
||||
}
|
||||
|
||||
.panel {
|
||||
@@ -31,7 +31,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
padding: 4px 36px 4px 8px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background-color: var(--background-color);
|
||||
@@ -124,3 +124,9 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wrapper {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.board-grid-entry:has([data-board-widget-header]):hover,
|
||||
.board-grid-entry:has([data-board-widget-header]):focus-within {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.board-grid-entry[data-grid-preview="true"] {
|
||||
width: var(--board-grid-preview-width) !important;
|
||||
height: var(--board-grid-preview-height) !important;
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
apps,
|
||||
boardUserPermissions,
|
||||
boards,
|
||||
integrationItems,
|
||||
integrations,
|
||||
itemLayouts,
|
||||
items,
|
||||
layouts,
|
||||
@@ -253,6 +255,56 @@ describe("Board advanced interactions", () => {
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps media streams inside the widget and edit metadata above it", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
try {
|
||||
const mediaWidget = page.locator("[data-grid-item-id][data-kind='mediaServer']").first();
|
||||
await expect(mediaWidget.getByText("Interstellar")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const geometry = await mediaWidget.evaluate((element) => {
|
||||
const card = element.querySelector<HTMLElement>("[data-grid-item-content]");
|
||||
const paper = element.querySelector<HTMLElement>(".mrt-table-paper");
|
||||
const container = element.querySelector<HTMLElement>(".mrt-table-container");
|
||||
if (!card || !paper || !container) throw new Error("Media stream table did not render");
|
||||
|
||||
const cardBounds = card.getBoundingClientRect();
|
||||
const paperBounds = paper.getBoundingClientRect();
|
||||
const containerBounds = container.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
card: { top: cardBounds.top, bottom: cardBounds.bottom },
|
||||
paper: { top: paperBounds.top, bottom: paperBounds.bottom },
|
||||
container: { bottom: containerBounds.bottom },
|
||||
paperOverflow: getComputedStyle(paper).overflow,
|
||||
containerOverflowY: getComputedStyle(container).overflowY,
|
||||
};
|
||||
});
|
||||
|
||||
expect(geometry.paper.top).toBeGreaterThanOrEqual(geometry.card.top - 1);
|
||||
expect(geometry.paper.bottom).toBeLessThanOrEqual(geometry.card.bottom + 1);
|
||||
expect(geometry.container.bottom).toBeLessThanOrEqual(geometry.card.bottom + 1);
|
||||
expect(geometry.paperOverflow).toBe("hidden");
|
||||
expect(geometry.containerOverflowY).toBe("auto");
|
||||
|
||||
const editToggle = page.getByTestId("board-edit-mode-toggle");
|
||||
await editToggle.click();
|
||||
await expect(editToggle).toHaveAttribute("aria-pressed", "true", { timeout: 15_000 });
|
||||
|
||||
const mediaEntry = page.locator(".board-grid-entry:has([data-kind='mediaServer'])").first();
|
||||
const hoverHeader = mediaEntry.locator("[data-board-widget-header]");
|
||||
await mediaEntry.hover();
|
||||
await expect(hoverHeader).toBeVisible();
|
||||
|
||||
const entryBounds = await mediaEntry.boundingBox();
|
||||
const headerBounds = await hoverHeader.boundingBox();
|
||||
if (!entryBounds || !headerBounds) throw new Error("Media widget hover header has no bounds");
|
||||
expect(headerBounds.y + headerBounds.height).toBeLessThanOrEqual(entryBounds.y - 2);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => {
|
||||
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
|
||||
|
||||
@@ -379,6 +431,8 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
const bookmarksItemId = createId();
|
||||
const iframeItemId = createId();
|
||||
const appItemId = createId();
|
||||
const mediaServerItemId = createId();
|
||||
const integrationId = createId();
|
||||
const appId = createId();
|
||||
|
||||
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
|
||||
@@ -388,6 +442,13 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
iconUrl: "/favicon.ico",
|
||||
href: "https://example.com",
|
||||
});
|
||||
await db.insert(integrations).values({
|
||||
id: integrationId,
|
||||
name: "Demo Integration",
|
||||
url: "https://demo.homarr.dev",
|
||||
kind: "mock",
|
||||
appId: null,
|
||||
});
|
||||
await db.insert(layouts).values([
|
||||
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
|
||||
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
|
||||
@@ -431,7 +492,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
{
|
||||
id: mediaServerItemId,
|
||||
kind: "mediaServer",
|
||||
boardId,
|
||||
options: stringify({ showOnlyPlaying: true, showBitrate: true, showLocation: true }),
|
||||
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
|
||||
},
|
||||
]);
|
||||
await db.insert(integrationItems).values({ itemId: mediaServerItemId, integrationId });
|
||||
await db.insert(itemLayouts).values(
|
||||
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
|
||||
{
|
||||
@@ -470,6 +539,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
|
||||
width: 3,
|
||||
height: 3,
|
||||
},
|
||||
{
|
||||
itemId: mediaServerItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 4,
|
||||
yOffset: 3,
|
||||
width: 8,
|
||||
height: 3,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await db
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
|
||||
.tablePaper {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -15,6 +17,8 @@
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { StreamSession } from "@homarr/integrations";
|
||||
|
||||
import { SessionDetailsPopover } from "./component";
|
||||
import { getMediaServerColumnVisibility, SessionDetailsPopover } from "./component";
|
||||
|
||||
vi.mock("@homarr/translation/client", () => ({
|
||||
useScopedI18n: () => (key: string) => key,
|
||||
@@ -99,3 +99,12 @@ describe("SessionDetailsPopover", () => {
|
||||
expect(target?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMediaServerColumnVisibility", () => {
|
||||
test("keeps a stable responsive column model from the zero-sized first render", () => {
|
||||
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
|
||||
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
|
||||
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true });
|
||||
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,11 @@ type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlayi
|
||||
|
||||
type PlaybackStatus = "directPlay" | "directStream" | "transcodeVideo" | "transcodeAudio" | "transcoding";
|
||||
|
||||
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
|
||||
user: isAdvanced || width >= 300,
|
||||
status: isAdvanced || width >= 420,
|
||||
});
|
||||
|
||||
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
|
||||
if (!transcoding) return "directPlay";
|
||||
const { isVideoDirect, isAudioDirect, containerChanged } = transcoding;
|
||||
@@ -89,33 +94,30 @@ export default function MediaServerWidget({
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
|
||||
const result: MRT_ColumnDef<StreamSession>[] = [
|
||||
...(width >= 300 || isAdvanced
|
||||
? [
|
||||
{
|
||||
accessorKey: "user.username",
|
||||
header: t("items.user"),
|
||||
size: 160,
|
||||
{
|
||||
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>
|
||||
),
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
]
|
||||
: []),
|
||||
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"),
|
||||
@@ -132,52 +134,50 @@ export default function MediaServerWidget({
|
||||
);
|
||||
},
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
...(width >= 420 || isAdvanced
|
||||
? [
|
||||
{
|
||||
id: "status",
|
||||
header: t("items.status"),
|
||||
size: 110,
|
||||
{
|
||||
id: "status",
|
||||
header: t("items.status"),
|
||||
size: 110,
|
||||
|
||||
Cell: ({ row }) => {
|
||||
const currentlyPlaying = row.original.currentlyPlaying;
|
||||
if (!currentlyPlaying) return null;
|
||||
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;
|
||||
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>
|
||||
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>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
} satisfies MRT_ColumnDef<StreamSession>,
|
||||
]
|
||||
: []),
|
||||
</Group>
|
||||
{bitrateLabel && (
|
||||
<Text size="10px" c="dimmed">
|
||||
{bitrateLabel}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
return result;
|
||||
}, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]);
|
||||
}, [selectedRowId, showBitrate, showLocation, t]);
|
||||
|
||||
const columnVisibility = getMediaServerColumnVisibility(width, isAdvanced);
|
||||
|
||||
// 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
|
||||
@@ -215,6 +215,9 @@ export default function MediaServerWidget({
|
||||
initialState: {
|
||||
density: "xs",
|
||||
},
|
||||
state: {
|
||||
columnVisibility,
|
||||
},
|
||||
mantineTableHeadProps: {
|
||||
fz: "xs",
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user