diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/Media.tsx
index e6dd536..3bbf835 100644
--- a/frontend/src/pages/Media.tsx
+++ b/frontend/src/pages/Media.tsx
@@ -9,6 +9,10 @@ import type {
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
+import {
+ MobileCardRow,
+ type MobileCardField,
+} from "@/components/ui/mobile-card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -31,6 +35,7 @@ import {
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
+import { useIsMobile } from "../hooks/useIsMobile";
import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
@@ -75,6 +80,116 @@ function getMediaRowId(row: MediaItem): string {
return row.path;
}
+// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
+// Title is the primary identifier; size/HDR/library/year give the at-a-glance
+// tech + context info a user scanning the library on a phone needs. Runtime,
+// bitrate, resolution, codec etc. live on the desktop table only.
+const mediaCardFields: MobileCardField[] = [
+ { key: "title", label: "Title", render: (r) => r.title, primary: true },
+ { key: "size", label: "Size", render: (r) => r.size || "-" },
+ {
+ key: "hdr",
+ label: "HDR",
+ render: (r) => r.hdr || "-",
+ },
+ { key: "library", label: "Library", render: (r) => r.library || "-" },
+ {
+ key: "year",
+ label: "Year",
+ render: (r) => (r.year != null ? String(r.year) : "-"),
+ },
+];
+
+// Standalone pagination for the mobile card layout. The DataTable renders its
+// own pagination internally; this mirrors that UI (rows count, page-size
+// select, page indicator, prev/next) but works off the raw pagination state
+// instead of a TanStack table instance. See spec R3.3.
+function MediaMobilePagination({
+ pageIndex,
+ pageSize,
+ pageSizeOptions,
+ totalRows,
+ pageCount,
+ onPaginationChange,
+}: {
+ pageIndex: number;
+ pageSize: number;
+ pageSizeOptions: number[];
+ totalRows: number;
+ pageCount: number;
+ onPaginationChange: OnChangeFn;
+}) {
+ return (
+
+
+ {`${totalRows} row${totalRows === 1 ? "" : "s"}`}
+
+
+
+ Rows per page
+
+
+
+ Page {pageIndex + 1} of {pageCount}
+
+
+
+
+
+
+
+ );
+}
+
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
@@ -178,6 +293,7 @@ export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isSmall = usePrefersSmallScreen();
+ const isMobile = useIsMobile();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
@@ -532,33 +648,55 @@ export function Media() {
)}
- {status?.exists && (
-
-
-
- )}
+ {status?.exists &&
+ (isMobile ? (
+
+
+
+
+ {queryResult && (
+
+ )}
+
+ ) : (
+
+
+
+ ))}
);
}
diff --git a/frontend/src/pages/__tests__/Media.test.tsx b/frontend/src/pages/__tests__/Media.test.tsx
index 47ff5e1..571dafe 100644
--- a/frontend/src/pages/__tests__/Media.test.tsx
+++ b/frontend/src/pages/__tests__/Media.test.tsx
@@ -129,7 +129,11 @@ vi.mock("../../hooks/useDashboard", () => ({
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
+// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
+// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
+// so the DataTable path renders by default; mobile tests override.
beforeEach(() => {
+ setMatchMedia(false);
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
@@ -152,6 +156,30 @@ beforeEach(() => {
};
});
+/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
+function setMatchMedia(matches: boolean) {
+ const listeners: ((e: MediaQueryListEvent) => void)[] = [];
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query.includes("768") ? matches : false,
+ media: query,
+ onchange: null,
+ addEventListener: (
+ _evt: string,
+ listener: (e: MediaQueryListEvent) => void,
+ ) => listeners.push(listener),
+ removeEventListener: (
+ _evt: string,
+ listener: (e: MediaQueryListEvent) => void,
+ ) => {
+ const idx = listeners.indexOf(listener);
+ if (idx >= 0) listeners.splice(idx, 1);
+ },
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }));
+}
+
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render();
@@ -261,3 +289,68 @@ describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", (
).toBeInTheDocument();
});
});
+
+describe("Media (mobile card layout — slice 3)", () => {
+ it("renders cards with the title as primary below md", () => {
+ setMatchMedia(true);
+ render();
+
+ // Card titles render (primary field).
+ expect(screen.getByText("Inception")).toBeInTheDocument();
+ expect(screen.getByText("Matrix")).toBeInTheDocument();
+
+ // Card field labels render (at least once per row).
+ expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
+
+ // Desktop table headers do NOT render on mobile.
+ expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
+ expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
+ });
+
+ it("hides the column-visibility toggle below md", () => {
+ setMatchMedia(true);
+ render();
+
+ expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
+ });
+
+ it("renders pagination controls below the cards on mobile", () => {
+ setMatchMedia(true);
+ render();
+
+ expect(screen.getByText("2 rows")).toBeInTheDocument();
+ expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
+ expect(
+ screen.getByRole("button", { name: "Previous page" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Next page" }),
+ ).toBeInTheDocument();
+ });
+
+ it("navigates to the file browser when a card is tapped on mobile", async () => {
+ setMatchMedia(true);
+ render();
+
+ await userEvent.click(screen.getByText("Inception"));
+
+ expect(navigate).toHaveBeenCalledTimes(1);
+ expect(navigate).toHaveBeenCalledWith(
+ `/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
+ );
+ });
+
+ it("renders the DataTable (not cards) at desktop width", () => {
+ render();
+
+ // Desktop column headers render.
+ expect(
+ screen.getByRole("columnheader", { name: "Title" }),
+ ).toBeInTheDocument();
+ // Column-visibility toggle is present.
+ expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
+ });
+});