From b113a8f868b44b165992adbb7b4255e17f9a9f5f Mon Sep 17 00:00:00 2001 From: Sloth Date: Tue, 22 Sep 2026 12:12:51 -0700 Subject: [PATCH 1/4] fix(app): fix thumbnail dissapearing on bulk download in some scenarios and improve spacing on library view title --- src/ui/hooks/useBulkAddQueue.tsx | 24 +++++++-- src/ui/screens/LibraryScreen.test.tsx | 71 ++++++++++++++++++++++++++- src/ui/screens/LibraryScreen.tsx | 24 +++++++-- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/src/ui/hooks/useBulkAddQueue.tsx b/src/ui/hooks/useBulkAddQueue.tsx index 3a1772a..e400454 100644 --- a/src/ui/hooks/useBulkAddQueue.tsx +++ b/src/ui/hooks/useBulkAddQueue.tsx @@ -23,6 +23,15 @@ export type BulkAddEntry = { // BulkAddDialog tags each expanded playlist's own entries with its own // playlistId rather than assuming the whole batch came from one playlist. playlistId?: string; + // Already-known thumbnail for an entry that's already in the library (the + // Library tab's "Download selected" action) -- start() below prefers this + // over its own videoId-based guess, since that guess is a YouTube-only CDN + // URL pattern that's wrong for any other platform, and this flow's items + // skip the processItem fetch step entirely (see videoDir/epoch/resolution/ + // kind above), so nothing would ever correct a wrong guess later. Never + // set by the URL/playlist-paste flow (BulkAddDialog), which relies on that + // guess (or the later fetch) instead. + thumbnailUrl?: string; }; export type BulkAddItem = { @@ -434,11 +443,16 @@ function useBulkAddQueueState() { resolution: entry.resolution, kind: entry.kind, playlistId: entry.playlistId, - // YouTube's thumbnail CDN URL is a stable, public, unauthenticated - // pattern keyed on videoId -- free to construct for playlist-sourced - // entries with no extra fetch; list-sourced entries pick this up once - // processItem fetches their info. - thumbnailUrl: entry.videoId ? `https://i.ytimg.com/vi/${entry.videoId}/mqdefault.jpg` : undefined, + // entry.thumbnailUrl (set by the Library tab's "Download selected" + // action -- see BulkAddEntry's own comment) wins outright: those items + // never reach processItem's fetch step, so this is the only chance to + // get it right, and the YouTube CDN guess below is wrong for anything + // but YouTube anyway. Otherwise, YouTube's thumbnail CDN URL is a + // stable, public, unauthenticated pattern keyed on videoId -- free to + // construct for playlist-sourced entries with no extra fetch; + // list-sourced entries pick up a real one once processItem fetches + // their info. + thumbnailUrl: entry.thumbnailUrl ?? (entry.videoId ? `https://i.ytimg.com/vi/${entry.videoId}/mqdefault.jpg` : undefined), })); itemsRef.current = [...itemsRef.current, ...newItems]; setItems(itemsRef.current); diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx index 6a5ec70..d941c7d 100644 --- a/src/ui/screens/LibraryScreen.test.tsx +++ b/src/ui/screens/LibraryScreen.test.tsx @@ -5,7 +5,7 @@ import { render as rtlRender, screen, waitFor, within } from '@testing-library/r import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router'; import LibraryScreen from './LibraryScreen'; -import { BulkAddProvider } from '../hooks/useBulkAddQueue.tsx'; +import { BulkAddProvider, useBulkAddQueue, type BulkAddItem } from '../hooks/useBulkAddQueue.tsx'; import { LibraryTagsProvider } from '../hooks/useLibraryTags.tsx'; import { BackgroundPlayerProvider } from '../hooks/useBackgroundPlayer.tsx'; @@ -556,6 +556,54 @@ describe('LibraryScreen', () => { expect(screen.queryByText('1 item selected')).not.toBeInTheDocument(); }); + // Regression test: bulk-downloading an already-in-library video (this + // exact flow -- select it, then "Download selected") built its queue + // entry with no thumbnailUrl at all, so BulkAddSidePanel's item preview + // (which only renders an when thumbnailUrl is truthy) fell back to + // its plain placeholder -- unlike a fresh bulk-add paste, which always + // has one. The already-in-library video's local thumbnailPath should be + // preferred as the source, since it's normally already cached by the + // time this flow is even reachable. + it('carries the already-cached local thumbnail into the queued entry for "Download selected"', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ + channels: [{ + channelFolderName: 'Channel A', displayName: 'Channel A', channelIconPath: null, + videos: [makeVideo({ + thumbnailPath: '/lib/Channel A/vidA/video-thumbnail.jpg', + metadata: { ...makeVideo().metadata, originalUrl: 'https://youtube.com/watch?v=vidA' }, + })], + }], + }); + let queuedItems: BulkAddItem[] = []; + function QueueProbe() { + queuedItems = useBulkAddQueue().items; + return null; + } + rtlRender( + + + + + + + + + + , + ); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' })); + await user.click(screen.getByRole('button', { name: /Download selected/ })); + await screen.findByText('Download 1 selected video'); + await user.click(screen.getByRole('button', { name: 'Queue Download' })); + + await waitFor(() => expect(queuedItems).toHaveLength(1)); + expect(queuedItems[0].thumbnailUrl).toBe(`app-video://local/${encodeURIComponent('/lib/Channel A/vidA/video-thumbnail.jpg')}?v=0`); + }); + it('hides "Move selected" when only one sublibrary exists', async () => { const user = userEvent.setup(); (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); @@ -878,7 +926,7 @@ describe('LibraryScreen', () => { // both in the flat "all videos" list (viewMode 'video', exercised here) // and the per-channel grid (VideoCard is shared by both, so this covers // both call sites without a second test). - it('shows a platform chip on the video card itself, next to the resolution chip', async () => { + it('shows a platform chip on the video card itself, in the row below the title (not next to it)', async () => { (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ channels: makeChannelsWithOneNonYoutube() }); render(); @@ -889,6 +937,25 @@ describe('LibraryScreen', () => { const betaCard = screen.getByText('Beta Video').closest('.MuiCard-root') as HTMLElement; expect(within(betaCard).queryByText('youtube')).not.toBeInTheDocument(); }); + + // Regression test: the platform name used to show up twice on a generic + // entry's card -- once as the new platform chip, once as the plain-text + // channel-name caption underneath (which, for a platform-grouped entry, + // is that same platform string). The channel-name caption is only + // meaningful for a real YouTube channel; a generic entry should show it + // nowhere near the card, not even duplicated. + it('hides the plain-text channel name for a generic entry (avoiding duplicating the platform chip), but keeps it for a real YouTube channel', async () => { + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ channels: makeChannelsWithOneNonYoutube() }); + render(); + await screen.findByText('Alpha Video'); + + const alphaCard = screen.getByText('Alpha Video').closest('.MuiCard-root') as HTMLElement; + expect(within(alphaCard).queryByText('Channel A')).not.toBeInTheDocument(); + + const betaCard = screen.getByText('Beta Video').closest('.MuiCard-root') as HTMLElement; + expect(within(betaCard).getByText('Channel B')).toBeInTheDocument(); + }); }); describe('platform-group channel (non-YouTube)', () => { diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 4564ebb..763933f 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -336,6 +336,15 @@ export default function LibraryScreen() { epoch: v.latestEpoch!, resolution: isMp3 ? 'mp3' : targetResolution, kind: isMp3 ? 'audio' : 'video', + // Unlike a fresh bulk-add paste, this video is already in the + // library -- its thumbnail is almost always already cached locally + // (ensureVideoThumbnail ran when it was added), so prefer that over + // re-hitting the remote URL, falling back to the remote URL only if + // the local cache genuinely isn't there yet. Without this, + // BulkAddSidePanel's item preview (which only renders an when + // thumbnailUrl is truthy) fell back to its plain placeholder for + // every single item from this flow. + thumbnailUrl: v.thumbnailPath ? buildAppVideoUrl(v.thumbnailPath) : (v.metadata.thumbnail || undefined), })); start(entries, { download: true, targetResolution }); setBulkDownloadDialogOpen(false); @@ -825,6 +834,7 @@ function VideoCard({ video, onSelect, channelLabel, selected, selectionActive, o videoTags: Record; }) { const bestQuality = getBestDownloadedQuality(video.epochs); + const isGeneric = !!video.metadata.platform && video.metadata.platform !== 'youtube'; const appliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(video.metadata.videoId)); const { enqueue, showToast } = useBackgroundPlayer(); const [queueLoading, setQueueLoading] = useState(false); @@ -911,10 +921,16 @@ function VideoCard({ video, onSelect, channelLabel, selected, selectionActive, o sx={{ aspectRatio: '16 / 9', backgroundColor: 'grey.800', backgroundSize: 'cover', backgroundPosition: 'center' }} /> - - {video.metadata.title || video.videoFolderName} + {video.metadata.title || video.videoFolderName} + {/* Channel name (real channels only -- see isGeneric below) and the + platform/resolution chips share this one row rather than sitting + beside the title, so the title itself gets the full card width + instead of competing with 1-2 chips for space. */} + + {channelLabel && !isGeneric && + {channelLabel}} - {video.metadata.platform && video.metadata.platform !== 'youtube' && + {isGeneric && - {channelLabel && - {channelLabel}} {appliedTags.length > 0 && {appliedTags.map((tag) => ( From cc1d6fe97b45e8f0c0d1010d84b12a5108debdd6 Mon Sep 17 00:00:00 2001 From: Sloth Date: Tue, 22 Sep 2026 12:39:48 -0700 Subject: [PATCH 2/4] feat(app): implement list view as an option to show the library items --- src/electron/main.mjs | 14 +- src/electron/preload.cjs | 2 + src/electron/settings.mjs | 10 + src/types/electron-api.d.ts | 2 + src/ui/components/LibraryBottomBar.tsx | 65 +++-- src/ui/screens/LibraryScreen.test.tsx | 72 +++++ src/ui/screens/LibraryScreen.tsx | 372 +++++++++++++++++++------ testing/mockData/electronAPIMocks.ts | 2 + 8 files changed, 433 insertions(+), 106 deletions(-) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index d4695f6..0b0130e 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -11,7 +11,7 @@ import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs' import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, setPlaylistManualThumbnail, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile, firstAvailablePlaylistThumbnail, resolvePlaylistThumbnailUrl, findPlaylistThumbnailPath } from './library.mjs'; -import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; +import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampLibraryDisplayMode, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; import { createFfmpegRunner } from './ffmpegUtils.mjs'; @@ -548,6 +548,18 @@ ipcMain.handle('settings:setLibrarySort', async (e, { sortField, sortDirection } return { success: true, sortField: settings.librarySortField, sortDirection: settings.librarySortDirection }; }); +ipcMain.handle('settings:getLibraryDisplayMode', async () => { + const { libraryDisplayMode } = readSettings(); + return { libraryDisplayMode: clampLibraryDisplayMode(libraryDisplayMode) }; +}); + +ipcMain.handle('settings:setLibraryDisplayMode', async (e, mode) => { + const settings = readSettings(); + settings.libraryDisplayMode = clampLibraryDisplayMode(mode); + writeSettings(settings); + return { success: true, libraryDisplayMode: settings.libraryDisplayMode }; +}); + ipcMain.handle('settings:getThemeMode', async () => { const { themeMode } = readSettings(); // Dark is the default for a fresh install (paired with SlothUI as the diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index 22d7517..a5f9b89 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -43,6 +43,8 @@ contextBridge.exposeInMainWorld('electronAPI', { setLibraryViewMode: (mode) => ipcRenderer.invoke('settings:setLibraryViewMode', mode), getLibrarySort: () => ipcRenderer.invoke('settings:getLibrarySort'), setLibrarySort: (payload) => ipcRenderer.invoke('settings:setLibrarySort', payload), + getLibraryDisplayMode: () => ipcRenderer.invoke('settings:getLibraryDisplayMode'), + setLibraryDisplayMode: (mode) => ipcRenderer.invoke('settings:setLibraryDisplayMode', mode), getThemeMode: () => ipcRenderer.invoke('settings:getThemeMode'), setThemeMode: (mode) => ipcRenderer.invoke('settings:setThemeMode', mode), getThemeName: () => ipcRenderer.invoke('settings:getThemeName'), diff --git a/src/electron/settings.mjs b/src/electron/settings.mjs index fe45db6..821df10 100644 --- a/src/electron/settings.mjs +++ b/src/electron/settings.mjs @@ -43,6 +43,16 @@ export function clampLibrarySortDirection(value) { return value === 'desc' ? 'desc' : 'asc'; } +// The flat video view's card-grid vs. compact-list layout -- kept separate +// from libraryViewMode (channel vs. video grouping) since the two are +// orthogonal. +export const LIBRARY_DISPLAY_MODES = ['grid', 'list']; +export const LIBRARY_DISPLAY_MODE_DEFAULT = 'grid'; + +export function clampLibraryDisplayMode(value) { + return LIBRARY_DISPLAY_MODES.includes(value) ? value : LIBRARY_DISPLAY_MODE_DEFAULT; +} + // The overall theme (color palette/typography), independent of light/dark // mode -- 'default' is this app's original plain-MUI look, 'slothui' is the // palette pulled from SlothArchiver-info's landing page (see theme.ts). diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 01e24ec..c821f8f 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -97,6 +97,8 @@ declare global { setLibraryViewMode: (mode: 'channel' | 'video') => Promise<{ success: boolean; libraryViewMode: 'channel' | 'video' }> getLibrarySort: () => Promise<{ sortField: LibrarySortField; sortDirection: LibrarySortDirection }> setLibrarySort: (payload: { sortField: LibrarySortField; sortDirection: LibrarySortDirection }) => Promise<{ success: boolean; sortField: LibrarySortField; sortDirection: LibrarySortDirection }> + getLibraryDisplayMode: () => Promise<{ libraryDisplayMode: 'grid' | 'list' }> + setLibraryDisplayMode: (mode: 'grid' | 'list') => Promise<{ success: boolean; libraryDisplayMode: 'grid' | 'list' }> getThemeMode: () => Promise<{ themeMode: 'light' | 'dark' }> setThemeMode: (mode: 'light' | 'dark') => Promise<{ success: boolean; themeMode: 'light' | 'dark' }> getThemeName: () => Promise<{ themeName: 'default' | 'slothui' }> diff --git a/src/ui/components/LibraryBottomBar.tsx b/src/ui/components/LibraryBottomBar.tsx index f9ea0f8..1e85e9a 100644 --- a/src/ui/components/LibraryBottomBar.tsx +++ b/src/ui/components/LibraryBottomBar.tsx @@ -1,4 +1,4 @@ -import { Box, Button, Paper, Slider, Stack, Typography } from '@mui/material'; +import { Box, Button, Paper, Slider, Stack, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; import PhotoSizeSelectSmallIcon from '@mui/icons-material/PhotoSizeSelectSmall'; import PhotoSizeSelectLargeIcon from '@mui/icons-material/PhotoSizeSelectLarge'; import DownloadIcon from '@mui/icons-material/Download'; @@ -6,6 +6,8 @@ import FolderDeleteIcon from '@mui/icons-material/FolderDelete'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'; import LocalOfferIcon from '@mui/icons-material/LocalOffer'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import ViewListIcon from '@mui/icons-material/ViewList'; export const THUMBNAIL_SIZE_MIN = 160; export const THUMBNAIL_SIZE_MAX = 360; @@ -16,12 +18,18 @@ export const THUMBNAIL_SIZE_STEP = 10; // view has no thumbnail grid to size, so omitting them hides that section // entirely rather than showing an irrelevant control. export default function LibraryBottomBar({ + displayMode, onDisplayModeChange, thumbnailSize, onThumbnailSizeChange, onThumbnailSizeCommit, selectedCount, canBulkDownload, onDownloadSelected, canDeleteLocalFiles, onDeleteLocalFiles, onDeleteFromLibrary, canMove, onMoveSelected, canTag, onTagSelected, }: { + // Optional, same reason as thumbnailSize below -- only the flat video + // view has a grid/list layout to switch between; the Playlists view (and + // a single channel's own video grid) omit these, which hides the toggle. + displayMode?: 'grid' | 'list'; + onDisplayModeChange?: (mode: 'grid' | 'list') => void; thumbnailSize?: number; onThumbnailSizeChange?: (size: number) => void; onThumbnailSizeCommit?: (size: number) => void; @@ -79,24 +87,45 @@ export default function LibraryBottomBar({ } - {thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit && - - - + {displayMode !== undefined && onDisplayModeChange && + onThumbnailSizeChange(value as number)} - // SAFETY: same single-scalar `value` as onChange above. - onChangeCommitted={(_e, value) => onThumbnailSizeCommit(value as number)} - aria-label="Thumbnail size" - /> - - } + onChange={(_e, value: 'grid' | 'list' | null) => value && onDisplayModeChange(value)} + aria-label="Library display mode" + > + + + + + + + + + + + } + {thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit && + + + onThumbnailSizeChange(value as number)} + // SAFETY: same single-scalar `value` as onChange above. + onChangeCommitted={(_e, value) => onThumbnailSizeCommit(value as number)} + aria-label="Thumbnail size" + /> + + } + ); } diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx index d941c7d..08d961b 100644 --- a/src/ui/screens/LibraryScreen.test.tsx +++ b/src/ui/screens/LibraryScreen.test.tsx @@ -96,6 +96,8 @@ beforeEach(() => { setLibraryViewMode: vi.fn().mockResolvedValue({ success: true, libraryViewMode: 'video' }), getLibrarySort: vi.fn().mockResolvedValue({ sortField: 'title', sortDirection: 'asc' }), setLibrarySort: vi.fn().mockResolvedValue({ success: true, sortField: 'title', sortDirection: 'asc' }), + getLibraryDisplayMode: vi.fn().mockResolvedValue({ libraryDisplayMode: 'grid' }), + setLibraryDisplayMode: vi.fn().mockResolvedValue({ success: true, libraryDisplayMode: 'list' }), getThumbnailSize: vi.fn().mockResolvedValue({ thumbnailSize: 220 }), setThumbnailSize: vi.fn().mockResolvedValue({ success: true, thumbnailSize: 220 }), refreshLibraryIndex: vi.fn().mockResolvedValue({ channels: makeChannels() }), @@ -311,6 +313,76 @@ describe('LibraryScreen', () => { expect(window.electronAPI.setThumbnailSize).toHaveBeenCalled(); }); + describe('grid/list display mode', () => { + it('shows the grid/list toggle only on the flat by-video list, not a single channel\'s own video grid', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('Channel A')); + + // Channel drill-down (VideoGrid) shares LibraryBottomBar's render call + // site with the flat video view, but has no list layout of its own -- + // the thumbnail slider still shows, the display-mode toggle doesn't. + expect(screen.getByRole('slider', { name: 'Thumbnail size' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'List view' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Grid view' })).not.toBeInTheDocument(); + }); + + it('shows the grid/list toggle on the flat by-video list, defaulting to grid (VideoCard)', async () => { + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + expect(screen.getByRole('button', { name: 'Grid view' })).toBeInTheDocument(); + expect(screen.getByText('Alpha Video').closest('.MuiCard-root')).not.toBeNull(); + }); + + it('switching to list view renders compact rows instead of cards, and persists the choice', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('button', { name: 'List view' })); + + expect(window.electronAPI.setLibraryDisplayMode).toHaveBeenCalledWith('list'); + expect(screen.getByText('Alpha Video').closest('.MuiCard-root')).toBeNull(); + expect(screen.getByText('Alpha Video').closest('.MuiListItem-root')).not.toBeNull(); + }); + + it('loads directly into list mode when that is the persisted setting, and shows the key fields per row', async () => { + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryDisplayMode as ReturnType).mockResolvedValue({ libraryDisplayMode: 'list' }); + render(); + await screen.findByText('Alpha Video'); + + expect(screen.getByRole('button', { name: 'List view' })).toHaveAttribute('aria-pressed', 'true'); + const row = screen.getByText('Alpha Video').closest('.MuiListItem-root'); + expect(row).not.toBeNull(); + expect(within(row as HTMLElement).getByText('Not downloaded')).toBeInTheDocument(); + expect(within(row as HTMLElement).getByText(/2026/)).toBeInTheDocument(); + }); + + it('selection and "Add to queue" still work identically in list mode', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryDisplayMode as ReturnType).mockResolvedValue({ libraryDisplayMode: 'list' }); + (window.electronAPI.getLibraryIndex as ReturnType).mockResolvedValue({ + channels: [{ + channelFolderName: 'Channel A', displayName: 'Channel A', channelIconPath: null, + videos: [makeVideo({ metadata: { ...makeVideo().metadata, downloadedFilePath: '/lib/Channel A/vidA/1/video.mp4' } })], + }], + }); + render(); + await screen.findByText('Alpha Video'); + + await user.click(screen.getByRole('checkbox', { name: 'Select Alpha Video' })); + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Add to queue' })); + expect(screen.queryByText('Detail: vidA')).not.toBeInTheDocument(); + }); + }); + describe('Add to queue (video card)', () => { it('is hidden when the video has no downloaded file at all', async () => { const user = userEvent.setup(); diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 763933f..60d0dae 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -15,6 +15,11 @@ import { FormControlLabel, IconButton, InputLabel, + List, + ListItem, + ListItemAvatar, + ListItemButton, + ListItemText, MenuItem, Paper, Select, @@ -135,6 +140,7 @@ export default function LibraryScreen() { const [viewMode, setViewMode] = useState('channel'); const [librarySection, setLibrarySection] = useState('videos'); const [thumbnailSize, setThumbnailSize] = useState(220); // overwritten by load() + const [displayMode, setDisplayMode] = useState<'grid' | 'list'>('grid'); // overwritten by load() const [deepLinkError, setDeepLinkError] = useState(null); // Set from the deep-link's own ?view=/?clip= query params (see the effect // below) -- the mini-player bar (MiniPlayerBar.tsx) uses these to reopen a @@ -192,17 +198,19 @@ export default function LibraryScreen() { // still this screen's own to own. const load = async () => { setLoading(true); - const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { tags: videoTags }] = await Promise.all([ + const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { libraryDisplayMode }, { tags: videoTags }] = await Promise.all([ window.electronAPI.getLibraryDir(), window.electronAPI.getLibraryIndex(), window.electronAPI.getLibraryViewMode(), window.electronAPI.getThumbnailSize(), + window.electronAPI.getLibraryDisplayMode(), window.electronAPI.listVideoTags(), ]); setLibraryDir(libraryDir); setChannels(index.channels); setViewMode(libraryViewMode); setThumbnailSize(thumbnailSize); + setDisplayMode(libraryDisplayMode); setVideoTags(videoTags); setLoading(false); }; @@ -273,6 +281,12 @@ export default function LibraryScreen() { window.electronAPI.setThumbnailSize(size); }; + // Same fire-and-forget pattern as handleViewModeChange above. + const handleDisplayModeChange = (mode: 'grid' | 'list') => { + setDisplayMode(mode); + window.electronAPI.setLibraryDisplayMode(mode); + }; + const toggleVideoSelected = (videoDir: string) => { setSelectedVideoDirs((prev) => { const next = new Set(prev); @@ -627,6 +641,7 @@ export default function LibraryScreen() { openFolderDir={activeLibraryTagDir} viewMode={viewMode} thumbnailSize={thumbnailSize} + displayMode={displayMode} selectedVideoDirs={selectedVideoDirs} onToggleSelect={toggleVideoSelected} onSelectAll={(dirs) => setSelectedVideoDirs(new Set(dirs))} @@ -702,6 +717,11 @@ export default function LibraryScreen() { {!loading && libraryDir && librarySection === 'videos' && !selectedVideo && (selectedChannel || viewMode === 'video') && void; + channelLabel?: string; + selected: boolean; + selectionActive: boolean; + onToggleSelect: (videoDir: string) => void; + videoTags: Record; +}) { + const bestQuality = getBestDownloadedQuality(video.epochs); + const isGeneric = !!video.metadata.platform && video.metadata.platform !== 'youtube'; + const appliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(video.metadata.videoId)); + const { enqueue, showToast } = useBackgroundPlayer(); + const [queueLoading, setQueueLoading] = useState(false); + const isDownloaded = !!video.metadata.downloadedFilePath; + const handleAddToQueue = async () => { + if (!video.metadata.downloadedFilePath) return; + setQueueLoading(true); + const source = await resolvePlayableSource(video.metadata.downloadedFilePath); + setQueueLoading(false); + if (!source) { + showToast(`Couldn't prepare "${video.metadata.title || video.videoFolderName}" for playback.`); + return; + } + enqueue({ + videoId: video.metadata.videoId, + title: video.metadata.fullTitle || video.metadata.title || null, + channel: video.metadata.channel, + thumbnailPath: video.thumbnailPath, + sourcePath: source.sourcePath, + mimeType: source.mimeType, + }); + }; + return ( + + {isDownloaded && + + + handleAddToQueue()} + aria-label="Add to queue" + disabled={queueLoading} + > + {queueLoading ? : } + + + } + + } + > + {/* Same hover/selection-forced-visible behavior as VideoCard's own + checkbox, in a fixed 34x34 slot so it lines up with the row's other + fixed-size slots. Sits outside ListItemButton (stopPropagation on + click) so toggling it doesn't also fire the row's onSelect. */} + e.stopPropagation()} + sx={{ + width: 34, height: 34, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, ml: 1, + opacity: selectionActive || selected ? 1 : 0, + transition: 'opacity 0.1s', + }} + > + onToggleSelect(video.videoDir)} + inputProps={{ 'aria-label': `Select ${video.metadata.title || video.videoFolderName}` }} + /> + + onSelect(video)}> + + + + + + {video.metadata.title || video.videoFolderName} + + {channelLabel && !isGeneric && + {channelLabel}} + {isGeneric && + } + {bestQuality ? ( + + ) : ( + + )} + {appliedTags.map((tag) => ( + + ))} + + } + secondary={ + + + {convertYYYYMMDDStringToDate(video.metadata.uploadDate || '') || video.metadata.uploadDate} + + {video.epochs.length > 1 && + {video.epochs.length} versions} + {video.clipCount > 0 && + {video.clipCount} clip{video.clipCount === 1 ? '' : 's'}} + + } + /> + + + ); +} + +function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, displayMode, selectedVideoDirs, onToggleSelect, onSelectAll, onViewModeChange, onSelectVideo, onRefresh, videoTags }: { channels: LibraryChannel[]; openFolderDir: string; viewMode: LibraryViewMode; thumbnailSize: number; + displayMode: 'grid' | 'list'; selectedVideoDirs: Set; onToggleSelect: (videoDir: string) => void; // Replaces the whole selection at once with exactly the given videoDirs @@ -1063,80 +1214,110 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec const allVisibleSelected = filtered.length > 0 && filtered.every(({ video }) => selectedVideoDirs.has(video.videoDir)); const someVisibleSelected = filtered.some(({ video }) => selectedVideoDirs.has(video.videoDir)); + // Select-all checkbox and the filter/sort/view-mode/folder/refresh cluster + // are each shared between grid mode's single header row and list mode's + // two tighter rows below -- pulled out once so the two layouts can't + // silently drift apart from each other. + const selectAllControl = ( + onSelectAll(allVisibleSelected ? [] : filtered.map(({ video }) => video.videoDir))} + /> + } + label="Select all" + /> + ); + const filterSortAndViewControls = ( + <> + + setFilterAnchorEl(e.currentTarget)} + aria-label="Filter by tag" + color={(selectedFilterTags.size > 0 || selectedSystemFilters.size > 0) ? 'primary' : 'default'} + > + + + + + + {/* Grouped into one bordered container so the field picker and + direction toggle read as a single "sort" instrument -- Select + uses variant="standard" so this outer Paper is the only + border drawn. */} + + + Order by + + + + setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')} + aria-label="Toggle sort direction" + > + {sortDirection === 'asc' ? : } + + + + + + window.electronAPI.openDirectory(openFolderDir)} size="small" aria-label="Open library folder"> + + + + + + + + + + ); + return ( - - - Library - - onSelectAll(allVisibleSelected ? [] : filtered.map(({ video }) => video.videoDir))} - /> - } - label="Select all" - /> - - - - setFilterAnchorEl(e.currentTarget)} - aria-label="Filter by tag" - color={(selectedFilterTags.size > 0 || selectedSystemFilters.size > 0) ? 'primary' : 'default'} - > - - - - - - {/* Grouped into one bordered container so the field picker and - direction toggle read as a single "sort" instrument -- Select - uses variant="standard" so this outer Paper is the only - border drawn. */} - - - Order by - - - - setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')} - aria-label="Toggle sort direction" - > - {sortDirection === 'asc' ? : } - - - - - - window.electronAPI.openDirectory(openFolderDir)} size="small" aria-label="Open library folder"> - - - - - - - - + {displayMode === 'list' ? ( + + + + + + + Library + {selectAllControl} + + + {filterSortAndViewControls} + + + + ) : ( + + + Library + + {selectAllControl} + + + {filterSortAndViewControls} + - + )} {flatVideos.length === 0 ? ( Nothing in the library yet -- use the library-add button next to the URL field on the Downloader tab. @@ -1157,20 +1338,37 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, selec onToggleSystemFilter={toggleSystemFilter} onClear={() => { setSelectedFilterTags(new Set()); setSelectedSystemFilters(new Set()); }} /> - - {filtered.map(({ video, channelName }) => ( - - ))} - + {displayMode === 'list' ? ( + + {filtered.map(({ video, channelName }) => ( + + ))} + + ) : ( + + {filtered.map(({ video, channelName }) => ( + + ))} + + )} ); } diff --git a/testing/mockData/electronAPIMocks.ts b/testing/mockData/electronAPIMocks.ts index 745189a..301284d 100644 --- a/testing/mockData/electronAPIMocks.ts +++ b/testing/mockData/electronAPIMocks.ts @@ -71,6 +71,8 @@ export const electronAPIMock = { setLibraryViewMode: async () => ({ success: true, libraryViewMode: 'channel' as const }), getLibrarySort: async () => ({ sortField: 'title' as const, sortDirection: 'asc' as const }), setLibrarySort: async () => ({ success: true, sortField: 'title' as const, sortDirection: 'asc' as const }), + getLibraryDisplayMode: async () => ({ libraryDisplayMode: 'grid' as const }), + setLibraryDisplayMode: async () => ({ success: true, libraryDisplayMode: 'grid' as const }), getThemeMode: async () => ({ themeMode: 'light' as const }), setThemeMode: async () => ({ success: true, themeMode: 'light' as const }), getThemeName: async () => ({ themeName: 'default' as const }), From 741ccc147fae6d6366d596a31b57b675bb048eeb Mon Sep 17 00:00:00 2001 From: Sloth Date: Tue, 22 Sep 2026 12:54:53 -0700 Subject: [PATCH 3/4] feat(app): improve flexibility of list view adding a column number slider --- src/electron/main.mjs | 14 ++++++- src/electron/preload.cjs | 2 + src/electron/settings.mjs | 13 +++++++ src/types/electron-api.d.ts | 2 + src/ui/components/LibraryBottomBar.tsx | 35 ++++++++++++++++- src/ui/screens/LibraryScreen.test.tsx | 54 ++++++++++++++++++++++++++ src/ui/screens/LibraryScreen.tsx | 26 +++++++++++-- testing/mockData/electronAPIMocks.ts | 2 + 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 0b0130e..836fcbe 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -11,7 +11,7 @@ import { getSupportedVideoFilters, allVideoFilter } from './utils/constants.mjs' import { getCurrentYtdlpVersion, isNewerVersion, performYtdlpUpdate } from './updater.mjs'; import { resolveLatestRelease, YTDLP_VERIFICATION_ERROR_CODE } from './ytdlpRelease.mjs'; import { writeLibraryEntry, overrideLibraryEntry, addLibraryVersion, refreshLibraryEntryMetadata, getLibraryIndex, refreshLibraryIndex, findVideoInIndex, recordLibraryDownload, swapLibraryDownload, savePlaybackPosition, findVideoThumbnailPath, deleteLibraryEntry, deleteLocalFiles, moveLibraryEntry, writePlaylistSnapshot, enrichPlaylistEntry, listPlaylistSnapshots, getPlaylistSnapshot, reconcilePlaylistSnapshot, setPlaylistManualThumbnail, undoPlaylistRefresh, deletePlaylistSnapshot, sanitizeForFilesystem, resolveInsideLibrary, libraryTagDir, DEFAULT_LIBRARY_DIR_NAME, listLibraryTags, createLibraryTag, listVideoTags, setVideoTag, addTagToVideos, removeVideosFromTags, transferVideoTags, checkAndRepairEpochFiles, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, buildClipFilePath, recordClip, listClips, deleteClip, updateClipFile, firstAvailablePlaylistThumbnail, resolvePlaylistThumbnailUrl, findPlaylistThumbnailPath } from './library.mjs'; -import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampLibraryDisplayMode, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; +import { createSettingsStore, clampMaxSimultaneousDownloads, clampThumbnailSize, THUMBNAIL_SIZE_DEFAULT, clampLibrarySortField, clampLibrarySortDirection, clampLibraryDisplayMode, clampLibraryListColumns, clampThemeName, clampResumeTrackingMode, RESUME_TRACKING_MODE_DEFAULT, clampResumeMinDurationSeconds, RESUME_MIN_DURATION_SECONDS_DEFAULT, clampEmbedMetadataByDefault, EMBED_METADATA_BY_DEFAULT_DEFAULT } from './settings.mjs'; import { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; import { createFfmpegRunner } from './ffmpegUtils.mjs'; @@ -560,6 +560,18 @@ ipcMain.handle('settings:setLibraryDisplayMode', async (e, mode) => { return { success: true, libraryDisplayMode: settings.libraryDisplayMode }; }); +ipcMain.handle('settings:getLibraryListColumns', async () => { + const { libraryListColumns } = readSettings(); + return { libraryListColumns: clampLibraryListColumns(libraryListColumns) }; +}); + +ipcMain.handle('settings:setLibraryListColumns', async (e, columns) => { + const settings = readSettings(); + settings.libraryListColumns = clampLibraryListColumns(columns); + writeSettings(settings); + return { success: true, libraryListColumns: settings.libraryListColumns }; +}); + ipcMain.handle('settings:getThemeMode', async () => { const { themeMode } = readSettings(); // Dark is the default for a fresh install (paired with SlothUI as the diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index a5f9b89..bf8ed0c 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -45,6 +45,8 @@ contextBridge.exposeInMainWorld('electronAPI', { setLibrarySort: (payload) => ipcRenderer.invoke('settings:setLibrarySort', payload), getLibraryDisplayMode: () => ipcRenderer.invoke('settings:getLibraryDisplayMode'), setLibraryDisplayMode: (mode) => ipcRenderer.invoke('settings:setLibraryDisplayMode', mode), + getLibraryListColumns: () => ipcRenderer.invoke('settings:getLibraryListColumns'), + setLibraryListColumns: (columns) => ipcRenderer.invoke('settings:setLibraryListColumns', columns), getThemeMode: () => ipcRenderer.invoke('settings:getThemeMode'), setThemeMode: (mode) => ipcRenderer.invoke('settings:setThemeMode', mode), getThemeName: () => ipcRenderer.invoke('settings:getThemeName'), diff --git a/src/electron/settings.mjs b/src/electron/settings.mjs index 821df10..9d83ac2 100644 --- a/src/electron/settings.mjs +++ b/src/electron/settings.mjs @@ -53,6 +53,19 @@ export function clampLibraryDisplayMode(value) { return LIBRARY_DISPLAY_MODES.includes(value) ? value : LIBRARY_DISPLAY_MODE_DEFAULT; } +// How many columns the flat video view's compact list lays its rows out in +// -- only meaningful once libraryDisplayMode is 'list' (grid mode uses +// thumbnailSize for its own column count instead, via thumbnailGridTemplateColumns). +// A small closed set (not a free-form number) since a list row is +// information-dense -- past 3 columns it stops reading as a "list" at all. +export const LIBRARY_LIST_COLUMNS = [1, 2, 3]; +export const LIBRARY_LIST_COLUMNS_DEFAULT = 1; + +export function clampLibraryListColumns(value) { + const n = Number(value); + return LIBRARY_LIST_COLUMNS.includes(n) ? n : LIBRARY_LIST_COLUMNS_DEFAULT; +} + // The overall theme (color palette/typography), independent of light/dark // mode -- 'default' is this app's original plain-MUI look, 'slothui' is the // palette pulled from SlothArchiver-info's landing page (see theme.ts). diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index c821f8f..8da6f03 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -99,6 +99,8 @@ declare global { setLibrarySort: (payload: { sortField: LibrarySortField; sortDirection: LibrarySortDirection }) => Promise<{ success: boolean; sortField: LibrarySortField; sortDirection: LibrarySortDirection }> getLibraryDisplayMode: () => Promise<{ libraryDisplayMode: 'grid' | 'list' }> setLibraryDisplayMode: (mode: 'grid' | 'list') => Promise<{ success: boolean; libraryDisplayMode: 'grid' | 'list' }> + getLibraryListColumns: () => Promise<{ libraryListColumns: number }> + setLibraryListColumns: (columns: number) => Promise<{ success: boolean; libraryListColumns: number }> getThemeMode: () => Promise<{ themeMode: 'light' | 'dark' }> setThemeMode: (mode: 'light' | 'dark') => Promise<{ success: boolean; themeMode: 'light' | 'dark' }> getThemeName: () => Promise<{ themeName: 'default' | 'slothui' }> diff --git a/src/ui/components/LibraryBottomBar.tsx b/src/ui/components/LibraryBottomBar.tsx index 1e85e9a..db1b189 100644 --- a/src/ui/components/LibraryBottomBar.tsx +++ b/src/ui/components/LibraryBottomBar.tsx @@ -1,6 +1,7 @@ import { Box, Button, Paper, Slider, Stack, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material'; import PhotoSizeSelectSmallIcon from '@mui/icons-material/PhotoSizeSelectSmall'; import PhotoSizeSelectLargeIcon from '@mui/icons-material/PhotoSizeSelectLarge'; +import ViewColumnIcon from '@mui/icons-material/ViewColumn'; import DownloadIcon from '@mui/icons-material/Download'; import FolderDeleteIcon from '@mui/icons-material/FolderDelete'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; @@ -13,6 +14,11 @@ export const THUMBNAIL_SIZE_MIN = 160; export const THUMBNAIL_SIZE_MAX = 360; export const THUMBNAIL_SIZE_STEP = 10; +// Mirrors settings.mjs's own LIBRARY_LIST_COLUMNS -- kept as plain numbers +// here (not imported from the Node module) since this is renderer code. +const LIST_COLUMNS_MIN = 1; +const LIST_COLUMNS_MAX = 3; + // Purely presentational/controlled -- the caller owns the state and any // settings IPC round-trip. Thumbnail-size props are optional: the Playlists // view has no thumbnail grid to size, so omitting them hides that section @@ -20,6 +26,7 @@ export const THUMBNAIL_SIZE_STEP = 10; export default function LibraryBottomBar({ displayMode, onDisplayModeChange, thumbnailSize, onThumbnailSizeChange, onThumbnailSizeCommit, + listColumns, onListColumnsChange, selectedCount, canBulkDownload, onDownloadSelected, canDeleteLocalFiles, onDeleteLocalFiles, onDeleteFromLibrary, canMove, onMoveSelected, @@ -33,6 +40,13 @@ export default function LibraryBottomBar({ thumbnailSize?: number; onThumbnailSizeChange?: (size: number) => void; onThumbnailSizeCommit?: (size: number) => void; + // Same slot as the thumbnail-size slider above, swapped in instead of it + // whenever displayMode is 'list' (see the render logic below) -- the + // thumbnail size has nothing to size once the grid isn't rendered at all. + // Optional for the same reason as thumbnailSize -- omitted wherever + // displayMode itself is omitted. + listColumns?: number; + onListColumnsChange?: (columns: number) => void; selectedCount: number; canBulkDownload: boolean; onDownloadSelected: () => void; @@ -107,7 +121,23 @@ export default function LibraryBottomBar({ } - {thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit && + {displayMode === 'list' && listColumns !== undefined && onListColumnsChange ? ( + + + onListColumnsChange(value as number)} + aria-label="List columns" + /> + + ) : thumbnailSize !== undefined && onThumbnailSizeChange && onThumbnailSizeCommit && ( - } + + )} ); diff --git a/src/ui/screens/LibraryScreen.test.tsx b/src/ui/screens/LibraryScreen.test.tsx index 08d961b..340eda3 100644 --- a/src/ui/screens/LibraryScreen.test.tsx +++ b/src/ui/screens/LibraryScreen.test.tsx @@ -98,6 +98,8 @@ beforeEach(() => { setLibrarySort: vi.fn().mockResolvedValue({ success: true, sortField: 'title', sortDirection: 'asc' }), getLibraryDisplayMode: vi.fn().mockResolvedValue({ libraryDisplayMode: 'grid' }), setLibraryDisplayMode: vi.fn().mockResolvedValue({ success: true, libraryDisplayMode: 'list' }), + getLibraryListColumns: vi.fn().mockResolvedValue({ libraryListColumns: 1 }), + setLibraryListColumns: vi.fn().mockResolvedValue({ success: true, libraryListColumns: 2 }), getThumbnailSize: vi.fn().mockResolvedValue({ thumbnailSize: 220 }), setThumbnailSize: vi.fn().mockResolvedValue({ success: true, thumbnailSize: 220 }), refreshLibraryIndex: vi.fn().mockResolvedValue({ channels: makeChannels() }), @@ -381,6 +383,58 @@ describe('LibraryScreen', () => { await user.click(screen.getByRole('button', { name: 'Add to queue' })); expect(screen.queryByText('Detail: vidA')).not.toBeInTheDocument(); }); + + // Regression test: the thumbnail-size slider does nothing useful once + // list mode is active (there's no grid to size) -- it swaps for a + // discrete 1/2/3 "list columns" slider in the same bottom-bar slot + // instead, defaulting to 1 column. + it('swaps the thumbnail-size slider for a discrete 1/2/3 list-columns slider once list mode is active', async () => { + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryDisplayMode as ReturnType).mockResolvedValue({ libraryDisplayMode: 'grid' }); + render(); + await screen.findByText('Alpha Video'); + + expect(screen.getByRole('slider', { name: 'Thumbnail size' })).toBeInTheDocument(); + expect(screen.queryByRole('slider', { name: 'List columns' })).not.toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'List view' })); + + expect(screen.queryByRole('slider', { name: 'Thumbnail size' })).not.toBeInTheDocument(); + const columnsSlider = screen.getByRole('slider', { name: 'List columns' }); + expect(columnsSlider).toBeInTheDocument(); + expect(columnsSlider).toHaveAttribute('aria-valuenow', '1'); + expect(columnsSlider).toHaveAttribute('aria-valuemin', '1'); + expect(columnsSlider).toHaveAttribute('aria-valuemax', '3'); + }); + + it('changing the list-columns slider persists the choice and lays the rows out in that many columns', async () => { + const user = userEvent.setup(); + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryDisplayMode as ReturnType).mockResolvedValue({ libraryDisplayMode: 'list' }); + render(); + await screen.findByText('Alpha Video'); + + const columnsSlider = screen.getByRole('slider', { name: 'List columns' }); + columnsSlider.focus(); + await user.keyboard('{ArrowRight}'); + + expect(window.electronAPI.setLibraryListColumns).toHaveBeenCalledWith(2); + const list = screen.getByText('Alpha Video').closest('.MuiList-root') as HTMLElement; + expect(list).toHaveStyle({ gridTemplateColumns: 'repeat(2, 1fr)' }); + }); + + it('loads directly into the persisted column count', async () => { + (window.electronAPI.getLibraryViewMode as ReturnType).mockResolvedValue({ libraryViewMode: 'video' }); + (window.electronAPI.getLibraryDisplayMode as ReturnType).mockResolvedValue({ libraryDisplayMode: 'list' }); + (window.electronAPI.getLibraryListColumns as ReturnType).mockResolvedValue({ libraryListColumns: 3 }); + render(); + await screen.findByText('Alpha Video'); + + expect(screen.getByRole('slider', { name: 'List columns' })).toHaveAttribute('aria-valuenow', '3'); + const list = screen.getByText('Alpha Video').closest('.MuiList-root') as HTMLElement; + expect(list).toHaveStyle({ gridTemplateColumns: 'repeat(3, 1fr)' }); + }); }); describe('Add to queue (video card)', () => { diff --git a/src/ui/screens/LibraryScreen.tsx b/src/ui/screens/LibraryScreen.tsx index 60d0dae..d654a4e 100644 --- a/src/ui/screens/LibraryScreen.tsx +++ b/src/ui/screens/LibraryScreen.tsx @@ -141,6 +141,7 @@ export default function LibraryScreen() { const [librarySection, setLibrarySection] = useState('videos'); const [thumbnailSize, setThumbnailSize] = useState(220); // overwritten by load() const [displayMode, setDisplayMode] = useState<'grid' | 'list'>('grid'); // overwritten by load() + const [listColumns, setListColumns] = useState(1); // overwritten by load() const [deepLinkError, setDeepLinkError] = useState(null); // Set from the deep-link's own ?view=/?clip= query params (see the effect // below) -- the mini-player bar (MiniPlayerBar.tsx) uses these to reopen a @@ -198,12 +199,13 @@ export default function LibraryScreen() { // still this screen's own to own. const load = async () => { setLoading(true); - const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { libraryDisplayMode }, { tags: videoTags }] = await Promise.all([ + const [{ libraryDir }, index, { libraryViewMode }, { thumbnailSize }, { libraryDisplayMode }, { libraryListColumns }, { tags: videoTags }] = await Promise.all([ window.electronAPI.getLibraryDir(), window.electronAPI.getLibraryIndex(), window.electronAPI.getLibraryViewMode(), window.electronAPI.getThumbnailSize(), window.electronAPI.getLibraryDisplayMode(), + window.electronAPI.getLibraryListColumns(), window.electronAPI.listVideoTags(), ]); setLibraryDir(libraryDir); @@ -211,6 +213,7 @@ export default function LibraryScreen() { setViewMode(libraryViewMode); setThumbnailSize(thumbnailSize); setDisplayMode(libraryDisplayMode); + setListColumns(libraryListColumns); setVideoTags(videoTags); setLoading(false); }; @@ -287,6 +290,14 @@ export default function LibraryScreen() { window.electronAPI.setLibraryDisplayMode(mode); }; + // Same fire-and-forget pattern -- unlike handleThumbnailSizeChange/Commit's + // drag-vs-release split, a discrete 1/2/3 slider has no intermediate drag + // ticks worth debouncing a write for. + const handleListColumnsChange = (columns: number) => { + setListColumns(columns); + window.electronAPI.setLibraryListColumns(columns); + }; + const toggleVideoSelected = (videoDir: string) => { setSelectedVideoDirs((prev) => { const next = new Set(prev); @@ -642,6 +653,7 @@ export default function LibraryScreen() { viewMode={viewMode} thumbnailSize={thumbnailSize} displayMode={displayMode} + listColumns={listColumns} selectedVideoDirs={selectedVideoDirs} onToggleSelect={toggleVideoSelected} onSelectAll={(dirs) => setSelectedVideoDirs(new Set(dirs))} @@ -721,7 +733,9 @@ export default function LibraryScreen() { // view -- a single channel's own VideoGrid (selectedChannel truthy) // shares this same bottom-bar render but has no list layout of its // own, so these two props stay undefined there. - {...(!selectedChannel && viewMode === 'video' ? { displayMode, onDisplayModeChange: handleDisplayModeChange } : {})} + {...(!selectedChannel && viewMode === 'video' + ? { displayMode, onDisplayModeChange: handleDisplayModeChange, listColumns, onListColumnsChange: handleListColumnsChange } + : {})} thumbnailSize={thumbnailSize} onThumbnailSizeChange={handleThumbnailSizeChange} onThumbnailSizeCommit={handleThumbnailSizeCommit} @@ -1121,12 +1135,16 @@ function VideoListRow({ video, onSelect, channelLabel, selected, selectionActive ); } -function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, displayMode, selectedVideoDirs, onToggleSelect, onSelectAll, onViewModeChange, onSelectVideo, onRefresh, videoTags }: { +function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, displayMode, listColumns, selectedVideoDirs, onToggleSelect, onSelectAll, onViewModeChange, onSelectVideo, onRefresh, videoTags }: { channels: LibraryChannel[]; openFolderDir: string; viewMode: LibraryViewMode; thumbnailSize: number; displayMode: 'grid' | 'list'; + // Only meaningful once displayMode is 'list' -- how many columns the + // compact rows lay out in (LibraryBottomBar's own slider, swapped in for + // the thumbnail-size one in list mode, controls this). + listColumns: number; selectedVideoDirs: Set; onToggleSelect: (videoDir: string) => void; // Replaces the whole selection at once with exactly the given videoDirs @@ -1339,7 +1357,7 @@ function FlatVideoList({ channels, openFolderDir, viewMode, thumbnailSize, displ onClear={() => { setSelectedFilterTags(new Set()); setSelectedSystemFilters(new Set()); }} /> {displayMode === 'list' ? ( - + {filtered.map(({ video, channelName }) => ( ({ success: true, sortField: 'title' as const, sortDirection: 'asc' as const }), getLibraryDisplayMode: async () => ({ libraryDisplayMode: 'grid' as const }), setLibraryDisplayMode: async () => ({ success: true, libraryDisplayMode: 'grid' as const }), + getLibraryListColumns: async () => ({ libraryListColumns: 1 }), + setLibraryListColumns: async () => ({ success: true, libraryListColumns: 1 }), getThemeMode: async () => ({ themeMode: 'light' as const }), setThemeMode: async () => ({ success: true, themeMode: 'light' as const }), getThemeName: async () => ({ themeName: 'default' as const }), From 152ba342f04d6f4275c3a53be9b676beaee5363f Mon Sep 17 00:00:00 2001 From: Sloth Date: Tue, 22 Sep 2026 13:01:56 -0700 Subject: [PATCH 4/4] chore: bump version and update changelog --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 12 ++++++------ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acdf4f2..d1cd78e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ This is the first documented entry — a snapshot of what SlothArchiver could do as of this release, not a history of every change that got it here. Future releases will log what actually changed from the previous one. +## [1.4.0] — 2026-09-22 +- **A compact list view for the library.** The flat "by video" list now has + a grid/list toggle next to the thumbnail-size slider: list view shows each + video as a dense row (thumbnail, title, platform/quality chips, labels, + upload date, version/clip counts) instead of a card, with the same + selection and "Add to queue" support as the grid. A second slider — discrete + 1/2/3 stops, replacing the thumbnail-size slider while list view is active + — lets you choose how many columns of rows to show, defaulting to one. +- **Platform label cleanup on the by-video list.** The platform name no + longer shows up twice on a card (once as its own chip, once again as plain + text where a YouTube channel name would go) — that plain-text line is + YouTube-channel-only now. The platform and quality/downloaded chips moved + down to sit next to it, left-aligned, so the title gets the full width of + the card instead of squeezing against them. +- **Fixed the thumbnail sometimes showing as a generic placeholder** when + bulk-downloading videos already in your library (Library view → select + some → Download selected) — it now carries over the video's own cached + thumbnail instead of guessing a YouTube-only URL that didn't apply. + ## [1.3.0] — 2026-09-22 - **Add videos from virtually any yt-dlp-supported site to your library, not just YouTube.** SoundCloud, Dailymotion, archive.org, PeerTube, and the rest diff --git a/README.md b/README.md index 7cf5149..4318c36 100644 --- a/README.md +++ b/README.md @@ -30,17 +30,17 @@ itself. ## Download - | Platform | Link | |---|---| -| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver.Setup.1.3.0.exe) | -| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver.1.3.0.exe) | -| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0-arm64.dmg) | -| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0.dmg) | -| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.3.0/SlothArchiver-1.3.0.AppImage) | +| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver.Setup.1.4.0.exe) | +| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver.1.4.0.exe) | +| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.0-arm64.dmg) | +| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.0.dmg) | +| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.4.0/SlothArchiver-1.4.0.AppImage) | All builds are unsigned, so your OS will show a first-run security warning. See [Installing](#installing) below. diff --git a/package-lock.json b/package-lock.json index 00360da..7709091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sloth-archiver", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sloth-archiver", - "version": "1.3.0", + "version": "1.4.0", "license": "GPL-3.0-or-later", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 1926d2e..bfe0aae 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sloth-archiver", "private": true, - "version": "1.3.0", + "version": "1.4.0", "description": "A desktop app for downloading and archiving videos, with a built-in library and playlist tracking.", "author": "Sloth ", "license": "GPL-3.0-or-later",