diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index f8de2cf43..6beb289a7 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -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; + } +} diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index edf69e044..22997fe48 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -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; diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index f29181c61..35ec77f86 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -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("[data-grid-item-content]"); + const paper = element.querySelector(".mrt-table-paper"); + const container = element.querySelector(".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 diff --git a/packages/widgets/src/media-server/component.module.css b/packages/widgets/src/media-server/component.module.css index 321f7ddfa..daeb987b2 100644 --- a/packages/widgets/src/media-server/component.module.css +++ b/packages/widgets/src/media-server/component.module.css @@ -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; } diff --git a/packages/widgets/src/media-server/component.spec.tsx b/packages/widgets/src/media-server/component.spec.tsx index ee941eac1..2c79d29cf 100644 --- a/packages/widgets/src/media-server/component.spec.tsx +++ b/packages/widgets/src/media-server/component.spec.tsx @@ -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 }); + }); +}); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index ea0043b53..a3342cf35 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -44,6 +44,11 @@ type TranscodingDecision = NonNullable ({ + 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(null); const columns = useMemo[]>(() => { const result: MRT_ColumnDef[] = [ - ...(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 }) => ( - - - - - {row.original.user?.username ?? t("items.unknownUser")} - - - {row.original.sessionName} - - - - ), - } satisfies MRT_ColumnDef, - ] - : []), + 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"), @@ -132,52 +134,50 @@ export default function MediaServerWidget({ ); }, } satisfies MRT_ColumnDef, - ...(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 ( - - - {t(`items.${status}` as never)} - - {(location ?? bitrateLabel) && ( - - - {location && (location === "lan" ? : )} - {location && ( - - {location} - - )} - - {bitrateLabel && ( - - {bitrateLabel} - - )} - + return ( + + + {t(`items.${status}` as never)} + + {(location ?? bitrateLabel) && ( + + + {location && (location === "lan" ? : )} + {location && ( + + {location} + )} - - ); - }, - } satisfies MRT_ColumnDef, - ] - : []), + + {bitrateLabel && ( + + {bitrateLabel} + + )} + + )} + + ); + }, + }, ]; 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", },