Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md

Large diffs are not rendered by default.

66 changes: 53 additions & 13 deletions client/src/components/PlaylistPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { usePlaylistDetail, PlaylistSortOrder, PlaylistDownloadState, PlaylistWa
import { useDownloadListingsRefresh } from '../hooks/useDownloadListingsRefresh';
import { usePlaylistMutations } from '../hooks/usePlaylistMutations';
import { useMediaServerStatus } from '../hooks/useMediaServerStatus';
import { MediaServerType, PlaylistSubscribeSettings } from '../types/playlist';
import { MediaServerType, PlaylistSubscribeSettings, PlaylistVideo } from '../types/playlist';
import PlaylistHeader from './PlaylistPage/components/PlaylistHeader';
import NoMediaServerWarning from './PlaylistPage/components/NoMediaServerWarning';
import PlaylistVideoList from './PlaylistPage/components/PlaylistVideoList';
Expand All @@ -30,10 +30,12 @@ import PlaylistWatchedFilterControl from './PlaylistPage/components/PlaylistWatc
import { useVideoSelection } from './shared/VideoList/hooks/useVideoSelection';
import VideoListSelectionPill from './shared/VideoList/VideoListSelectionPill';
import { SelectionAction } from './shared/VideoList/types';
import { Download as DownloadIcon } from '../lib/icons';
import { Delete as DeleteIcon, Download as DownloadIcon } from '../lib/icons';
import PlaylistFollowingDialog, { FollowingDialogMode } from './PlaylistPage/components/PlaylistFollowingDialog';
import PlaylistSettingsDialog from './PlaylistPage/components/PlaylistSettingsDialog';
import { toDownloadFileProps, toModalData } from './PlaylistPage/components/playlistVideoHelpers';
import { isDeletable, isDownloadable, toModalData } from './PlaylistPage/components/playlistVideoHelpers';
import { usePlaylistVideoDeletion } from './PlaylistPage/hooks/usePlaylistVideoDeletion';
import DeleteVideosDialog from './shared/DeleteVideosDialog';
import DownloadSettingsDialog from './DownloadManager/ManualDownload/DownloadSettingsDialog';
import { DownloadSettings } from './DownloadManager/ManualDownload/types';
import SubscriptionsBackButton from './shared/SubscriptionsBackButton';
Expand Down Expand Up @@ -160,11 +162,26 @@ function PlaylistPage({ token }: PlaylistPageProps) {
const selectionClearRef = useRef<() => void>(() => {});
const loadMoreRef = useRef<HTMLDivElement | null>(null);

// One selection holds downloaded and not-downloaded videos; each action
// applies to the selected videos it fits.
const videosById = useMemo(() => new Map(videos.map((v) => [v.youtube_id, v])), [videos]);
const idsThatFit = useCallback(
(ids: string[], fits: (video: PlaylistVideo) => boolean) =>
ids.filter((id) => {
const video = videosById.get(id);
return Boolean(video && fits(video));
}),
[videosById]
);

const deletion = usePlaylistVideoDeletion({ token, markVideoDeleted, refetchMeta });

const handleDownloadSelected = useCallback((ids: string[]) => {
if (!ids.length) return;
setPendingDownload({ mode: 'selected', ids });
const downloadable = idsThatFit(ids, isDownloadable);
if (!downloadable.length) return;
setPendingDownload({ mode: 'selected', ids: downloadable });
setDownloadDialogOpen(true);
}, []);
}, [idsThatFit]);

