diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba42e3..1cffdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ 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.2.0] — 2026-09-18 +- **Play anything in the background, and queue up more.** A new mini-player + bar at the bottom of the app lets you keep listening (or watching) while + you browse the library, start a download, or adjust Options — without + losing your place. Add a video to the queue from a hover button on the + player, the library grid, or a playlist's own entries; a "Play all" button + queues an entire playlist back-to-back. Queued items play through + automatically, with previous/next controls right in the mini bar. + - Click the queue counter to open the full side panel: see everything + queued, remove an item on hover, click a row to jump to it, or click a + thumbnail to open that video's own page. It defaults to audio-only, with + a toggle to switch to watching the video, and the panel itself is + resizable. + - Works for videos that need extra processing to play (like MKV + downloads) too — the queue prepares them the same way the regular + player already does, automatically. + - Adding something that's already in the queue is now blocked with a + quick heads-up instead of silently duplicating it. +- **Playlist view improvements.** + - Search within an open playlist, not just across your saved playlists. + - A playlist's thumbnail no longer gets stuck on a video that's since + gone private or been deleted, and you can manually pick which video's + thumbnail represents the playlist. + - Refreshing a playlist now actually pulls in the full data for any newly + found videos, not just their title and thumbnail. +- **A small icon in the header now shows when cookies are loaded** for + sending to yt-dlp, with a quick way to clear them right from there. + ## [1.1.1] — 2026-09-11 - **Fixed the Windows build failing to fetch its bundled Deno runtime.** Deno's own release pipeline generates the checksum file for its Windows diff --git a/README.md b/README.md index 804a369..716efc2 100644 --- a/README.md +++ b/README.md @@ -29,17 +29,17 @@ itself. ## Download - | Platform | Link | |---|---| -| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver.Setup.1.1.1.exe) | -| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver.1.1.1.exe) | -| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1-arm64.dmg) | -| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1.dmg) | -| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.1.1/SlothArchiver-1.1.1.AppImage) | +| Windows (installer) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver.Setup.1.2.0.exe) | +| Windows (portable, no install) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver.1.2.0.exe) | +| macOS (Apple Silicon) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.0-arm64.dmg) | +| macOS (Intel) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.0.dmg) | +| Linux (AppImage) | [Download](https://github.com/SlothSoftworks/Sloth-Archiver/releases/download/v1.2.0/SlothArchiver-1.2.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 6bb553b..3db9f63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sloth-archiver", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sloth-archiver", - "version": "1.1.1", + "version": "1.2.0", "license": "GPL-3.0-or-later", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index 9c119da..d68454a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sloth-archiver", "private": true, - "version": "1.1.1", + "version": "1.2.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", diff --git a/src/electron/library.mjs b/src/electron/library.mjs index 04f2afa..6bdc192 100644 --- a/src/electron/library.mjs +++ b/src/electron/library.mjs @@ -272,7 +272,7 @@ export function transferVideoTags(libraryDir, sourceTag, targetTag, videoIds) { // notice -- see LibraryVideoDetail.tsx/PlaylistsSection.tsx's own duplicated // copy of these two numbers. export const CURRENT_VIDEO_SCHEMA_VERSION = 4; -export const CURRENT_PLAYLIST_SCHEMA_VERSION = 1; +export const CURRENT_PLAYLIST_SCHEMA_VERSION = 2; // One cross-platform sanitizer using Windows' illegal-character set as the // superset (rather than branching per-OS) -- keeps folder names identical if @@ -1181,6 +1181,10 @@ export function writePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIBRARY unavailable: isDeadTitle(e.title, e.videoId), })), localFiles, + // Set only via setPlaylistManualThumbnail below -- null means + // "automatic" (follow the first available entry, see + // resolvePlaylistThumbnailUrl). + manualThumbnailVideoId: null, }; fs.writeFileSync(path.join(epochDir, 'metadata.json'), JSON.stringify(metadata, null, 2), 'utf-8'); return { playlistDir, epochDir, epoch: String(addedEpoch), metadata }; @@ -1245,17 +1249,49 @@ function resolvePlaylistEpochDir(playlistDir) { // Playlist-level (not per-epoch), a sibling of the epoch folders -- same // pattern as channel-icon.*/video-thumbnail.*. Cached by -// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live first -// entry has no thumbnailUrl of its own (empty playlist, or a dead first -// entry) -- the renderer always prefers the live entries[0].thumbnailUrl -// when available. -function findPlaylistThumbnailPath(playlistDir) { +// ensurePlaylistThumbnail (main.mjs) as a fallback for when the live +// resolved thumbnail (see resolvePlaylistThumbnailUrl below) has no +// thumbnailUrl of its own (empty playlist, or every entry dead) -- the +// renderer always prefers the live thumbnailUrl when available. Exported so +// main.mjs can re-read it after calling ensurePlaylistThumbnail itself +// (that function lives there, not here -- it needs the thumbnails.mjs +// fetcher instance main.mjs already owns). +export function findPlaylistThumbnailPath(playlistDir) { if (!fs.existsSync(playlistDir)) return null; const entry = fs.readdirSync(playlistDir, { withFileTypes: true }) .find((e) => e.isFile() && e.name.startsWith('playlist-thumbnail.')); return entry ? path.join(playlistDir, entry.name) : null; } +// Skips unavailable/thumbnail-less entries rather than blindly trusting +// entries[0] -- a dead or thumbnail-less first entry used to leave the +// playlist showing a broken/missing image even though later entries had a +// perfectly good one. Falls back to entries[0]'s own thumbnailUrl (possibly +// null) only once every entry has been checked and none qualify, so this +// never returns worse than the old unconditional behavior. +export function firstAvailablePlaylistThumbnail(entries) { + const available = (entries || []).find((e) => !e.unavailable && e.thumbnailUrl); + if (available) return available.thumbnailUrl; + return entries?.[0]?.thumbnailUrl || null; +} + +// The single source of truth for "what should this playlist's thumbnail be +// right now" -- used by both listPlaylistSnapshots and getPlaylistSnapshot +// so list and detail views never disagree, and by main.mjs whenever it +// needs to refresh the cached fallback file. A manual override +// (setPlaylistManualThumbnail below) wins only while its entry still exists +// and isn't unavailable -- a picked entry that later goes dead or drops out +// of the playlist on refresh silently reverts to automatic, same as B's own +// "never show known-dead content" rule, rather than needing reconcile to +// special-case it. +export function resolvePlaylistThumbnailUrl(metadata) { + if (metadata.manualThumbnailVideoId) { + const manual = (metadata.entries || []).find((e) => e.videoId === metadata.manualThumbnailVideoId); + if (manual && !manual.unavailable && manual.thumbnailUrl) return manual.thumbnailUrl; + } + return firstAvailablePlaylistThumbnail(metadata.entries); +} + // Summary list for the Library tab's new Playlists section -- nothing before // this read a saved playlist snapshot back into the renderer at all. export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME }) { @@ -1282,7 +1318,7 @@ export function listPlaylistSnapshots({ libraryDir, libraryTag = DEFAULT_LIBRARY addedEpoch: metadata.addedEpoch, lastRefreshedEpoch: metadata.lastRefreshedEpoch || null, hasPreviousMetadata: fs.existsSync(path.join(epochDir, 'previousMetadata.json')), - thumbnailUrl: metadata.entries?.[0]?.thumbnailUrl || null, + thumbnailUrl: resolvePlaylistThumbnailUrl(metadata), thumbnailPath: findPlaylistThumbnailPath(playlistDir), }); } @@ -1342,6 +1378,7 @@ export async function getPlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB localFiles, hasPreviousMetadata: previousMetadataSavedEpoch !== null, previousMetadataSavedEpoch, + thumbnailUrl: resolvePlaylistThumbnailUrl(metadata), thumbnailPath: findPlaylistThumbnailPath(playlistDir), }; } @@ -1420,9 +1457,27 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB localFiles[entry.videoId] = match ? match.video.videoDir : null; } + // Every live entry with no library match right now -- not just ones + // brand-new to this playlist. An entry can predate this feature, or have + // been in the playlist for a while without ever actually getting added + // (a failed/skipped bulk-add, or simply never triggered), and a refresh + // should still notice and pull its full data in either case, not only + // for entries the reconcile above happens to consider "new." + const missingFromLibraryEntries = reconciledEntries.filter((e) => !e.unavailable && !localFiles[e.videoId]); + const lastRefreshedEpoch = Date.now(); const newMetadata = { ...oldMetadata, + // A refresh brings the saved shape up to date with whatever + // writePlaylistSnapshot would produce today -- without this, an + // "Outdated data" playlist (schemaVersion behind current) stayed + // flagged outdated forever, since oldMetadata's own stale + // schemaVersion was just carried through unchanged by the spread + // above, no matter how many times it was refreshed. Falls back the + // same way writePlaylistSnapshot's own initial write does, for + // fields (manualThumbnailVideoId) that predate this schema version. + schemaVersion: CURRENT_PLAYLIST_SCHEMA_VERSION, + manualThumbnailVideoId: oldMetadata.manualThumbnailVideoId ?? null, title: freshTitle || oldMetadata.title, uploader: freshUploader || oldMetadata.uploader, lastRefreshedEpoch, @@ -1434,7 +1489,39 @@ export function reconcilePlaylistSnapshot({ libraryDir, libraryTag = DEFAULT_LIB fs.writeFileSync(tempPath, JSON.stringify(newMetadata, null, 2), 'utf-8'); fs.renameSync(tempPath, metadataPath); - return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries }; + return { success: true, added, removed, updated, lastRefreshedEpoch, entries: reconciledEntries, missingFromLibraryEntries, manualThumbnailVideoId: newMetadata.manualThumbnailVideoId ?? null }; +} + +// Sets (or, with videoId null, clears) which entry's thumbnail this +// playlist's own thumbnail should follow -- see resolvePlaylistThumbnailUrl +// for how this interacts with automatic (first-available-entry) selection. +// Refuses to point at an entry that doesn't exist or is already flagged +// unavailable, rather than silently accepting a pick that resolvePlaylistThumbnailUrl +// would just ignore anyway -- the caller (the UI's hover button) never +// offers this for an unavailable entry, but the backend shouldn't trust that. +// Doesn't itself touch the cached fallback file (ensurePlaylistThumbnail) -- +// that lives in main.mjs, which calls it right after this succeeds. +export function setPlaylistManualThumbnail({ libraryDir, libraryTag = DEFAULT_LIBRARY_DIR_NAME, playlistId, videoId }) { + const playlistDir = path.join(libraryTagDir(libraryDir, libraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); + const epochDir = resolvePlaylistEpochDir(playlistDir); + if (!epochDir) return { success: false, message: 'This playlist has no saved snapshot.' }; + + const metadataPath = path.join(epochDir, 'metadata.json'); + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + + if (videoId) { + const target = metadata.entries.find((e) => e.videoId === videoId); + if (!target || target.unavailable) { + return { success: false, message: 'That entry is not available to use as a thumbnail.' }; + } + } + + metadata.manualThumbnailVideoId = videoId || null; + const tempPath = `${metadataPath}.new`; + fs.writeFileSync(tempPath, JSON.stringify(metadata, null, 2), 'utf-8'); + fs.renameSync(tempPath, metadataPath); + + return { success: true, manualThumbnailVideoId: metadata.manualThumbnailVideoId, thumbnailUrl: resolvePlaylistThumbnailUrl(metadata) }; } // One-shot undo -- reverts to previousMetadata.json (written by the most diff --git a/src/electron/library.test.mjs b/src/electron/library.test.mjs index 531ff7e..2fdcfd8 100644 --- a/src/electron/library.test.mjs +++ b/src/electron/library.test.mjs @@ -24,6 +24,13 @@ import { findVideoInIndex, writePlaylistSnapshot, enrichPlaylistEntry, + listPlaylistSnapshots, + getPlaylistSnapshot, + reconcilePlaylistSnapshot, + setPlaylistManualThumbnail, + firstAvailablePlaylistThumbnail, + resolvePlaylistThumbnailUrl, + CURRENT_PLAYLIST_SCHEMA_VERSION, PLAYLISTS_DIR_NAME, CLIPS_DIR_NAME, DEFAULT_LIBRARY_DIR_NAME, @@ -1129,6 +1136,182 @@ describe('writePlaylistSnapshot / enrichPlaylistEntry', () => { }); }); +describe('firstAvailablePlaylistThumbnail / resolvePlaylistThumbnailUrl', () => { + const entries = [ + { videoId: 'v1', title: null, url: 'u1', thumbnailUrl: 't1', unavailable: true }, // dead, but has a thumbnail + { videoId: 'v2', title: 'Two', url: 'u2', thumbnailUrl: null, unavailable: false }, // live, no thumbnail + { videoId: 'v3', title: 'Three', url: 'u3', thumbnailUrl: 't3', unavailable: false }, // live, has one -- automatic's own pick + { videoId: 'v4', title: 'Four', url: 'u4', thumbnailUrl: 't4', unavailable: false }, // live, has one too + ]; + + it('skips a dead first entry and a live-but-thumbnail-less one, picking the first qualifying entry', () => { + expect(firstAvailablePlaylistThumbnail(entries)).toBe('t3'); + }); + + it('falls back to entries[0]\'s own thumbnailUrl when nothing qualifies', () => { + const allDead = [{ videoId: 'v1', thumbnailUrl: 't1', unavailable: true }, { videoId: 'v2', thumbnailUrl: null, unavailable: true }]; + expect(firstAvailablePlaylistThumbnail(allDead)).toBe('t1'); + expect(firstAvailablePlaylistThumbnail([])).toBeNull(); + }); + + it('a manual override pointing at a live entry with a real thumbnail wins over the automatic pick', () => { + // Automatic (firstAvailablePlaylistThumbnail) would pick v3 -- the + // override explicitly points at v4 instead, and must win. + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'v4' })).toBe('t4'); + }); + + it('falls back to automatic when the manual override entry is dead or missing entirely', () => { + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'v1' })).toBe('t3'); // v1 is dead -- ignored + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: 'gone' })).toBe('t3'); // not even in entries + expect(resolvePlaylistThumbnailUrl({ entries, manualThumbnailVideoId: null })).toBe('t3'); // no override at all + }); +}); + +describe('listPlaylistSnapshots / getPlaylistSnapshot (thumbnail resolution)', () => { + const deadFirstEntries = () => [ + { videoId: 'v1', title: 'v1', url: 'u1', thumbnailUrl: 'dead-thumb' }, // dead title (== videoId) + { videoId: 'v2', title: 'Live One', url: 'u2', thumbnailUrl: 'live-thumb' }, + ]; + + it('listPlaylistSnapshots skips a dead first entry\'s thumbnail in favor of the first live one', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: deadFirstEntries(), index: { channels: [] } }); + const [summary] = listPlaylistSnapshots({ libraryDir }); + expect(summary.thumbnailUrl).toBe('live-thumb'); + }); + + it('getPlaylistSnapshot exposes the same resolved thumbnailUrl and a manualThumbnailVideoId field', async () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: deadFirstEntries(), index: { channels: [] } }); + const snapshot = await getPlaylistSnapshot({ libraryDir, playlistId: 'PL1', index: { channels: [] } }); + expect(snapshot.thumbnailUrl).toBe('live-thumb'); + expect(snapshot.manualThumbnailVideoId).toBeNull(); + }); +}); + +describe('reconcilePlaylistSnapshot', () => { + const initialEntries = () => [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }]; + + it('reports every live entry with no library match -- new ones, but not a brand-new dead placeholder', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + + const fresh = [ + { videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }, // unchanged, but no library match + { videoId: 'v2', title: 'Video Two', url: 'u2', thumbnailUrl: 't2', uploadDate: '20260102' }, // new, live + { videoId: 'v3', title: 'v3', url: 'u3' }, // new, but dead title (== videoId) + ]; + const result = reconcilePlaylistSnapshot({ libraryDir, playlistId: 'PL1', freshEntries: fresh, index: { channels: [] } }); + + expect(result.added).toBe(2); // v2 and v3 count as "added" for the summary toast... + // ...but missingFromLibraryEntries is scoped differently: every live + // entry without a library match, v1 included even though it's not new + // to this refresh, v3 excluded because it's dead (nothing worth fetching). + const missingIds = result.missingFromLibraryEntries.map((e) => e.videoId).sort(); + expect(missingIds).toEqual(['v1', 'v2']); + }); + + it('flags a pre-existing playlist entry that still has no library match, even though it\'s not new to this refresh', () => { + // The bug this covers: an entry that has sat in the playlist for a + // while (e.g. a skipped/failed earlier bulk-add, or one that predates + // this feature) never gets picked up on refresh just because it isn't + // "new" -- missingFromLibraryEntries is keyed on library presence, not + // on refresh novelty. + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + const indexWithV1 = { channels: [{ videos: [{ metadata: { videoId: 'v1' }, videoDir: '/lib/Channel A/v1' }] }] }; + + const stillMissing = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, // v1 still not in the library + }); + expect(stillMissing.missingFromLibraryEntries.map((e) => e.videoId)).toEqual(['v1']); + + const nowPresent = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: indexWithV1, // added to the library between refreshes + }); + expect(nowPresent.missingFromLibraryEntries).toHaveLength(0); + }); + + it('carries manualThumbnailVideoId through a refresh unchanged, and surfaces it in the return value', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + const result = reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, + }); + + expect(result.manualThumbnailVideoId).toBe('v1'); + }); + + it('bumps a stale schemaVersion (and backfills fields that predate it) on refresh, so "Outdated data" actually clears', () => { + // Real bug: a refresh recomputed everything else but spread oldMetadata's + // own stale schemaVersion straight through unchanged, so a playlist + // flagged "Outdated data" stayed flagged forever no matter how many + // times it was refreshed. + const written = writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: initialEntries(), index: { channels: [] } }); + const metadataPath = path.join(written.epochDir, 'metadata.json'); + const stale = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')); + delete stale.manualThumbnailVideoId; // predates schemaVersion 2 + stale.schemaVersion = 1; + fs.writeFileSync(metadataPath, JSON.stringify(stale, null, 2), 'utf-8'); + + reconcilePlaylistSnapshot({ + libraryDir, playlistId: 'PL1', + freshEntries: [{ videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }], + index: { channels: [] }, + }); + + const updated = readMetadata(written.epochDir); + expect(updated.schemaVersion).toBe(CURRENT_PLAYLIST_SCHEMA_VERSION); + expect(updated.manualThumbnailVideoId).toBeNull(); + }); +}); + +describe('setPlaylistManualThumbnail', () => { + const entries = () => [ + { videoId: 'v1', title: 'Video One', url: 'u1', thumbnailUrl: 't1', uploadDate: '20260101' }, + { videoId: 'v2', title: 'v2', url: 'u2' }, // dead title (== videoId) + ]; + + it('sets the override, resolves the thumbnail to that entry\'s own, and persists it', () => { + const written = writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + expect(result.success).toBe(true); + expect(result.manualThumbnailVideoId).toBe('v1'); + expect(result.thumbnailUrl).toBe('t1'); + expect(readMetadata(written.epochDir).manualThumbnailVideoId).toBe('v1'); + }); + + it('clearing (videoId null) reverts to automatic resolution', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v1' }); + + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: null }); + + expect(result.success).toBe(true); + expect(result.manualThumbnailVideoId).toBeNull(); + }); + + it('refuses to set an unavailable or nonexistent entry as the thumbnail', () => { + writePlaylistSnapshot({ libraryDir, playlistId: 'PL1', entries: entries(), index: { channels: [] } }); + + const deadResult = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'v2' }); + expect(deadResult.success).toBe(false); + + const missingResult = setPlaylistManualThumbnail({ libraryDir, playlistId: 'PL1', videoId: 'nope' }); + expect(missingResult.success).toBe(false); + }); + + it('reports failure for a playlist with no saved snapshot', () => { + const result = setPlaylistManualThumbnail({ libraryDir, playlistId: 'nope', videoId: 'v1' }); + expect(result.success).toBe(false); + }); +}); + describe('moveLibraryEntry', () => { it('refuses to move a videoDir outside the configured library folder', () => { expect(() => moveLibraryEntry({ libraryDir, videoDir: '/etc', targetTag: 'Music' })) diff --git a/src/electron/main.mjs b/src/electron/main.mjs index 7918001..1892cee 100644 --- a/src/electron/main.mjs +++ b/src/electron/main.mjs @@ -10,7 +10,7 @@ import os from 'node:os'; 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, 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 } from './library.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 { makeCookiesArgs, looksLikeNetscapeFormat, convertHeaderCookiesToNetscape, validateNetscapeLines, SUPPORTED_COOKIE_BROWSERS, reapStaleCookieCopies } from './cookies.mjs'; import { downloadImageToFile, createThumbnailFetchers } from './thumbnails.mjs'; @@ -858,7 +858,12 @@ ipcMain.handle('library:fetchPlaylistEntries', async (e, playlistUrl) => { index, }); // Fire-and-forget, same as the video/channel thumbnail caches. - ensurePlaylistThumbnail(result.playlistDir, playlist.entries[0]?.thumbnailUrl); + // Prefers result.metadata.entries (already dead-flagged by + // writePlaylistSnapshot's own isDeadTitle check) over the raw + // fetch, so a dead first entry doesn't win here either -- falls + // back to the raw entries only on the rare no-op-second-save + // case (result.metadata is null then). + ensurePlaylistThumbnail(result.playlistDir, firstAvailablePlaylistThumbnail(result.metadata?.entries || playlist.entries)); } return { success: true, entries: playlist.entries, playlistId: playlist.id }; } catch (err) { @@ -931,13 +936,36 @@ ipcMain.handle('library:refreshPlaylist', async (e, playlistId) => { index, }); const playlistDir = path.join(libraryTagDir(libraryDir, activeLibraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); - ensurePlaylistThumbnail(playlistDir, result.entries[0]?.thumbnailUrl); + ensurePlaylistThumbnail(playlistDir, resolvePlaylistThumbnailUrl({ entries: result.entries, manualThumbnailVideoId: result.manualThumbnailVideoId })); return result; } catch (err) { return { success: false, message: err instanceof Error ? err.message : String(err) }; } }); +// "Set as playlist thumbnail" (videoId set) / "Reset to automatic" +// (videoId null) -- see setPlaylistManualThumbnail (library.mjs) for the +// persistence + validation, and resolvePlaylistThumbnailUrl for how this +// interacts with the automatic first-available-entry fallback. +ipcMain.handle('library:setPlaylistManualThumbnail', async (e, { playlistId, videoId }) => { + const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); + if (!libraryDir) { + return { success: false, message: 'No library folder configured.' }; + } + try { + const result = setPlaylistManualThumbnail({ libraryDir, libraryTag: activeLibraryTag, playlistId, videoId }); + if (!result.success) return result; + const playlistDir = path.join(libraryTagDir(libraryDir, activeLibraryTag), PLAYLISTS_DIR_NAME, sanitizeForFilesystem(playlistId)); + // Refreshes the cached fallback file to match the newly-resolved + // thumbnail -- same fire-and-forget-style cache used everywhere else, + // but awaited here since the response needs the resulting path. + await ensurePlaylistThumbnail(playlistDir, result.thumbnailUrl); + return { ...result, thumbnailPath: findPlaylistThumbnailPath(playlistDir) }; + } catch (err) { + return { success: false, message: err instanceof Error ? err.message : String(err) }; + } +}); + ipcMain.handle('library:undoPlaylistRefresh', async (e, playlistId) => { const { libraryDir, activeLibraryTag = DEFAULT_LIBRARY_DIR_NAME } = readSettings(); if (!libraryDir) { diff --git a/src/electron/preload.cjs b/src/electron/preload.cjs index ba2434c..22d7517 100644 --- a/src/electron/preload.cjs +++ b/src/electron/preload.cjs @@ -73,6 +73,7 @@ contextBridge.exposeInMainWorld('electronAPI', { listPlaylists: () => ipcRenderer.invoke('library:listPlaylists'), getPlaylist: (playlistId) => ipcRenderer.invoke('library:getPlaylist', playlistId), refreshPlaylist: (playlistId) => ipcRenderer.invoke('library:refreshPlaylist', playlistId), + setPlaylistManualThumbnail: (playlistId, videoId) => ipcRenderer.invoke('library:setPlaylistManualThumbnail', { playlistId, videoId }), undoPlaylistRefresh: (playlistId) => ipcRenderer.invoke('library:undoPlaylistRefresh', playlistId), deletePlaylist: (playlistId) => ipcRenderer.invoke('library:deletePlaylist', playlistId), recordLibraryDownload: (payload) => ipcRenderer.invoke('library:recordDownload', payload), diff --git a/src/types.ts b/src/types.ts index 48f9801..34d58b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,7 +89,16 @@ export type PlaylistSnapshot = { // What Undo would revert to, and when that version was itself last // current -- null when there's nothing to undo. previousMetadataSavedEpoch: number | null; + // The resolved thumbnail source (manual override if set and still valid, + // else the first available -- not dead -- entry) and a locally-cached + // fallback file path, mirroring PlaylistSummary's own pair. + thumbnailUrl: string | null; thumbnailPath: string | null; + // The entry deliberately picked via "Set as playlist thumbnail," or null + // for the default automatic (first-available-entry) behavior. Only + // honored while that entry still exists and isn't unavailable -- see + // resolvePlaylistThumbnailUrl (library.mjs). + manualThumbnailVideoId: string | null; } export type DownloadProgressMessage = { diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index c6f4270..01e24ec 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -22,8 +22,9 @@ type LibrarySortDirection = 'asc' | 'desc'; // Mirrors listLibraryTags' return shape (library.mjs) -- folderName is what // every IPC call actually keys on; tagName is presentational (today always // equal to folderName, since sublibrary names aren't renamable separately -// from their folder). -type LibraryTag = { +// from their folder). Exported so useLibraryTags.tsx and its consumers share +// this one declaration instead of each keeping their own local copy. +export type LibraryTag = { tagName: string; folderName: string; createdEpoch: number | null; @@ -125,7 +126,8 @@ declare global { enrichPlaylistEntry: (payload: { playlistId: string; videoId: string; title?: string | null; uploadDate?: string | null; thumbnailUrl?: string | null }) => Promise<{ success: boolean; message?: string }> listPlaylists: () => Promise<{ playlists: PlaylistSummary[] }> getPlaylist: (playlistId: string) => Promise<{ playlist: PlaylistSnapshot | null }> - refreshPlaylist: (playlistId: string) => Promise<{ success: boolean; added?: number; removed?: number; updated?: number; lastRefreshedEpoch?: number; entries?: PlaylistEntry[]; message?: string }> + refreshPlaylist: (playlistId: string) => Promise<{ success: boolean; added?: number; removed?: number; updated?: number; lastRefreshedEpoch?: number; entries?: PlaylistEntry[]; missingFromLibraryEntries?: PlaylistEntry[]; manualThumbnailVideoId?: string | null; message?: string }> + setPlaylistManualThumbnail: (playlistId: string, videoId: string | null) => Promise<{ success: boolean; manualThumbnailVideoId?: string | null; thumbnailUrl?: string | null; thumbnailPath?: string | null; message?: string }> undoPlaylistRefresh: (playlistId: string) => Promise<{ success: boolean; metadata?: PlaylistSnapshot; message?: string }> deletePlaylist: (playlistId: string) => Promise<{ success: boolean; message?: string }> recordLibraryDownload: (payload: { videoDir: string; epoch: string; filePath: string; resolution: string; format?: string; kind?: 'video' | 'audio' }) => Promise<{ success: boolean }> diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c45ea60..633a07b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -11,6 +11,9 @@ import { YtdlpUpdaterProvider } from './hooks/useYtdlpUpdater'; import { LibraryNotificationProvider } from './hooks/useLibraryNotifications'; import { BulkAddProvider } from './hooks/useBulkAddQueue.tsx'; import { ThemeModeProvider, useThemeMode } from './hooks/useThemeMode.tsx'; +import { LibraryTagsProvider } from './hooks/useLibraryTags.tsx'; +import { CookiesChangeProvider } from './hooks/useCookiesChange.tsx'; +import { BackgroundPlayerProvider } from './hooks/useBackgroundPlayer.tsx'; // Forwards uncaught renderer errors to the same main.log a crashed main // process already writes to (see errorLog:report in main.mjs) -- the renderer @@ -51,18 +54,24 @@ function AppContent() { - - - {/* "/*" (not "/") so MainPage -- and everything that lives - alongside its tabs, like BulkAddSidePanel and the - yt-dlp-update dialog -- stays mounted for every nested path - this app's internal navigation uses (e.g. - /library/video/:videoId). Those aren't separate s - with their own element; they're just locations MainPage's - own tab logic reads reactively (see MainPage.tsx). */} - }> - - + + + + + + {/* "/*" (not "/") so MainPage -- and everything that lives + alongside its tabs, like BulkAddSidePanel and the + yt-dlp-update dialog -- stays mounted for every nested path + this app's internal navigation uses (e.g. + /library/video/:videoId). Those aren't separate s + with their own element; they're just locations MainPage's + own tab logic reads reactively (see MainPage.tsx). */} + }> + + + + + diff --git a/src/ui/MainPage.test.tsx b/src/ui/MainPage.test.tsx index 5479c8b..e2e0bdf 100644 --- a/src/ui/MainPage.test.tsx +++ b/src/ui/MainPage.test.tsx @@ -6,6 +6,8 @@ import { MemoryRouter } from 'react-router'; import MainPage from './MainPage'; import { LibraryNotificationProvider, useLibraryNotification } from './hooks/useLibraryNotifications'; import { YtdlpUpdaterProvider } from './hooks/useYtdlpUpdater'; +import { CookiesChangeProvider } from './hooks/useCookiesChange'; +import { BackgroundPlayerProvider } from './hooks/useBackgroundPlayer'; // Each tab's screen is a large, independently-tested component (its own // dedicated test file covers it) -- mocked out here so MainPage's tests stay @@ -29,6 +31,8 @@ beforeEach(() => { getFfmpegVersion: vi.fn().mockResolvedValue('7.0.2'), onYtdlpUpdateProgress: vi.fn(), removeYtdlpUpdateProgressListener: vi.fn(), + getCookiesConfig: vi.fn().mockResolvedValue({ cookiesMode: 'file', cookiesBrowser: '', supportedBrowsers: [], cookiesPersistAcrossSessions: false }), + getCookieStatus: vi.fn().mockResolvedValue({ loaded: false, cookieCount: 0 }), }; }); @@ -45,8 +49,12 @@ function renderMainPage() { - - + + + + + + , @@ -108,4 +116,51 @@ describe('MainPage', () => { await user.click(screen.getByRole('button', { name: 'Close' })); await waitFor(() => expect(screen.queryByText(/under active construction/)).not.toBeInTheDocument()); }); + + describe('cookies header indicator', () => { + it('is hidden when no cookies are configured', async () => { + renderMainPage(); + await waitFor(() => expect(window.electronAPI.getCookiesConfig).toHaveBeenCalled()); + expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Clear cookies')).not.toBeInTheDocument(); + }); + + it('shows the indicator with a tooltip in file mode, and clicking clear calls deleteCookie', async () => { + const user = userEvent.setup(); + window.electronAPI.getCookieStatus = vi.fn().mockResolvedValue({ loaded: true, cookieCount: 3 }); + window.electronAPI.deleteCookie = vi.fn().mockResolvedValue({ success: true }); + renderMainPage(); + + const cookieIcon = await screen.findByLabelText('Cookies loaded'); + await user.hover(cookieIcon); + expect(await screen.findByText('Cookies are loaded and being sent to yt-dlp')).toBeInTheDocument(); + + // Re-mock to reflect the cleared state before the click resolves, so + // the post-clear refetch shows the indicator disappearing. + window.electronAPI.getCookieStatus = vi.fn().mockResolvedValue({ loaded: false, cookieCount: 0 }); + await user.click(screen.getByLabelText('Clear cookies')); + + expect(window.electronAPI.deleteCookie).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument()); + }); + + it('shows the indicator in browser mode, and clicking clear calls setCookiesConfig with an empty browser', async () => { + const user = userEvent.setup(); + window.electronAPI.getCookiesConfig = vi.fn().mockResolvedValue({ + cookiesMode: 'browser', cookiesBrowser: 'chrome', supportedBrowsers: ['chrome'], cookiesPersistAcrossSessions: false, + }); + window.electronAPI.setCookiesConfig = vi.fn().mockResolvedValue({ success: true, cookiesMode: 'browser', cookiesBrowser: '' }); + renderMainPage(); + + await screen.findByLabelText('Cookies loaded'); + + window.electronAPI.getCookiesConfig = vi.fn().mockResolvedValue({ + cookiesMode: 'browser', cookiesBrowser: '', supportedBrowsers: ['chrome'], cookiesPersistAcrossSessions: false, + }); + await user.click(screen.getByLabelText('Clear cookies')); + + expect(window.electronAPI.setCookiesConfig).toHaveBeenCalledWith({ cookiesMode: 'browser', cookiesBrowser: '' }); + await waitFor(() => expect(screen.queryByLabelText('Cookies loaded')).not.toBeInTheDocument()); + }); + }); }); diff --git a/src/ui/MainPage.tsx b/src/ui/MainPage.tsx index 2eea7de..5ff471e 100644 --- a/src/ui/MainPage.tsx +++ b/src/ui/MainPage.tsx @@ -18,6 +18,8 @@ import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; import DialogActions from '@mui/material/DialogActions'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; +import CookieIcon from '@mui/icons-material/Cookie'; +import CancelIcon from '@mui/icons-material/Cancel'; import './App.css'; import './MainPage.css'; import DownloaderScreen from './screens/DownloaderScreen'; @@ -25,7 +27,9 @@ import OptionsScreen from './screens/OptionsScreen'; import LibraryScreen from './screens/LibraryScreen'; import YtdlpUpdateDialog from './components/YtdlpUpdateDialog'; import BulkAddSidePanel, { BulkAddToggleButton } from './components/BulkAddSidePanel'; +import MiniPlayerBar from './components/MiniPlayerBar'; import { useLibraryNotification } from './hooks/useLibraryNotifications'; +import { useCookiesChange } from './hooks/useCookiesChange'; import { useYtdlpUpdater } from './hooks/useYtdlpUpdater'; import buttonIcon from '../../assets/button_icon.png'; @@ -116,6 +120,45 @@ export function BasicTabs() { }, []); const { currentVersion: ytdlpVersion } = useYtdlpUpdater(); const { count: libraryNotificationCount, reset: resetLibraryNotifications } = useLibraryNotification(); + + // "Loaded" mirrors cookies.mjs's own makeCookiesArgs() precedence, the + // function that actually decides what yt-dlp receives -- browser mode + // wins whenever a browser is configured there, file mode only matters + // otherwise, so this checks whichever one is actually live rather than + // just whether a cookies.txt happens to exist on disk. + const [cookiesMode, setCookiesMode] = useState<'file' | 'browser'>('file'); + const [cookiesBrowser, setCookiesBrowser] = useState(''); + const [cookieFileLoaded, setCookieFileLoaded] = useState(false); + const { version: cookiesChangeVersion, notifyChanged: notifyCookiesChanged } = useCookiesChange(); + const refreshCookieIndicator = () => { + window.electronAPI.getCookiesConfig().then((config) => { + setCookiesMode(config.cookiesMode); + setCookiesBrowser(config.cookiesBrowser); + }); + window.electronAPI.getCookieStatus().then((status) => setCookieFileLoaded(status.loaded)); + }; + // Re-fetches whenever OptionsScreen bumps this shared signal after saving, + // deleting, or switching cookies -- without it, this only ever refetched + // once on mount, so the header stayed stuck at whatever cookie state + // existed when the app launched until the next restart. + useEffect(() => { + refreshCookieIndicator(); + }, [cookiesChangeVersion]); + const cookiesLoaded = cookiesMode === 'browser' ? !!cookiesBrowser : cookieFileLoaded; + // Same bare one-click clear Options' own "Delete cookie"/"Clear" buttons + // already do (handleDelete/handleClearBrowserSelection, OptionsScreen.tsx) + // -- no confirmation dialog, just reuse the same two calls and refresh. + const handleClearCookiesFromHeader = async () => { + if (cookiesMode === 'browser') { + await window.electronAPI.setCookiesConfig({ cookiesMode: 'browser', cookiesBrowser: '' }); + } else { + await window.electronAPI.deleteCookie(); + } + refreshCookieIndicator(); + // So OptionsScreen (if open) picks up the clear too, same as this + // header picks up a save/delete/switch made there. + notifyCookiesChanged(); + }; // Bumped to force LibraryScreen to remount (see its `key` below) -- every // other tab gets this reset for free, since CustomTabPanel only renders a // tab's content while active. Library's own drill-down and Videos/ @@ -164,6 +207,34 @@ export function BasicTabs() { {appVersion && } + {cookiesLoaded && + + + + + + } + > + + + + + + } setInfoOpen(true)} aria-label="About"> @@ -182,6 +253,7 @@ export function BasicTabs() { + setInfoOpen(false)} maxWidth="xs" fullWidth> diff --git a/src/ui/components/BulkAddDialog.test.tsx b/src/ui/components/BulkAddDialog.test.tsx index 7e55a2f..738385e 100644 --- a/src/ui/components/BulkAddDialog.test.tsx +++ b/src/ui/components/BulkAddDialog.test.tsx @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; import BulkAddDialog from './BulkAddDialog'; import { BulkAddProvider } from '../hooks/useBulkAddQueue'; +import { LibraryTagsProvider, useLibraryTags } from '../hooks/useLibraryTags'; beforeEach(() => { window.electronAPI = { @@ -13,6 +15,8 @@ beforeEach(() => { getMaxSimultaneousDownloads: vi.fn().mockResolvedValue({ maxSimultaneousDownloads: 1 }), listLibraryTags: vi.fn().mockResolvedValue({ tags: [] }), getActiveLibraryTag: vi.fn().mockResolvedValue({ activeLibraryTag: 'DefaultLibrary', activeLibraryTagDir: '' }), + setActiveLibraryTag: vi.fn().mockResolvedValue({ success: true, activeLibraryTag: 'DefaultLibrary' }), + createLibraryTag: vi.fn().mockResolvedValue({ success: true, tag: { tagName: 'New', folderName: 'New', createdEpoch: 1 } }), }; window.electronAPIPythonDownload = { startDownloadPython: vi.fn(), @@ -24,11 +28,47 @@ beforeEach(() => { function renderDialog(onClose = vi.fn()) { const utils = render( - , + , ); return { ...utils, onClose }; } +// Regression harness for the sublibrary-selector staleness bug: +// BulkAddDialog stays permanently mounted (only its `open` prop toggles) in +// the real app, so this renders it alongside a sibling that can create a +// sublibrary "elsewhere" (same as LibraryScreen would) while the dialog +// stays mounted throughout, both sharing one LibraryTagsProvider -- exactly +// the scenario the old fetch-once-on-mount effect could never pick up. +function CreateTagButton() { + const { createTag } = useLibraryTags(); + return ; +} + +function SwitchTagButton({ tag }: { tag: string }) { + const { switchTag } = useLibraryTags(); + return ; +} + +// Toggling `open` locally (a real close/reopen), not remounting BulkAddDialog +// itself -- matches how BulkAddSidePanel actually drives it in the real app. +function ToggleableDialogWithCreateHarness() { + const [open, setOpen] = useState(true); + return ( + <> + + + + setOpen(false)} /> + + ); +} + +function renderDialogWithCreateHarness() { + return render( + , + ); +} + describe('BulkAddDialog', () => { it('shows a validation error and does not submit when a link is invalid', async () => { const user = userEvent.setup(); @@ -114,4 +154,65 @@ describe('BulkAddDialog', () => { expect(onClose).toHaveBeenCalled(); expect(window.electronAPI.fetchPlaylistEntries).not.toHaveBeenCalled(); }); + + describe('sublibrary selector staleness regression', () => { + it('shows a sublibrary created elsewhere without remounting this permanently-mounted dialog', async () => { + const user = userEvent.setup(); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [{ tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }], + }); + renderDialogWithCreateHarness(); + + // Only one sublibrary so far -- the "Add to" selector isn't shown yet. + await waitFor(() => expect(screen.queryByLabelText('Add to')).not.toBeInTheDocument()); + + (window.electronAPI.createLibraryTag as ReturnType).mockResolvedValue({ + success: true, tag: { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + }); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [ + { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }, + { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + ], + }); + // hidden: true -- MUI's Dialog marks everything outside itself + // aria-hidden while open, including this sibling test-harness button, + // which is otherwise perfectly real and clickable. + await user.click(screen.getByRole('button', { name: 'create sublibrary (test)', hidden: true })); + + // The dialog never remounted (same instance throughout) but now shows + // the selector at all -- it was absent above with only one sublibrary, + // and appears once useLibraryTags' shared state picks up the second. + await waitFor(() => expect(screen.getByLabelText('Add to')).toBeInTheDocument()); + }); + + it('reseeds the default target to the current active tag every time the dialog reopens, not just its first mount', async () => { + const user = userEvent.setup(); + (window.electronAPI.listLibraryTags as ReturnType).mockResolvedValue({ + tags: [ + { tagName: 'DefaultLibrary', folderName: 'DefaultLibrary', createdEpoch: null }, + { tagName: 'Second', folderName: 'Second', createdEpoch: 1 }, + ], + }); + renderDialogWithCreateHarness(); + await waitFor(() => expect(screen.getByLabelText('Add to')).toBeInTheDocument()); + expect(screen.getByLabelText('Add to')).toHaveTextContent('DefaultLibrary'); + + // Close, then simulate the active sublibrary changing elsewhere while + // closed (e.g. switched from LibraryScreen, sharing the same hook), + // then reopen. + (window.electronAPI.getActiveLibraryTag as ReturnType).mockResolvedValue({ + activeLibraryTag: 'Second', activeLibraryTagDir: '/lib/Second', + }); + // hidden: true throughout -- MUI's Dialog exit transition keeps these + // sibling test-harness buttons marked aria-hidden for a moment even + // after `open` flips to false (see the comment on the other + // regression test above for why they're aria-hidden at all). + await user.click(screen.getByRole('button', { name: 'toggle dialog (test)', hidden: true })); // close + await user.click(screen.getByRole('button', { name: 'switch to Second (test)', hidden: true })); + await user.click(screen.getByRole('button', { name: 'toggle dialog (test)', hidden: true })); // reopen + + await waitFor(() => expect(screen.getByLabelText('Add to')).toHaveTextContent('Second')); + }); + }); }); diff --git a/src/ui/components/BulkAddDialog.tsx b/src/ui/components/BulkAddDialog.tsx index 6021f74..a90031a 100644 --- a/src/ui/components/BulkAddDialog.tsx +++ b/src/ui/components/BulkAddDialog.tsx @@ -18,20 +18,13 @@ import { } from '@mui/material'; import { isValidUrl } from '../../utils/utils.ts'; import { useBulkAddQueue, type BulkAddEntry } from '../hooks/useBulkAddQueue.tsx'; +import { useLibraryTags } from '../hooks/useLibraryTags.tsx'; // Fixed set of common quality tiers -- not derived from any specific video's // own available resolutions, since those aren't known until each entry is // fetched. Per-video matching happens in useBulkAddQueue's pickClosestResolution. export const QUALITY_TIERS = ['2160', '1440', '1080', '720', '480', '360', '240', '144', 'MP3']; -// Mirrors listLibraryTags' return shape (library.mjs) -- see LibraryScreen.tsx's -// own copy of this type for why it isn't shared/imported across screens. -type LibraryTag = { - tagName: string; - folderName: string; - createdEpoch: number | null; -}; - function isPlaylistUrl(url: string): boolean { try { return new URL(url).searchParams.has('list'); @@ -47,20 +40,16 @@ export default function BulkAddDialog({ open, onClose }: { open: boolean; onClos const [targetResolution, setTargetResolution] = useState('720'); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Only ever shown/relevant once more than one sublibrary exists -- same - // fetch-once-on-mount pattern as DownloaderScreen's own copy of this. - const [libraryTags, setLibraryTags] = useState([]); + // Only ever shown/relevant once more than one sublibrary exists. + const { libraryTags, activeLibraryTag } = useLibraryTags(); const [targetLibraryTag, setTargetLibraryTag] = useState(''); + // Re-seeds every time the dialog opens, not just on this permanently- + // mounted component's first-ever mount (BulkAddDialog only toggles `open`, + // it never remounts) -- otherwise the default target would go stale the + // same way the old fetch-once-on-mount bug did, just one step removed. useEffect(() => { - (async () => { - const [{ tags }, { activeLibraryTag }] = await Promise.all([ - window.electronAPI.listLibraryTags(), - window.electronAPI.getActiveLibraryTag(), - ]); - setLibraryTags(tags); - setTargetLibraryTag(activeLibraryTag); - })(); - }, []); + if (open) setTargetLibraryTag(activeLibraryTag); + }, [open, activeLibraryTag]); const handleClose = () => { if (submitting) return; diff --git a/src/ui/components/BulkAddSidePanel.test.tsx b/src/ui/components/BulkAddSidePanel.test.tsx index 10e6bcc..c83cac4 100644 --- a/src/ui/components/BulkAddSidePanel.test.tsx +++ b/src/ui/components/BulkAddSidePanel.test.tsx @@ -4,6 +4,7 @@ import { render, screen, act, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import BulkAddSidePanel, { BulkAddToggleButton } from './BulkAddSidePanel'; import { BulkAddProvider, useBulkAddQueue } from '../hooks/useBulkAddQueue'; +import { LibraryTagsProvider } from '../hooks/useLibraryTags'; // Real timers throughout this file, deliberately -- combining fake timers // with @testing-library/user-event's click simulation turned out to hang @@ -52,7 +53,7 @@ function Harness({ onReady }: { onReady: (queue: ReturnType; const utils = render( - { queue = q; }} />, + { queue = q; }} />, ); return { ...utils, getQueue: () => queue }; } diff --git a/src/ui/components/ClipCollectionView.test.tsx b/src/ui/components/ClipCollectionView.test.tsx index e4a9e20..e4da5f2 100644 --- a/src/ui/components/ClipCollectionView.test.tsx +++ b/src/ui/components/ClipCollectionView.test.tsx @@ -1,10 +1,62 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import ClipCollectionView from './ClipCollectionView'; +import { BackgroundPlayerProvider, useBackgroundPlayer } from '../hooks/useBackgroundPlayer.tsx'; import type { LibraryClip } from '../../types'; +// Same one-shot readiness sequence LibraryVideoPlayer.test.tsx fires -- +// Vidstack won't treat the @@ -506,60 +621,94 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: ) : ( <> {selectedPlaylist && selectedPlaylist.entries.length > 0 && - - {visibleSelectableEntries.length > 0 && - <> - Select all - {/* Bare Checkbox in the same fixed-size Box used per-row below - (not FormControlLabel, whose built-in margins would throw - off the alignment) so this checkbox sits in the exact same - column as the per-row ones. */} - - setSelectedVideoIds(allVisibleSelected ? new Set() : new Set(visibleSelectableEntries.map((e) => e.videoId)))} - inputProps={{ 'aria-label': 'Select all' }} - /> - - } - {/* Filter button lives in the same slot each row's "go to - library" button uses below, so it stays in that column - rather than floating loose -- and stays reachable even - once a filter hides every entry (gated on the total - entry count above, not the filtered one). */} - - - + + } + {selectedPlaylist && selectedPlaylist.entries.length > 0 && + + getDownloadedEntry(e)) ? '' : 'No downloaded videos in this playlist yet'}> + + + + + + {visibleSelectableEntries.length > 0 && + <> + Select all + {/* Bare Checkbox in the same fixed-size Box used per-row below + (not FormControlLabel, whose built-in margins would throw + off the alignment) so this checkbox sits in the exact same + column as the per-row ones. */} + + setSelectedVideoIds(allVisibleSelected ? new Set() : new Set(visibleSelectableEntries.map((e) => e.videoId)))} + inputProps={{ 'aria-label': 'Select all' }} + /> + + } + {/* Filter button lives in the same slot each row's "go to + library" button uses below, so it stays in that column + rather than floating loose -- and stays reachable even + once a filter hides every entry (gated on the total + entry count above, not the filtered one). */} + + + setFilterAnchorEl(e.currentTarget)} + aria-label="Filter by tag" + color={(selectedTagFilters.size > 0 || selectedSystemFilters.size > 0) ? 'primary' : 'default'} + > + + + + + + + } {selectedPlaylist && selectedPlaylist.entries.length > 0 && filteredEntries.length === 0 && - No videos match the selected filter.} + No videos match your search or the selected filter.} {filteredEntries.map((entry) => { const videoDir = selectedPlaylist?.localFiles[entry.videoId]; const entryQuality = getEntryQuality(entry.videoId); const entryAppliedTags = Object.keys(videoTags).filter((name) => videoTags[name].includes(entry.videoId)); + const isManualThumbnail = !!selectedPlaylist?.manualThumbnailVideoId && selectedPlaylist.manualThumbnailVideoId === entry.videoId; + const downloadedEntry = getDownloadedEntry(entry); + const entryQueueLoading = queueLoadingIds.has(entry.videoId); return ( - {/* Both slots are always rendered (blank when not + {/* All three slots are always rendered (blank when not applicable) so the checkbox column stays aligned across rows regardless of whether an entry is - unavailable or lacks a library link. */} + unavailable, isn't queueable, or lacks a library + link. */} {!entry.unavailable && } + + {downloadedEntry && + + + handleAddEntryToQueue(entry)} + aria-label="Add to queue" + disabled={entryQueueLoading} + // Stays visible (not just hover-revealed) + // while loading, so the spinner doesn't + // disappear the moment the pointer leaves + // the row after the click that started it. + sx={{ opacity: entryQueueLoading ? 1 : 0, transition: 'opacity 0.1s' }} + > + {entryQueueLoading ? : } + + + } + {videoDir && @@ -581,7 +751,33 @@ export default function PlaylistsSection({ onBulkBarUpdate }: { onBulkBarUpdate: } > - + + + {!entry.unavailable && + + handleSetPlaylistThumbnail(isManualThumbnail ? null : entry.videoId)} + aria-label={isManualThumbnail ? 'Reset to automatic thumbnail' : 'Set as playlist thumbnail'} + sx={{ + position: 'absolute', top: -6, right: -6, width: 20, height: 20, + backgroundColor: 'background.paper', + '&:hover': { backgroundColor: 'background.paper' }, + // Always visible once active, so it's clear at a + // glance which entry the thumbnail follows even + // without hovering -- hover-only otherwise, to + // keep every other row's chrome out of the way. + opacity: isManualThumbnail ? 1 : 0, + transition: 'opacity 0.15s', + }} + > + {isManualThumbnail + ? + : } + + } + = {}): BackgroundPlayerVideo { + return { + videoId: 'v1', title: 'Video One', channel: 'Some Channel', + thumbnailPath: null, sourcePath: '/lib/c/v1/1/video.mp4', mimeType: 'video/mp4', + ...overrides, + }; +} + +// Drives the shared background-player context from outside QueueDrawer +// itself, same pattern MiniPlayerBar.test.tsx already uses. Starts the +// drawer closed and opens it via its own button -- MUI's Modal (which +// Drawer is built on) marks sibling content aria-hidden while open, which +// would otherwise make these enqueue buttons unreachable by role once the +// drawer is already open, same as real usage (MiniPlayerBar only opens the +// drawer after items already exist). +function Harness({ videos, onClose }: { videos: BackgroundPlayerVideo[]; onClose: () => void }) { + const { enqueue } = useBackgroundPlayer(); + const [open, setOpen] = useState(false); + return ( + <> + {videos.map((v) => ( + + ))} + + { setOpen(false); onClose(); }} /> + + ); +} + +function renderDrawer(videos: BackgroundPlayerVideo[], onClose = vi.fn()) { + const utils = render( + + + + + , + ); + return { ...utils, onClose }; +} + +// Enqueues both videos then opens the drawer -- shared setup for every +// test below, since MUI's Modal hides sibling content (including the +// Harness's own enqueue buttons) via aria-hidden once the drawer is open. +async function enqueueBothAndOpen(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'enqueue v1 (test)' })); + await user.click(screen.getByRole('button', { name: 'enqueue v2 (test)' })); + await user.click(screen.getByRole('button', { name: 'open drawer (test)' })); +} + +// A title matching a queue item's own title can also appear in the header +// (which shows the currently-playing item's title) -- this scopes to the +// specific row in the list, not the header. +function rowText(text: string): HTMLElement { + const match = screen.getAllByText(text).find((el) => el.closest('li')); + if (!match) throw new Error(`No row found with text: ${text}`); + return match; +} + +describe('QueueDrawer', () => { + it('renders every queued item and highlights the currently-playing one', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1', title: 'Video One' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + + await screen.findByText('Video Two'); + expect(screen.getAllByText('Video One').length).toBeGreaterThan(0); + expect(rowText('Video One').closest('li')).toHaveAttribute('data-active', 'true'); + expect(rowText('Video Two').closest('li')).not.toHaveAttribute('data-active'); + }); + + it('disables previous at the start of the queue and next at the end', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + + expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next' })).not.toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByRole('button', { name: 'Previous' })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('play-pause and previous/next call through to the background player', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1', title: 'Video One' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + + await user.click(screen.getByRole('button', { name: 'Next' })); + expect(rowText('Video One').closest('li')).not.toHaveAttribute('data-active'); + expect(rowText('Video Two').closest('li')).toHaveAttribute('data-active', 'true'); + }); + + it('clicking a row (not its thumbnail) plays that item instead of navigating', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1', title: 'Video One' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + expect(rowText('Video One').closest('li')).toHaveAttribute('data-active', 'true'); + + await user.click(rowText('Video Two')); + + expect(rowText('Video One').closest('li')).not.toHaveAttribute('data-active'); + expect(rowText('Video Two').closest('li')).toHaveAttribute('data-active', 'true'); + // Still on the same page -- no navigation happened. + expect(rowText('Video Two')).toBeInTheDocument(); + }); + + it('removes an item from the queue via its own hover-revealed remove button', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1', title: 'Video One' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + + await user.click(screen.getByRole('button', { name: 'Remove Video Two from queue' })); + + expect(screen.queryByText('Video Two')).not.toBeInTheDocument(); + expect(rowText('Video One')).toBeInTheDocument(); + }); + + it('clicking a queued item\'s thumbnail links to its (clip-aware) detail route and closes the drawer', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1' }), makeVideo({ videoId: 'v2', title: 'Video Two', clipId: 'clip1' })]; + const { onClose } = renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + + // The title text itself isn't inside the link (only the thumbnail is, + // per the row-click-plays-instead behavior tested elsewhere) -- find it + // via the row, not by matching link text. + const secondLink = rowText('Video Two').closest('li')!.querySelector('a')!; + expect(secondLink).toHaveAttribute('href', '/library/video/v2?view=clips&clip=clip1'); + + await user.click(secondLink); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows the currently-playing item\'s title at the top, updating as playback moves through the queue', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1', title: 'Video One' }), makeVideo({ videoId: 'v2', title: 'Video Two' })]; + renderDrawer(videos); + await enqueueBothAndOpen(user); + await screen.findByText('Video Two'); + + expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent('Video One'); + + await user.click(screen.getByRole('button', { name: 'Next' })); + + expect(screen.getByRole('heading', { level: 6 })).toHaveTextContent('Video Two'); + }); + + describe('audio/video mode toggle', () => { + it('defaults to audio only -- no live video frame, no native controls', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1' })]; + renderDrawer(videos); + await user.click(screen.getByRole('button', { name: 'enqueue v1 (test)' })); + await user.click(screen.getByRole('button', { name: 'open drawer (test)' })); + await screen.findByRole('button', { name: 'Audio only' }); + + expect(screen.getByRole('button', { name: 'Audio only' })).toHaveAttribute('aria-pressed', 'true'); + // The video can live inside MUI's Modal portal (document.body), not + // necessarily under this render's own container. + expect(document.querySelector('video')).not.toHaveAttribute('controls'); + }); + + it('switching to Video reveals the live element with native controls; switching back hides it again', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1' })]; + renderDrawer(videos); + await user.click(screen.getByRole('button', { name: 'enqueue v1 (test)' })); + await user.click(screen.getByRole('button', { name: 'open drawer (test)' })); + await screen.findByRole('button', { name: 'Video' }); + + await user.click(screen.getByRole('button', { name: 'Video' })); + expect(document.querySelector('video')).toHaveAttribute('controls'); + + await user.click(screen.getByRole('button', { name: 'Audio only' })); + expect(document.querySelector('video')).not.toHaveAttribute('controls'); + }); + + it('shows a play/pause + seek bar (same as MiniPlayerBar\'s own) while in audio mode, since native video controls aren\'t visible there', async () => { + const user = userEvent.setup(); + const videos = [makeVideo({ videoId: 'v1' })]; + renderDrawer(videos); + await user.click(screen.getByRole('button', { name: 'enqueue v1 (test)' })); + await user.click(screen.getByRole('button', { name: 'open drawer (test)' })); + await screen.findByRole('button', { name: 'Audio only' }); + + expect(screen.getByRole('slider', { name: 'Seek' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Video' })); + expect(screen.queryByRole('slider', { name: 'Seek' })).not.toBeInTheDocument(); + }); + }); + + it('drags the resize handle to change the drawer\'s width', async () => { + const videos = [makeVideo({ videoId: 'v1' })]; + renderDrawer(videos); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'enqueue v1 (test)' })); + await user.click(screen.getByRole('button', { name: 'open drawer (test)' })); + const handle = await screen.findByTestId('queue-drawer-resize-handle'); + const panel = handle.parentElement as HTMLElement; + const widthBefore = panel.style.width; + + fireEvent.pointerDown(handle, { clientX: 360 }); + fireEvent.pointerMove(window, { clientX: 460 }); + fireEvent.pointerUp(window); + + expect(panel.style.width).not.toBe(widthBefore); + expect(panel.style.width).toBe('460px'); + }); +}); diff --git a/src/ui/components/QueueDrawer.tsx b/src/ui/components/QueueDrawer.tsx new file mode 100644 index 0000000..d512568 --- /dev/null +++ b/src/ui/components/QueueDrawer.tsx @@ -0,0 +1,229 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { Link as RouterLink } from 'react-router'; +import { + Avatar, Box, Divider, Drawer, IconButton, List, ListItem, Slider, Stack, ToggleButton, ToggleButtonGroup, Tooltip, Typography, +} from '@mui/material'; +import PlayArrowIcon from '@mui/icons-material/PlayArrow'; +import PauseIcon from '@mui/icons-material/Pause'; +import SkipPreviousIcon from '@mui/icons-material/SkipPrevious'; +import SkipNextIcon from '@mui/icons-material/SkipNext'; +import MusicNoteIcon from '@mui/icons-material/MusicNote'; +import AudiotrackIcon from '@mui/icons-material/Audiotrack'; +import VideocamIcon from '@mui/icons-material/Videocam'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; +import { buildAppVideoUrl } from '../../utils/utils.ts'; +import { useBackgroundPlayer, videoDetailPathFor, formatPlaybackTime } from '../hooks/useBackgroundPlayer.tsx'; + +const MIN_WIDTH = 280; +const MAX_WIDTH = 640; +const DEFAULT_WIDTH = 360; + +type MediaMode = 'audio' | 'video'; + +// Opened from MiniPlayerBar.tsx's up-arrow -- the full queue view, sliding +// in from the screen's left edge. +export default function QueueDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { + const { + queue, currentIndex, current, paused, currentTime, duration, pause, resume, seek, + next, previous, playAt, removeAt, setVisibleContainer, + } = useBackgroundPlayer(); + const [mediaMode, setMediaMode] = useState('audio'); + const [width, setWidth] = useState(DEFAULT_WIDTH); + const draggingRef = useRef(false); + + // Drags the drawer's right edge to resize it -- window-level pointermove/ + // pointerup listeners (not React's own onPointerMove) since the pointer + // routinely leaves this element's bounds mid-drag. + const handleResizePointerDown = (e: ReactPointerEvent) => { + e.preventDefault(); + draggingRef.current = true; + const startX = e.clientX; + const startWidth = width; + const handleMove = (moveEvent: globalThis.PointerEvent) => { + if (!draggingRef.current) return; + setWidth(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + (moveEvent.clientX - startX)))); + }; + const handleUp = () => { + draggingRef.current = false; + window.removeEventListener('pointermove', handleMove); + window.removeEventListener('pointerup', handleUp); + }; + window.addEventListener('pointermove', handleMove); + window.addEventListener('pointerup', handleUp); + }; + + return ( + + + {current && + + {current.title || current.videoId} + {current.channel && + {current.channel}} + } + + + + + + + + + + + (paused ? resume() : pause())} aria-label={paused ? 'Play' : 'Pause'}> + {paused ? : } + + + + = queue.length - 1} aria-label="Next"> + + + + + + {/* Same style as LibraryScreen.tsx's own by-channel/by-video + LibraryViewModeToggle -- an exclusive, icon-only + ToggleButtonGroup. Audio is the default: reparenting the live + + + {current && mediaMode === 'video' && + // The one real background-player + } + > + playAt(index)} + sx={{ + display: 'flex', alignItems: 'center', gap: 1.5, width: '100%', px: 2, py: 1, pr: 5, + cursor: 'pointer', + }} + > + {/* Only the thumbnail navigates -- clicking anywhere else on + the row plays that entry instead (see playAt above). */} + { e.stopPropagation(); onClose(); }} + sx={{ flexShrink: 0, display: 'block', lineHeight: 0 }} + > + + + + + + {item.title || item.videoId} + {item.channel && + {item.channel}} + + + + ))} + + + {/* Drag-to-resize handle -- window-level pointer listeners (attached + on pointerdown, see handleResizePointerDown) so dragging keeps + tracking even once the pointer leaves this thin strip. */} + + + + ); +} diff --git a/src/ui/hooks/useBackgroundPlayer.test.tsx b/src/ui/hooks/useBackgroundPlayer.test.tsx new file mode 100644 index 0000000..65892cb --- /dev/null +++ b/src/ui/hooks/useBackgroundPlayer.test.tsx @@ -0,0 +1,384 @@ +// @vitest-environment jsdom +import { StrictMode, type ReactNode } from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { act, render, renderHook, waitFor } from '@testing-library/react'; +import { BackgroundPlayerProvider, useBackgroundPlayer, videoDetailPathFor, nativelyPlayableSource, type BackgroundPlayerVideo } from './useBackgroundPlayer'; + +function makeVideo(overrides: Partial = {}): BackgroundPlayerVideo { + return { + videoId: 'v1', title: 'Video One', channel: 'Some Channel', + thumbnailPath: '/lib/c/v1/thumb.jpg', sourcePath: '/lib/c/v1/1/video.mp4', mimeType: 'video/mp4', + ...overrides, + }; +} + +describe('videoDetailPathFor', () => { + it('links to the plain video route by default', () => { + expect(videoDetailPathFor(makeVideo({ videoId: 'abc123' }))).toBe('/library/video/abc123'); + }); + + it('links into Clip Collection with the clip pre-selected when clipId is set', () => { + expect(videoDetailPathFor(makeVideo({ videoId: 'abc123', clipId: 'clip1' }))) + .toBe('/library/video/abc123?view=clips&clip=clip1'); + }); +}); + +describe('nativelyPlayableSource', () => { + it('returns null for a missing or non-native file', () => { + expect(nativelyPlayableSource(null)).toBeNull(); + expect(nativelyPlayableSource(undefined)).toBeNull(); + expect(nativelyPlayableSource('/lib/c/v1/1/video.mkv')).toBeNull(); + }); + + it('resolves mp4/webm to their source path and mime type', () => { + expect(nativelyPlayableSource('/lib/c/v1/1/video.mp4')).toEqual({ sourcePath: '/lib/c/v1/1/video.mp4', mimeType: 'video/mp4' }); + expect(nativelyPlayableSource('/lib/c/v1/1/video.webm')).toEqual({ sourcePath: '/lib/c/v1/1/video.webm', mimeType: 'video/webm' }); + }); +}); + +describe('useBackgroundPlayer', () => { + it('throws when used outside a BackgroundPlayerProvider', () => { + expect(() => renderHook(() => useBackgroundPlayer())).toThrow(/must be used within a BackgroundPlayerProvider/); + }); + + it('starts with an empty queue and nothing playing', () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + expect(result.current.queue).toEqual([]); + expect(result.current.current).toBeNull(); + expect(result.current.paused).toBe(true); + }); + + it('enqueue() on an empty queue starts playing that item immediately', async () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + // jsdom's HTMLMediaElement.play() rejects ("not implemented") -- expected + // and already swallowed by the hook itself (see its own .catch(() => {})). + act(() => result.current.enqueue(makeVideo())); + + await waitFor(() => expect(result.current.current).toEqual(makeVideo())); + expect(result.current.currentIndex).toBe(0); + expect(result.current.videoRef.current?.src).toContain('app-video://local/'); + }); + + it('enqueue() while something is already playing appends without interrupting it', async () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + act(() => result.current.enqueue(makeVideo({ videoId: 'v1' }))); + await waitFor(() => expect(result.current.current?.videoId).toBe('v1')); + const srcAfterFirst = result.current.videoRef.current?.src; + + act(() => result.current.enqueue(makeVideo({ videoId: 'v2', title: 'Video Two' }))); + + expect(result.current.queue).toHaveLength(2); + expect(result.current.current?.videoId).toBe('v1'); + expect(result.current.currentIndex).toBe(0); + expect(result.current.videoRef.current?.src).toBe(srcAfterFirst); + }); + + it('enqueue() refuses an exact repeat (same videoId and clipId), showing a toast instead', async () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + act(() => result.current.enqueue(makeVideo({ videoId: 'v1', title: 'Video One' }))); + await waitFor(() => expect(result.current.queue).toHaveLength(1)); + expect(result.current.toastMessage).toBeNull(); + + act(() => result.current.enqueue(makeVideo({ videoId: 'v1', title: 'Video One' }))); + + expect(result.current.queue).toHaveLength(1); + expect(result.current.toastMessage).toContain('Video One'); + + act(() => result.current.dismissToast()); + expect(result.current.toastMessage).toBeNull(); + }); + + it('enqueue() allows the same videoId with a different clipId (a clip vs. its parent video)', async () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + act(() => result.current.enqueue(makeVideo({ videoId: 'v1', clipId: null }))); + await waitFor(() => expect(result.current.queue).toHaveLength(1)); + + act(() => result.current.enqueue(makeVideo({ videoId: 'v1', clipId: 'clip1' }))); + + expect(result.current.queue).toHaveLength(2); + expect(result.current.toastMessage).toBeNull(); + }); + + it('two enqueue() calls fired back to back (before either has re-rendered) both land correctly, with the video element matching the first item -- regression for the "added item stays gray, never plays" bug', async () => { + const { result } = renderHook(() => useBackgroundPlayer(), { wrapper: BackgroundPlayerProvider }); + + // Both calls inside one act(): neither has had a chance to re-render + // (and thus refresh `queue`/`currentIndex` in a plain closure) before + // the second one runs -- exactly the "two clicks in quick succession" + // scenario the bug report described ("adding individual items from + // different playlists ... only sometimes"). + act(() => { + result.current.enqueue(makeVideo({ videoId: 'v1', title: 'Video One', sourcePath: '/lib/c/v1/1/video.mp4' })); + result.current.enqueue(makeVideo({ videoId: 'v2', title: 'Video Two', sourcePath: '/lib/c/v2/1/video.mp4' })); + }); + + expect(result.current.queue.map((v) => v.videoId)).toEqual(['v1', 'v2']); + expect(result.current.currentIndex).toBe(0); + expect(result.current.current?.videoId).toBe('v1'); + // The real