Merge slice D: media browse/detail mutation surface (#209)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaBrowseScreen } from './MediaBrowseScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ artwork: '', collectionType: 'Movie', id: 1, mediaType: 'Movie', title: 'Blade Runner' },
|
||||
{ artwork: '', collectionType: 'Movie', id: 2, mediaType: 'Movie', title: 'Alien' }
|
||||
];
|
||||
|
||||
function mockFetch() {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: items, totalCount: items.length }));
|
||||
}
|
||||
if (url === '/api/collections') {
|
||||
return Promise.resolve(jsonResponse([{ collectionType: 'Collection', id: 7, name: 'Favorites', useCustomPlaybackOrder: false }]));
|
||||
}
|
||||
if (url === '/api/playlists/groups') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
if (url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('MediaBrowseScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/app/media?kind=movies');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('renders a per-tile Add-to menu on every card', async () => {
|
||||
mockFetch();
|
||||
render(<MediaBrowseScreen />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' })).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('supports select mode and adds the selected items to a collection', async () => {
|
||||
const fetchSpy = mockFetch();
|
||||
render(<MediaBrowseScreen />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
// No selection bar until we enter select mode and pick something.
|
||||
expect(screen.queryByText(/selected/)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Alien'));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add to collection/ }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument());
|
||||
|
||||
// The first existing collection (Favorites, id 7) is preselected — just submit.
|
||||
await waitFor(() => expect(screen.getByRole('option', { name: 'Favorites' })).toBeTruthy());
|
||||
fireEvent.click(screen.getAllByRole('button', { name: /Add to collection/ }).at(-1)!);
|
||||
|
||||
await waitFor(() => {
|
||||
const addCall = fetchSpy.mock.calls.find(
|
||||
([url, init]) => String(url) === '/api/collections/7/items' && (init as RequestInit | undefined)?.method === 'POST'
|
||||
);
|
||||
expect(addCall).toBeTruthy();
|
||||
const body = JSON.parse(String((addCall![1] as RequestInit).body));
|
||||
expect(body.movieIds).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
it('selects every loaded item with Select all on page', async () => {
|
||||
mockFetch();
|
||||
render(<MediaBrowseScreen />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Blade Runner')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select' }));
|
||||
fireEvent.click(screen.getByText('Blade Runner'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select all on page' }));
|
||||
expect(screen.getByText('2 selected')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }));
|
||||
expect(screen.queryByText(/selected/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, FolderTree, Info, RefreshCw, Search, TriangleAlert } from 'lucide-react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Info,
|
||||
ListChecks,
|
||||
ListVideo,
|
||||
RefreshCw,
|
||||
Search,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
getLibraryBrowseItems,
|
||||
@@ -7,10 +18,15 @@ import {
|
||||
type LibraryBrowseItem,
|
||||
type LibraryBrowseMediaType
|
||||
} from '../api';
|
||||
import { AddToCollectionDialog, AddToMenu, AddToPlaylistDialog } from '../media/addTo';
|
||||
import { MediaPosterCard } from '../media/MediaPosterCard';
|
||||
import { mediaDetailPath } from '../media/mediaKinds';
|
||||
import { navigateToPath } from '../routing';
|
||||
|
||||
function itemKey(item: LibraryBrowseItem): string {
|
||||
return `${item.mediaType}-${item.id}`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
interface MediaKind {
|
||||
@@ -51,6 +67,11 @@ export function MediaBrowseScreen() {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [pageNum, setPageNum] = useState(0);
|
||||
const [state, setState] = useState<BrowseState>({ items: [], error: null, status: 'loading', totalCount: 0 });
|
||||
// Screen-level multi-select. Deliberately opt-in (a "Select" toggle) rather than Blazor's
|
||||
// always-on selection, so the default browse experience stays a plain drill-in.
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selected, setSelected] = useState<Map<string, LibraryBrowseItem>>(new Map());
|
||||
const [bulkDialog, setBulkDialog] = useState<'collection' | 'playlist' | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
const seqRef = useRef(0);
|
||||
|
||||
@@ -66,6 +87,7 @@ export function MediaBrowseScreen() {
|
||||
const handle = window.setTimeout(() => {
|
||||
setPageNum(0);
|
||||
setQuery(queryInput.trim());
|
||||
setSelected(new Map());
|
||||
}, 300);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
@@ -96,6 +118,7 @@ export function MediaBrowseScreen() {
|
||||
setPageNum(0);
|
||||
setQueryInput('');
|
||||
setQuery('');
|
||||
setSelected(new Map());
|
||||
// Keep the URL shareable/back-navigable without remounting the whole screen.
|
||||
window.history.replaceState(null, '', `/app/media?kind=${next.slug}`);
|
||||
};
|
||||
@@ -105,6 +128,42 @@ export function MediaBrowseScreen() {
|
||||
load();
|
||||
};
|
||||
|
||||
const goToPage = (updater: (current: number) => number) => {
|
||||
setPageNum(updater);
|
||||
setSelected(new Map());
|
||||
};
|
||||
|
||||
const toggleSelectMode = () => {
|
||||
setSelectMode((on) => !on);
|
||||
setSelected(new Map());
|
||||
};
|
||||
|
||||
const toggleSelect = (item: LibraryBrowseItem) => {
|
||||
setSelected((current) => {
|
||||
const next = new Map(current);
|
||||
const key = itemKey(item);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.set(key, item);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllOnPage = () => {
|
||||
setSelected((current) => {
|
||||
const next = new Map(current);
|
||||
for (const item of state.items) {
|
||||
next.set(itemKey(item), item);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearSelection = () => setSelected(new Map());
|
||||
|
||||
const selectedItems = Array.from(selected.values());
|
||||
const totalPages = state.status === 'success' ? Math.max(1, Math.ceil(state.totalCount / PAGE_SIZE)) : 1;
|
||||
|
||||
return (
|
||||
@@ -134,6 +193,14 @@ export function MediaBrowseScreen() {
|
||||
Folder Browser
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleSelectMode}
|
||||
size="sm"
|
||||
startIcon={<ListChecks aria-hidden="true" size={14} />}
|
||||
variant={selectMode ? 'primary' : 'secondary'}
|
||||
>
|
||||
{selectMode ? 'Done' : 'Select'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={refresh}
|
||||
size="sm"
|
||||
@@ -144,6 +211,48 @@ export function MediaBrowseScreen() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="ctv-channels-actionbar">
|
||||
<span className="ctv-channels-selected">{selected.size} selected</span>
|
||||
<Button onClick={selectAllOnPage} size="sm" variant="secondary">
|
||||
Select all on page
|
||||
</Button>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<Button
|
||||
onClick={() => setBulkDialog('collection')}
|
||||
size="sm"
|
||||
startIcon={<FolderPlus aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Add to collection
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setBulkDialog('playlist')}
|
||||
size="sm"
|
||||
startIcon={<ListVideo aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Add to playlist
|
||||
</Button>
|
||||
<Button onClick={clearSelection} size="sm" variant="ghost">
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddToCollectionDialog
|
||||
items={selectedItems}
|
||||
onAdded={clearSelection}
|
||||
onClose={() => setBulkDialog(null)}
|
||||
open={bulkDialog === 'collection'}
|
||||
/>
|
||||
<AddToPlaylistDialog
|
||||
items={selectedItems}
|
||||
onAdded={clearSelection}
|
||||
onClose={() => setBulkDialog(null)}
|
||||
open={bulkDialog === 'playlist'}
|
||||
/>
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
@@ -173,9 +282,12 @@ export function MediaBrowseScreen() {
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
actions={<AddToMenu compact items={[item]} />}
|
||||
item={item}
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
onToggleSelect={selectMode ? toggleSelect : undefined}
|
||||
selected={selected.has(itemKey(item))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -187,7 +299,7 @@ export function MediaBrowseScreen() {
|
||||
<span className="ctv-channels-spacer" />
|
||||
<IconButton
|
||||
disabled={pageNum === 0}
|
||||
onClick={() => setPageNum((current) => Math.max(0, current - 1))}
|
||||
onClick={() => goToPage((current) => Math.max(0, current - 1))}
|
||||
size="sm"
|
||||
title="Previous page"
|
||||
>
|
||||
@@ -198,7 +310,7 @@ export function MediaBrowseScreen() {
|
||||
</span>
|
||||
<IconButton
|
||||
disabled={pageNum + 1 >= totalPages}
|
||||
onClick={() => setPageNum((current) => current + 1)}
|
||||
onClick={() => goToPage((current) => current + 1)}
|
||||
size="sm"
|
||||
title="Next page"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MovieDetailScreen, ShowDetailScreen } from './MediaDetailScreen';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
MovieDetailScreen,
|
||||
SeasonDetailScreen,
|
||||
ShowDetailScreen
|
||||
} from './MediaDetailScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
@@ -26,6 +31,67 @@ const movie = {
|
||||
fanArt: ''
|
||||
};
|
||||
|
||||
function showDetail(mediaSourceKind: string) {
|
||||
return {
|
||||
id: 42,
|
||||
libraryId: 3,
|
||||
mediaSourceKind,
|
||||
title: 'The Show',
|
||||
year: '2001',
|
||||
plot: 'Show plot',
|
||||
genres: [],
|
||||
tags: [],
|
||||
studios: [],
|
||||
networks: [],
|
||||
contentRatings: [],
|
||||
languages: [],
|
||||
actors: [],
|
||||
poster: '',
|
||||
fanArt: ''
|
||||
};
|
||||
}
|
||||
|
||||
const season = {
|
||||
id: 8,
|
||||
showId: 42,
|
||||
title: 'The Show',
|
||||
name: 'Season 1',
|
||||
year: '2001',
|
||||
poster: '',
|
||||
fanArt: ''
|
||||
};
|
||||
|
||||
const artist = {
|
||||
id: 11,
|
||||
name: 'The Artist',
|
||||
disambiguation: '',
|
||||
biography: 'Bio',
|
||||
genres: [],
|
||||
styles: [],
|
||||
moods: [],
|
||||
languages: [],
|
||||
thumbnail: '',
|
||||
fanArt: ''
|
||||
};
|
||||
|
||||
// Serves a detail record on its /api/<kind>/<id> route, an empty child browse page, and stubs for
|
||||
// the Add-to dialog loads so opening the plus menu never errors.
|
||||
function mockDetailFetch(detail: unknown) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
}
|
||||
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
if (url.startsWith('/api/libraries/') && url.includes('/scan-show')) {
|
||||
return Promise.resolve(new Response(null, { status: 200 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(detail));
|
||||
});
|
||||
}
|
||||
|
||||
describe('media detail screens', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -50,4 +116,66 @@ describe('media detail screens', () => {
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Show not found.')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('offers an Add-to menu on the movie detail page', async () => {
|
||||
mockDetailFetch(movie);
|
||||
render(<MovieDetailScreen id={5} />);
|
||||
await waitFor(() => expect(screen.getByText('The Movie')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Add to…' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers an Add-to menu on the season and artist detail pages', async () => {
|
||||
mockDetailFetch(season);
|
||||
const { unmount } = render(<SeasonDetailScreen id={8} />);
|
||||
await waitFor(() => expect(screen.getByText('The Show — Season 1')).toBeInTheDocument());
|
||||
expect(screen.getAllByRole('button', { name: 'Add to…' }).length).toBeGreaterThanOrEqual(1);
|
||||
unmount();
|
||||
|
||||
mockDetailFetch(artist);
|
||||
render(<ArtistDetailScreen id={11} />);
|
||||
await waitFor(() => expect(screen.getByText('The Artist')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Add to…' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides per-show scan for Local libraries and shows it for Plex', async () => {
|
||||
mockDetailFetch(showDetail('Local'));
|
||||
const { unmount } = render(<ShowDetailScreen id={42} />);
|
||||
await waitFor(() => expect(screen.getByText('The Show')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: 'Quick scan' })).toBeNull();
|
||||
unmount();
|
||||
|
||||
const fetchSpy = mockDetailFetch(showDetail('Plex'));
|
||||
render(<ShowDetailScreen id={42} />);
|
||||
await waitFor(() => expect(screen.getByText('The Show')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Quick scan' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deep scan' }));
|
||||
await waitFor(() => {
|
||||
const scanCall = fetchSpy.mock.calls.find(([url]) => String(url) === '/api/libraries/3/scan-show');
|
||||
expect(scanCall).toBeTruthy();
|
||||
const body = JSON.parse(String((scanCall![1] as RequestInit).body));
|
||||
expect(body).toMatchObject({ deepScan: true, showTitle: 'The Show' });
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('Scan queued')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('exposes Media Info and Troubleshoot actions on episode cards', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
if (url.startsWith('/api/library/browse')) {
|
||||
return Promise.resolve(
|
||||
jsonResponse({ page: [{ artwork: '', collectionType: 'Episode', id: 91, mediaType: 'Episode', title: 'Pilot' }], totalCount: 1 })
|
||||
);
|
||||
}
|
||||
if (url === '/api/collections' || url === '/api/playlists/groups' || url === '/api/schedules') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(season));
|
||||
});
|
||||
|
||||
render(<SeasonDetailScreen id={8} />);
|
||||
await waitFor(() => expect(screen.getByText('Pilot')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Media Info' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Troubleshoot Playback' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Info, Stethoscope, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Info, ScanSearch, Stethoscope, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Dialog, IconButton, Spinner, Tag } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getShow,
|
||||
messageFromLibraryBrowseError,
|
||||
messageFromMediaDetailError,
|
||||
scanShow,
|
||||
type ArtistDetail,
|
||||
type LibraryBrowseItem,
|
||||
type LibraryBrowseMediaType,
|
||||
@@ -19,12 +20,27 @@ import {
|
||||
type SeasonDetail,
|
||||
type ShowDetail
|
||||
} from '../api';
|
||||
import { AddToMenu } from '../media/addTo';
|
||||
import { MediaPosterCard } from '../media/MediaPosterCard';
|
||||
import { mediaDetailPath, parseDurationSeconds } from '../media/mediaKinds';
|
||||
import { navigateToPath } from '../routing';
|
||||
|
||||
const CHILD_PAGE_SIZE = 60;
|
||||
|
||||
// Media source kinds whose libraries support the single-show rescan endpoint.
|
||||
const REMOTE_SOURCE_KINDS = new Set(['Plex', 'Jellyfin', 'Emby']);
|
||||
|
||||
// Builds a minimal LibraryBrowseItem for a detail page's own subject so it can feed the shared
|
||||
// Add-to menu/dialogs, which only read id/mediaType/title off each item (see toAddItemsRequest /
|
||||
// AddToScheduleDialog). The remaining fields are inert here.
|
||||
function detailBrowseItem(mediaType: LibraryBrowseMediaType, id: number, title: string): LibraryBrowseItem {
|
||||
return { artwork: '', id, mediaType, title } as unknown as LibraryBrowseItem;
|
||||
}
|
||||
|
||||
function troubleshootPath(mediaItemId: number): string {
|
||||
return `/app/troubleshooting/playback?mediaItem=${mediaItemId}`;
|
||||
}
|
||||
|
||||
type DetailState<T> =
|
||||
| { status: 'loading' }
|
||||
| { status: 'notfound' }
|
||||
@@ -182,11 +198,13 @@ function StateWarning({ state, path }: { state: string | null | undefined; path?
|
||||
function ChildGrid({
|
||||
heading,
|
||||
mediaType,
|
||||
parentId
|
||||
parentId,
|
||||
renderActions
|
||||
}: {
|
||||
heading: string;
|
||||
mediaType: LibraryBrowseMediaType;
|
||||
parentId: number;
|
||||
renderActions?: (item: LibraryBrowseItem) => ReactNode;
|
||||
}) {
|
||||
const [items, setItems] = useState<LibraryBrowseItem[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
@@ -262,6 +280,7 @@ function ChildGrid({
|
||||
const detailPath = mediaDetailPath(item);
|
||||
return (
|
||||
<MediaPosterCard
|
||||
actions={renderActions?.(item)}
|
||||
item={item}
|
||||
key={`${item.mediaType}-${item.id}`}
|
||||
onOpen={detailPath ? () => navigateToPath(detailPath) : undefined}
|
||||
@@ -514,14 +533,14 @@ export function MovieDetailScreen({ id }: { id: number }) {
|
||||
Media Info
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigateToPath(`/app/troubleshooting/playback?mediaItem=${id}`)}
|
||||
onClick={() => navigateToPath(troubleshootPath(id))}
|
||||
size="sm"
|
||||
startIcon={<Stethoscope size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Troubleshoot Playback
|
||||
</Button>
|
||||
{/* Add-to-collection / add-to-playlist mutations are out of scope here; tracked by #153 / #155. */}
|
||||
<AddToMenu items={[detailBrowseItem('Movie', id, movie.title)]} />
|
||||
</>
|
||||
}
|
||||
chips={
|
||||
@@ -550,6 +569,67 @@ export function MovieDetailScreen({ id }: { id: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ShowScanControls({ show }: { show: ShowDetail }) {
|
||||
const [scanning, setScanning] = useState<false | 'quick' | 'deep'>(false);
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runScan = (deepScan: boolean) => {
|
||||
setScanning(deepScan ? 'deep' : 'quick');
|
||||
setMessage(null);
|
||||
scanShow(show.libraryId, { deepScan, showTitle: show.title })
|
||||
.then(() => {
|
||||
if (activeRef.current) {
|
||||
setScanning(false);
|
||||
setMessage({ kind: 'ok', text: 'Scan queued' });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setScanning(false);
|
||||
setMessage({ kind: 'error', text: messageFromMediaDetailError(error, 'Unable to queue scan') });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
disabled={scanning !== false}
|
||||
loading={scanning === 'quick'}
|
||||
onClick={() => runScan(false)}
|
||||
size="sm"
|
||||
startIcon={<ScanSearch size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Quick scan
|
||||
</Button>
|
||||
<Button
|
||||
disabled={scanning !== false}
|
||||
loading={scanning === 'deep'}
|
||||
onClick={() => runScan(true)}
|
||||
size="sm"
|
||||
startIcon={<ScanSearch size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Deep scan
|
||||
</Button>
|
||||
{message ? (
|
||||
<span className={message.kind === 'error' ? 'ctv-field-error' : 'ctv-channels-selected'} role={message.kind === 'error' ? 'alert' : 'status'}>
|
||||
{message.text}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShowDetailScreen({ id }: { id: number }) {
|
||||
const state = useDetail<ShowDetail>(useCallback(() => getShow(id), [id]));
|
||||
|
||||
@@ -564,6 +644,7 @@ export function ShowDetailScreen({ id }: { id: number }) {
|
||||
}
|
||||
|
||||
const show = state.data;
|
||||
const canScanShow = REMOTE_SOURCE_KINDS.has(show.mediaSourceKind);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -571,6 +652,12 @@ export function ShowDetailScreen({ id }: { id: number }) {
|
||||
Back
|
||||
</Button>
|
||||
<DetailShell
|
||||
actions={
|
||||
<>
|
||||
<AddToMenu items={[detailBrowseItem('TelevisionShow', id, show.title)]} />
|
||||
{canScanShow ? <ShowScanControls show={show} /> : null}
|
||||
</>
|
||||
}
|
||||
chips={
|
||||
<div className="ctv-detail-chipstack">
|
||||
<ChipRow label="Genres" values={show.genres} />
|
||||
@@ -588,7 +675,12 @@ export function ShowDetailScreen({ id }: { id: number }) {
|
||||
title={show.title}
|
||||
>
|
||||
<ActorsRow actors={show.actors} />
|
||||
<ChildGrid heading="Seasons" mediaType="TelevisionSeason" parentId={id} />
|
||||
<ChildGrid
|
||||
heading="Seasons"
|
||||
mediaType="TelevisionSeason"
|
||||
parentId={id}
|
||||
renderActions={(item) => <AddToMenu compact items={[item]} />}
|
||||
/>
|
||||
</DetailShell>
|
||||
</>
|
||||
);
|
||||
@@ -596,6 +688,7 @@ export function ShowDetailScreen({ id }: { id: number }) {
|
||||
|
||||
export function SeasonDetailScreen({ id }: { id: number }) {
|
||||
const state = useDetail<SeasonDetail>(useCallback(() => getSeason(id), [id]));
|
||||
const [infoItemId, setInfoItemId] = useState<number | null>(null);
|
||||
|
||||
if (state.status === 'loading') {
|
||||
return <Loading />;
|
||||
@@ -608,6 +701,7 @@ export function SeasonDetailScreen({ id }: { id: number }) {
|
||||
}
|
||||
|
||||
const season = state.data;
|
||||
const seasonTitle = `${season.title} — ${season.name}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -620,13 +714,34 @@ export function SeasonDetailScreen({ id }: { id: number }) {
|
||||
Back to show
|
||||
</Button>
|
||||
<DetailShell
|
||||
actions={<AddToMenu items={[detailBrowseItem('TelevisionSeason', id, seasonTitle)]} />}
|
||||
fanart={season.fanArt}
|
||||
poster={season.poster}
|
||||
subtitle={season.year}
|
||||
title={`${season.title} — ${season.name}`}
|
||||
title={seasonTitle}
|
||||
>
|
||||
<ChildGrid heading="Episodes" mediaType="Episode" parentId={id} />
|
||||
<ChildGrid
|
||||
heading="Episodes"
|
||||
mediaType="Episode"
|
||||
parentId={id}
|
||||
renderActions={(item) => (
|
||||
<>
|
||||
<AddToMenu compact items={[item]} />
|
||||
<IconButton onClick={() => setInfoItemId(item.id)} size="sm" title="Media Info">
|
||||
<Info aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => navigateToPath(troubleshootPath(item.id))} size="sm" title="Troubleshoot Playback">
|
||||
<Stethoscope aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</DetailShell>
|
||||
<MediaInfoDialog
|
||||
mediaItemId={infoItemId ?? 0}
|
||||
onClose={() => setInfoItemId(null)}
|
||||
open={infoItemId !== null}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -652,6 +767,7 @@ export function ArtistDetailScreen({ id }: { id: number }) {
|
||||
Back
|
||||
</Button>
|
||||
<DetailShell
|
||||
actions={<AddToMenu items={[detailBrowseItem('Artist', id, artist.name)]} />}
|
||||
chips={
|
||||
<div className="ctv-detail-chipstack">
|
||||
<ChipRow label="Genres" values={artist.genres} />
|
||||
@@ -666,7 +782,12 @@ export function ArtistDetailScreen({ id }: { id: number }) {
|
||||
subtitle={artist.disambiguation}
|
||||
title={artist.name}
|
||||
>
|
||||
<ChildGrid heading="Music Videos" mediaType="MusicVideo" parentId={id} />
|
||||
<ChildGrid
|
||||
heading="Music Videos"
|
||||
mediaType="MusicVideo"
|
||||
parentId={id}
|
||||
renderActions={(item) => <AddToMenu compact items={[item]} />}
|
||||
/>
|
||||
</DetailShell>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user