const handleConfirmDownload = useCallback(
async (settings: DownloadSettings | null) => {
Expand Down Expand Up @@ -204,22 +221,29 @@ function PlaylistPage({ token }: PlaylistPageProps) {
return videos.filter((v) => ids.has(v.youtube_id) && v.previously_downloaded).length;
}, [pendingDownload, videos]);

const downloadActions = useMemo<SelectionAction<string>[]>(
const selectionActions = useMemo<SelectionAction<string>[]>(
() => [
{
id: 'download',
label: 'Download Selected',
label: 'Download',
intent: 'success',
icon: <DownloadIcon size={14} />,
onClick: (ids) => {
void handleDownloadSelected(ids);
},
disabled: (ids) => idsThatFit(ids, isDownloadable).length === 0,
onClick: handleDownloadSelected,
},
{
id: 'delete',
label: 'Delete',
intent: 'danger',
icon: <DeleteIcon size={14} />,
disabled: (ids) => deletion.loading || idsThatFit(ids, isDeletable).length === 0,
onClick: (ids) => deletion.requestDelete(idsThatFit(ids, isDeletable)),
},
],
[handleDownloadSelected]
[handleDownloadSelected, idsThatFit, deletion.loading, deletion.requestDelete]
);

const selection = useVideoSelection<string>({ actions: downloadActions });
const selection = useVideoSelection<string>({ actions: selectionActions });
useEffect(() => {
const eligible = selection.selectedIds.filter(id => !snapshot.videos[id]?.state);
if (eligible.length !== selection.selectedIds.length) selection.set(eligible);
Expand Down Expand Up @@ -316,6 +340,14 @@ function PlaylistPage({ token }: PlaylistPageProps) {
[playlist, ignoreVideo, markVideoIgnored, refetchMeta, videos, showSnackbar]
);

const handleConfirmDelete = async () => {
const outcome = await deletion.confirmDelete();
if (!outcome) return;
showSnackbar(outcome.message, outcome.severity);
// Nothing deleted: keep the selection so the user can retry.
if (outcome.deletedIds.length > 0) selection.clear();
};

const handleVideoDeleted = useCallback(
(ytId: string) => {
markVideoDeleted(ytId);
Expand Down Expand Up @@ -547,6 +579,14 @@ function PlaylistPage({ token }: PlaylistPageProps) {
</Alert>
</Snackbar>

<DeleteVideosDialog
open={deletion.dialogOpen}
onClose={deletion.cancelDelete}
onConfirm={() => { void handleConfirmDelete(); }}
videoCount={deletion.pendingCount}
skippedCount={selection.count - deletion.pendingCount}
/>

{modalVideo && (
<VideoModal
open
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail';
import { Button, Card, CardContent, Checkbox, Chip, Tooltip, Typography } from '../../ui';
import { PlaylistVideo } from '../../../types/playlist';
import { formatDurationClock } from '../../../utils';
import { isDownloadable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator';
import WatchedChip from '../../shared/WatchedChip';
import { SHARED_STATUS_CHIP_SMALL_STYLE, SHARED_COMPACT_CHIP_OVERRIDES } from '../../shared/chipStyles';
Expand Down Expand Up @@ -35,7 +35,7 @@ const PlaylistVideoCard: React.FC<PlaylistVideoCardProps> = ({
onToggle,
}) => {
const status = statusLabel(video);
const selectable = isDownloadable(video);
const selectable = isSelectable(video);
const isPending = pendingId === video.youtube_id;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import { PlaylistVideo } from '../../../types/playlist';
import PlaylistVideoTable from './PlaylistVideoTable';
import PlaylistVideoCard from './PlaylistVideoCard';

// Account for the sidebar and page gutters, not just the viewport breakpoint.
const MIN_TABLE_WIDTH = 1200;
// Measured on the list itself, so the sidebar and page gutters are accounted
// for. Below this the title column gets too narrow and cards read better.
const MIN_TABLE_WIDTH = 720;
// Used only until the list is measured: roughly the viewport width below
// which the list is narrower than MIN_TABLE_WIDTH with the top navigation.
const NARROW_VIEWPORT_QUERY = '(max-width: 899px)';

interface PlaylistVideoListProps {
sortOrder?: PlaylistSortOrder;
Expand Down Expand Up @@ -36,7 +40,7 @@ const PlaylistVideoList: React.FC<PlaylistVideoListProps> = ({
onSelectAll,
onClearSelection,
}) => {
const isNarrowViewport = useMediaQuery('(max-width: 1279px)');
const isNarrowViewport = useMediaQuery(NARROW_VIEWPORT_QUERY);
const listRef = useRef<HTMLDivElement>(null);
const [isNarrowList, setIsNarrowList] = useState<boolean | null>(null);
const showEmpty = !loading && videos.length === 0;
Expand Down
112 changes: 61 additions & 51 deletions client/src/components/PlaylistPage/components/PlaylistVideoTable.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React from 'react';
import { PlaylistSortOrder } from '../../../hooks/usePlaylistDetail';
import {
Button,
Checkbox,
Chip,
IconButton,
Table,
TableBody,
TableCell,
Expand All @@ -14,7 +14,8 @@ import {
} from '../../ui';
import { PlaylistVideo } from '../../../types/playlist';
import { formatDurationClock } from '../../../utils';
import { isDownloadable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import { Block as BlockIcon, CheckCircleOutline as CheckCircleOutlineIcon } from '../../../lib/icons';
import { isSelectable, statusLabel, PlaylistVideoDates, toDownloadFileProps } from './playlistVideoHelpers';
import DownloadFormatIndicator from '../../shared/DownloadFormatIndicator';
import WatchedChip from '../../shared/WatchedChip';

Expand Down Expand Up @@ -46,56 +47,88 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({
onSelectAll,
onClearSelection,
}) => {
const downloadableIds = videos.filter(isDownloadable).map((v) => v.youtube_id);
const selectedDownloadable = downloadableIds.filter(isSelected);
const allSelected =
downloadableIds.length > 0 && selectedDownloadable.length === downloadableIds.length;
const someSelected =
selectedDownloadable.length > 0 && selectedDownloadable.length < downloadableIds.length;
const selectableIds = videos.filter(isSelectable).map((v) => v.youtube_id);
const selectedCount = selectableIds.filter(isSelected).length;
const allSelected = selectableIds.length > 0 && selectedCount === selectableIds.length;
const someSelected = selectedCount > 0 && selectedCount < selectableIds.length;

// Fixed columns stay narrow so the title column keeps room down to
// PlaylistVideoList's MIN_TABLE_WIDTH; the title column takes the rest.
return (
<Table size="small" className="table-fixed">
<TableHead>
<TableRow>
<TableCell component="th" className="w-[44px]">
<TableCell component="th" className="w-[48px]">
<Checkbox
indeterminate={someSelected}
checked={allSelected}
onChange={(e) => (e.target.checked ? onSelectAll(downloadableIds) : onClearSelection())}
aria-label="Select all downloadable videos"
onChange={(e) => (e.target.checked ? onSelectAll(selectableIds) : onClearSelection())}
aria-label="Select all videos"
/>
</TableCell>
<TableCell component="th" className="w-[60px]">#</TableCell>
<TableCell component="th" className="w-[48px]">#</TableCell>
<TableCell component="th" className="w-[140px]">Thumbnail</TableCell>
<TableCell component="th">Title</TableCell>
<TableCell component="th" className="w-[18%]">Channel</TableCell>
<TableCell component="th" className="w-[180px]">Dates</TableCell>
<TableCell component="th" className="w-[90px] whitespace-nowrap">Duration</TableCell>
<TableCell component="th" className="w-[140px] whitespace-nowrap">Status</TableCell>
<TableCell component="th" className="w-[120px] whitespace-nowrap" align="right">Actions</TableCell>
<TableCell component="th" className="w-[112px]">Dates</TableCell>
<TableCell component="th" className="w-[72px] whitespace-nowrap">Duration</TableCell>
<TableCell component="th" className="w-[120px] whitespace-nowrap">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{videos.map((v) => {
const label = v.title || v.youtube_id;
const isPending = pendingId === v.youtube_id;
const status = statusLabel(v);
const selectable = isDownloadable(v);
const selectable = isSelectable(v);
return (
<TableRow
key={`${v.playlist_id}-${v.youtube_id}`}
hover
onClick={() => onVideoClick(v)}
className="cursor-pointer"
aria-label={`Open ${v.title || v.youtube_id}`}
aria-label={`Open ${label}`}
>
<TableCell onClick={(e) => e.stopPropagation()}>
{selectable && (
<Checkbox
checked={isSelected(v.youtube_id)}
onChange={() => onToggle(v.youtube_id)}
aria-label={`Select ${v.title || v.youtube_id}`}
/>
)}
<div className="flex flex-col items-center gap-0.5">
{selectable && (
<Checkbox
checked={isSelected(v.youtube_id)}
onChange={() => onToggle(v.youtube_id)}
aria-label={`Select ${label}`}
/>
)}
{v.ignored ? (
<Tooltip title="Include this video in this playlist's downloads and server sync again">
<IconButton
size="sm"
color="warning"
aria-label={`Include ${label}`}
onClick={() => onUnignore(v.youtube_id)}
disabled={isPending}
>
<CheckCircleOutlineIcon />
</IconButton>
</Tooltip>
) : (
<Tooltip
title={
v.downloaded
? 'Exclude from this playlist: removed from synced server playlists and M3U files; the file stays on disk'
: 'Exclude from this playlist: skipped when downloading this playlist'
}
>
<IconButton
size="sm"
className="text-muted-foreground"
aria-label={`Exclude ${label}`}
onClick={() => onIgnore(v.youtube_id)}
disabled={isPending}
>
<BlockIcon />
</IconButton>
</Tooltip>
)}
</div>
</TableCell>
<TableCell>{v.position}</TableCell>
<TableCell>
Expand All @@ -116,11 +149,9 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({
</TableCell>
<TableCell>
<Typography variant="body2" className="line-clamp-2 whitespace-normal">
{v.title || v.youtube_id}
{label}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" className="text-muted-foreground line-clamp-1">
<Typography variant="caption" className="block mt-0.5 text-muted-foreground line-clamp-1">
{v.channel_name || '-'}
</Typography>
</TableCell>
Expand Down Expand Up @@ -149,27 +180,6 @@ const PlaylistVideoTable: React.FC<PlaylistVideoTableProps> = ({
<WatchedChip watchedBy={v.watched_by || []} />
</div>
</TableCell>
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{v.ignored ? (
<Tooltip title="Include this video in this playlist's downloads and server sync again">
<Button size="sm" variant="outlined" onClick={() => onUnignore(v.youtube_id)} disabled={isPending}>
Include
</Button>
</Tooltip>
) : (
<Tooltip
title={
v.downloaded
? 'Exclude from this playlist: removed from synced server playlists and M3U files; the file stays on disk'
: 'Exclude from this playlist: skipped when downloading this playlist'
}
>
<Button size="sm" variant="outlined" onClick={() => onIgnore(v.youtube_id)} disabled={isPending}>
Exclude
</Button>
</Tooltip>
)}
</TableCell>
</TableRow>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@ describe('PlaylistVideoCard', () => {
expect(onToggle).toHaveBeenCalledWith('dl1');
});

test('renders no checkbox for an already-downloaded video', () => {
render(<PlaylistVideoCard {...baseProps} video={makeVideo({ downloaded: true })} />);
test('renders a checkbox for a downloaded video so it can be deleted', () => {
const onToggle = jest.fn();
render(<PlaylistVideoCard {...baseProps} onToggle={onToggle} video={makeVideo({ youtube_id: 'done', downloaded: true })} />);
fireEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith('done');
});

test('renders no checkbox while the video is downloading', () => {
render(<PlaylistVideoCard {...baseProps} video={makeVideo({ activity: 'downloading' })} />);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,16 @@ describe('PlaylistVideoList container sizing', () => {
test('uses the available container width to switch between cards and a table', () => {
render(<PlaylistVideoList {...baseProps} videos={[makeVideo({ published_at: '20260827' })]} loading={false} />);
expect(screen.getByRole('table')).toBeInTheDocument();
resize(900);
resize(719);
expect(screen.queryByRole('table')).not.toBeInTheDocument();
expect(screen.getByText('2026-08-27')).toBeVisible();
resize(1200);
resize(720);
expect(screen.getByRole('table')).toBeInTheDocument();
});

test('keeps the table on a list narrower than a full desktop page', () => {
render(<PlaylistVideoList {...baseProps} videos={[makeVideo()]} loading={false} />);
resize(900);
expect(screen.getByRole('table')).toBeInTheDocument();
});

Expand Down
Loading
Loading