diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 114b0c60..6a3c9143 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -262,7 +262,9 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete without a button that reads exactly that, as on a full player before v3. `shot` hides Obsidian's status bar, except in `status-bar`, which puts a `9 properties` item in it over the mini player and has no phone shot, because - Obsidian hides the status bar on mobile. + Obsidian hides the status bar on mobile. The states and their seeded data + live in `screens/scenes.mjs`, the instance calls in `screens/instance.mjs`, + and the contact sheet in `screens/sheet.mjs`. - `.claude/skills/verify/fixtures.mjs` runs as `feed`, `feed-b`, `transcripts`, `library`, `retitle`, `requests`, `audio [vault path]` or `stop`. `library` prints the URL of a manifest of eight podcasts, one whose feed answers 404, and diff --git a/.claude/skills/verify/screens.mjs b/.claude/skills/verify/screens.mjs index 6fb89716..f0685d17 100644 --- a/.claude/skills/verify/screens.mjs +++ b/.claude/skills/verify/screens.mjs @@ -52,966 +52,27 @@ import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { - cliSocketExists, - execObsidian, - loadRunnerConfig, - obsidianCommandArgs, - resolveInstanceOptions, - resolveProvisionOptions, -} from "obsidian-e2e/runner"; -import { FIXTURE_HOST } from "../../../scripts/e2e-instance.mjs"; + episodeNote, + evaluate, + go, + offline, + place, + playback, + press, + root, + shot, + statusItem, + theme, + THEMES, + UI, + WIDTHS, +} from "./screens/instance.mjs"; +import { library, seed, STATES } from "./screens/scenes.mjs"; +import { contactSheet } from "./screens/sheet.mjs"; -const root = fileURLToPath(new URL("../../..", import.meta.url)); -const config = await loadRunnerConfig(root); -const provision = resolveProvisionOptions({}, config, root); -const instance = resolveInstanceOptions(provision, {}, config, root); -const DATA_PATH = path.join(provision.vaultPath, ".obsidian/plugins/podnotes/data.json"); -const UI = fileURLToPath(new URL("ui.mjs", import.meta.url)); -const FIXTURES = fileURLToPath(new URL("fixtures.mjs", import.meta.url)); const OUT = path.join(root, ".obsidian-e2e-artifacts/screens"); const DESIGN = path.join(os.homedir(), ".claude/orchestrate/podnotes/design/screens"); -const VIEW_TYPE = "podcast_player_view"; -const CALL_TIMEOUT_MS = 15000; -/** @type {Record} */ -const WIDTHS = { sidebar: [320, 720], pane: [820, 680], phone: [390, 844] }; -/** @type {Record} */ -const THEMES = { light: "moonstone", dark: "obsidian" }; -const CURRENT_THEME = `(document.body.classList.contains("theme-dark") ? "dark" : "light")`; - -/** - * @typedef {{ title: string, podcastName: string, duration: number, episodeDate: string }} Episode - * @typedef {{ feed: { title: string }, missing: boolean, episodes: Episode[] }} LibraryFeed - * @typedef {{ feeds: LibraryFeed[], add: { title: string, url: string } }} Library - * @typedef {{ kind: string, key?: string, name?: string }} Page - * @typedef {Record} Data - * @typedef {(data: Data, library: Library) => void | Promise} Ingredient - */ - -/** - * @typedef {object} Recipe - * @property {string} scene - * @property {Ingredient} [extra] Applied after the scene's ingredients. - * @property {(library: Library) => Page} page - * @property {(library: Library) => string} [text] Text the view shows once it is ready. - * @property {boolean} [paste] - * @property {boolean} [offline] - * @property {boolean} [statusBar] - * @property {boolean} [open] Opens the full player. - * @property {number | "error"} [play] The second the current episode pauses at, or "error" - * when its media fails to load. - * @property {boolean} [note] The episode note already holds a capture. - * @property {string} [press] The full player's button to press, by its text. - * @property {string} [notice] Text an Obsidian notice shows, which the shot keeps. - */ - -/** The episode the listener is in the middle of, as in the prototype. @type {[string, string]} */ -const NOW_PLAYING = ["Field Notes", "#213"]; -const NOW_PLAYING_AT = 2294; -/** @type {[string, string]} */ -const NO_CHAPTERS = ["Signal & Noise", "Ep. 87"]; -/** @type {[string, string]} */ -const VIDEO = ["Signal & Noise", "Ep. 86"]; -/** @type {[string, string]} */ -const BROKEN_MEDIA = ["Signal & Noise", "Ep. 85"]; -/** @type {Recipe} */ -const FULL_PLAYER = { - scene: "lists", - page: () => ({ kind: "library" }), - open: true, - play: NOW_PLAYING_AT, -}; - -/** @type {Record} */ -const STATES = { - "first-run": { scene: "empty", page: () => ({ kind: "library" }) }, - "add-podcast": { - scene: "library", - page: () => ({ kind: "add" }), - text: (library) => library.add.title, - paste: true, - }, - library: { scene: "library", page: () => ({ kind: "library" }) }, - episodes: { - scene: "episodes", - page: (library) => ({ kind: "feed", key: longestTitle(library).podcastName }), - text: (library) => longestTitle(library).title.slice(0, 40), - }, - "error-feed": { - scene: "library", - page: (library) => ({ kind: "feed", key: missingFeed(library).feed.title }), - }, - queue: { scene: "lists", page: () => ({ kind: "queue" }), text: () => QUEUE[0][1] }, - playlists: { - scene: "lists", - page: () => ({ kind: "playlists" }), - text: () => PLAYLISTS[PLAYLISTS.length - 1].name, - }, - playlist: { - scene: "lists", - page: () => ({ kind: "playlist", name: PLAYLIST.name }), - text: () => PLAYLIST.episodes[0][1], - }, - latest: { scene: "lists", page: () => ({ kind: "latest" }), text: newestTitle }, - played: { scene: "lists", page: () => ({ kind: "played" }), text: () => PLAYED[1][0][1] }, - "local-files": { - scene: "local", - page: () => ({ kind: "local" }), - text: () => path.parse(LOCAL_FILES[0][0]).name, - }, - offline: { - scene: "offline", - page: () => ({ kind: "latest" }), - text: newestTitle, - offline: true, - }, - "status-bar": { - scene: "lists", - page: () => ({ kind: "queue" }), - text: () => QUEUE[0][1], - statusBar: true, - }, - // The chapters load after the player opens. This one is #213's at NOW_PLAYING_AT. - "now-playing": { ...FULL_PLAYER, text: () => "The method of loci in practice" }, - "mini-player": { - scene: "lists", - page: (library) => ({ kind: "feed", key: episode(library, NOW_PLAYING).podcastName }), - play: NOW_PLAYING_AT, - text: (library) => episode(library, NOW_PLAYING).title.slice(0, 40), - }, - // The capture's notice, which has to outlast the shot. - capture: { ...FULL_PLAYER, note: true, press: "Timestamp", notice: "Timestamp added to" }, - // #213's last chapter. - chapters: { ...FULL_PLAYER, press: "Chapters", text: () => "Listener question" }, - "up-next": { ...FULL_PLAYER, press: "Up next", text: () => QUEUE[0][1] }, - "up-next-empty": { - ...FULL_PLAYER, - extra: (data) => { - data.queue.episodes = []; - }, - press: "Up next", - }, - "no-chapters": { - ...FULL_PLAYER, - extra: (data, library) => { - data.currentEpisode = episode(library, NO_CHAPTERS); - }, - play: 1140, - text: () => NO_CHAPTERS[1], - }, - video: { - ...FULL_PLAYER, - extra: (data, library) => { - data.currentEpisode = episode(library, VIDEO); - data.queue.episodes = latest(library).slice(0, 14); - }, - play: 4, - text: () => VIDEO[1], - }, - "local-player": { - ...FULL_PLAYER, - scene: "local", - extra: (data) => { - data.currentEpisode = data.downloadedEpisodes["local file"].find( - (/** @type {{ filePath: string }} */ file) => file.filePath === LOCAL_FILES[0][0], - ); - }, - play: 1283, - text: () => path.parse(LOCAL_FILES[0][0]).name, - }, - "media-error": { - ...FULL_PLAYER, - extra: (data, library) => { - data.currentEpisode = episode(library, BROKEN_MEDIA); - }, - play: "error", - text: () => BROKEN_MEDIA[1], - }, -}; -const STATUS_ITEM = "podnotes-verify-status-item"; - -// What a listener of the fixture library did with it, as in the design prototype. -// Episodes are named by the start of their podcast's title and of their own. -/** @type {[string, string][]} */ -const QUEUE = [ - ["Slow Thinking", "The case for boredom"], - ["Lunch Break History", "Who invented the index?"], - ["Signal & Noise", "Ep. 88"], - ["Night Owl Science", "Sleep, memory"], -]; -const PLAYLIST = { - name: "Commute", - /** @type {[string, string][]} */ - episodes: [ - ["Lunch Break History", "Who invented the index?"], - ["Making Sense of Markets", "Rates"], - ["Signal & Noise", "Ep. 87"], - ["Slow Thinking", "Walking"], - ["Small Rooms", "A desk"], - ["Lunch Break History", "Medieval"], - ], -}; -/** @type {[[string, string], number | "finished"][]} */ -const PLAYED = [ - [NOW_PLAYING, NOW_PLAYING_AT], - [["Field Notes", "#212"], "finished"], - [["Field Notes", "#210"], "finished"], - [["Field Notes", "#209"], 760], - [["Field Notes", "#208"], "finished"], - [["Field Notes", "#206"], "finished"], - [["Signal & Noise", "Ep. 88"], 760], - [["Night Owl Science", "Sleep, memory"], 600], -]; -/** @type {[string, string][]} */ -const FAVORITES = [ - ["Field Notes", "#212"], - ["The Archive Hour", "Luhmann"], - ["Slow Thinking", "Attention"], - ["Lunch Break History", "The library of Alexandria"], - ["Field Notes", "#208"], -]; -const PLAYLISTS = [ - { - name: "Memory research", - /** @type {[string, string][]} */ - episodes: [ - ["Field Notes", "#213"], - ["Night Owl Science", "Sleep, memory"], - ["The Archive Hour", "Commonplace books"], - ["Field Notes", "#209"], - ], - }, - { - name: "Long walks, slow ideas", - /** @type {[string, string][]} */ - episodes: [ - ["Slow Thinking", "Walking"], - ["Field Notes", "#211"], - ["The Archive Hour", "Luhmann"], - ], - }, -]; -/** @type {[string, string][]} */ -const DOWNLOADS = [ - ["Field Notes", "#213"], - ["Field Notes", "#211"], - ["Slow Thinking", "The case for boredom"], - ["Night Owl Science", "Sleep, memory"], -]; -/** Vault media: path, length in seconds, and the played position. @type {[string, number, (number | "finished")?][]} */ -const LOCAL_FILES = [ - ["Recordings/Family/Interview with grandma.m4a", 3850], - ["University/Psychology/Lecture 07 - Cognitive load theory.mp4", 2913, 1032], - ["Inbox/Voice memo 2026-09-14.webm", 221], - ["Recordings/Book club/Book club - The Overstory, chapters 1-4.mp3", 5460, "finished"], -]; -/** Silent media small enough for a vault, by container. @type {Record} */ -const ENCODERS = { - ".m4a": ["-c:a", "aac", "-b:a", "8k"], - ".mp4": ["-c:v", "libx264", "-crf", "51", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "8k"], - ".webm": ["-c:a", "libopus", "-b:a", "6k"], - ".mp3": ["-c:a", "libmp3lame", "-b:a", "8k"], -}; - -/** Each scene's ingredients, applied in order to the default data.json. @type {Record} */ -const SCENES = { - empty: [], - library: [libraryData], - episodes: [libraryData], - lists: [libraryData, listening], - local: [libraryData, listening, localFiles], - offline: [libraryData, listening, downloads], -}; - -/** @param {string[]} args */ -async function obsidian(args) { - if (!(await cliSocketExists(instance))) { - throw new Error( - "This worktree's Obsidian instance is not running: npm run start:e2e-obsidian", - ); - } - const started = Date.now(); - const { stdout } = await execObsidian( - instance, - obsidianCommandArgs(instance.vaultName, args), - {}, - { timeout: CALL_TIMEOUT_MS }, - ); - // The CLI exits 0 with no output when the timeout kills it, so only the clock tells. - if (Date.now() - started >= CALL_TIMEOUT_MS) { - throw new Error(`obsidian ${args[0]} did not answer within ${CALL_TIMEOUT_MS} ms.`); - } - return stdout.trim(); -} - -/** @param {string} body */ -async function evaluate(body) { - const output = await obsidian([ - "eval", - `code=(async () => JSON.stringify((await (async () => {${body}})()) ?? null))()`, - ]); - if (!output.startsWith("=> ")) throw new Error(output || "eval printed nothing"); - return JSON.parse(output.slice(3)); -} - -/** @param {string} method @param {object} [params] */ -async function cdp(method, params = {}) { - const output = await obsidian([ - "dev:cdp", - `method=${method}`, - `params=${JSON.stringify(params)}`, - ]); - if (!output.startsWith("{")) throw new Error(`${method}: ${output}`); - return JSON.parse(output); -} - -/** @param {number} ms */ -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Errors count as false, because the window reloads under some of these waits. - * @param {string} body @param {string} what @param {number} [timeoutMs] - */ -async function waitFor(body, what, timeoutMs = 30000) { - const deadline = Date.now() + timeoutMs; - let last = ""; - for (;;) { - try { - if ((await evaluate(body)) === true) return; - } catch (error) { - last = error instanceof Error ? error.message : String(error); - } - if (Date.now() > deadline) throw new Error(`${what} after ${timeoutMs} ms. ${last}`); - await sleep(250); - } -} - -/** @param {boolean} on */ -async function setMobile(on) { - const { mobile, origin } = await evaluate( - "return { mobile: app.isMobile, origin: performance.timeOrigin };", - ); - if (mobile === on) return; - await evaluate(`setTimeout(() => app.emulateMobile(${on}), 50); return true;`); - await waitFor( - `return performance.timeOrigin !== ${origin} && app.isMobile === ${on} && - app.workspace.layoutReady && Boolean(app.plugins.plugins.podnotes);`, - `Obsidian did not reload ${on ? "into" : "out of"} mobile emulation`, - ); -} - -/** @param {string} name */ -async function place(name) { - if (!(name in WIDTHS)) throw new Error("Usage: place "); - const [width, height] = WIDTHS[name]; - const sidebar = name === "sidebar"; - const phone = name === "phone"; - if (phone) { - await cdp("Emulation.setDeviceMetricsOverride", { - width, - height, - deviceScaleFactor: 1, - mobile: true, - screenWidth: width, - screenHeight: height, - }); - } else { - await cdp("Emulation.clearDeviceMetricsOverride"); - } - await setMobile(phone); - await evaluate(` - const { workspace } = app; - const inSidebar = (leaf) => leaf.getRoot() === workspace.rightSplit; - for (const leaf of workspace.getLeavesOfType("${VIEW_TYPE}")) { - if (inSidebar(leaf) !== ${sidebar}) leaf.detach(); - } - if (workspace.getLeavesOfType("${VIEW_TYPE}").length === 0) { - // Not getLeaf(false): it can return the leaf just detached when that leaf had focus. - let leaf = ${sidebar} ? workspace.getRightLeaf(false) : undefined; - workspace.iterateRootLeaves((candidate) => { - if (!${sidebar} && (!leaf || candidate.view.getViewType() === "empty")) leaf = candidate; - }); - await leaf.setViewState({ type: "${VIEW_TYPE}" }); - } - // Show player's path: it reveals the leaf, and on mobile mounts the view. - await app.plugins.plugins.podnotes.activateView(); - workspace.leftSplit.collapse(); - if (!${sidebar}) workspace.rightSplit.collapse(); - return true; - `); - const measure = async () => { - await waitFor( - `return document.querySelector(".podcast-view")?.getBoundingClientRect().width > 0;`, - "The PodNotes view did not appear", - 10000, - ); - return evaluate(` - await new Promise((resolve) => setTimeout(resolve, 300)); - const rect = document.querySelector(".podcast-view").getBoundingClientRect(); - return { width: rect.width, height: ${phone} ? innerHeight : rect.height, - phone: document.body.classList.contains("is-phone") }; - `); - }; - // Obsidian's chrome can leave the view a fraction of a pixel taller; a capture rounds it. - const fits = (/** @type {{ width: number, height: number }} */ view) => - view.width === width && Math.round(view.height) === height; - let measured = await measure(); - for (let attempt = 0; !phone && attempt < 3; attempt++) { - if (fits(measured)) break; - await evaluate(` - const win = require("electron").remote.getCurrentWindow(); - if (win.isFullScreen()) win.setFullScreen(false); - if (win.isMaximized()) win.unmaximize(); - const [contentWidth, contentHeight] = win.getContentSize(); - const dw = ${width} - ${measured.width}; - const dh = ${height} - ${measured.height}; - if (${sidebar}) app.workspace.rightSplit.setSize(app.workspace.rightSplit.size + dw); - win.setContentSize( - ${sidebar} ? Math.max(contentWidth, 1024) : Math.round(contentWidth + dw), - Math.round(contentHeight + dh), - ); - return true; - `); - measured = await measure(); - } - const problems = [ - measured.width !== width && `the view is ${measured.width} px wide, not ${width}`, - Math.round(measured.height) !== height && - `${phone ? "the screen" : "the view"} is ${measured.height} px tall, not ${height}`, - phone !== measured.phone && `body.is-phone is ${measured.phone}`, - ].filter(Boolean); - if (problems.length > 0) throw new Error(`place ${name}: ${problems.join("; ")}.`); - console.log(JSON.stringify({ place: name, width: measured.width, height: measured.height })); -} - -/** @param {string} name */ -async function theme(name) { - if (!(name in THEMES)) throw new Error("Usage: theme "); - await evaluate(`app.changeTheme("${THEMES[name]}"); return true;`); - await waitFor( - `return document.body.classList.contains("theme-${name}");`, - `The ${name} theme did not apply`, - 5000, - ); -} - -/** @returns {Promise} */ -async function library() { - const url = execFileSync(process.execPath, [FIXTURES, "library"], { encoding: "utf8" }).trim(); - const response = await fetch(url.replace(`//${FIXTURE_HOST}:`, "//127.0.0.1:")); - if (!response.ok) { - throw new Error( - `${url} answered ${response.status}. Restart the fixture server: node .claude/skills/verify/fixtures.mjs stop`, - ); - } - return response.json(); -} - -/** @param {Library} lib */ -function longestTitle(lib) { - return lib.feeds - .flatMap((feed) => feed.episodes) - .reduce((longest, episode) => - episode.title.length > longest.title.length ? episode : longest, - ); -} - -/** @param {Library} lib */ -function missingFeed(lib) { - const feed = lib.feeds.find((candidate) => candidate.missing); - if (!feed) throw new Error("The fixture library has no feed that answers 404."); - return feed; -} - -/** What Latest episodes lists, newest first: Night Owl Science's feed answers 404. @param {Library} lib */ -function latest(lib) { - return lib.feeds - .filter((feed) => !feed.missing) - .flatMap((feed) => feed.episodes) - .sort((a, b) => b.episodeDate.localeCompare(a.episodeDate)); -} - -/** @param {Library} lib */ -function newestTitle(lib) { - return latest(lib)[0].title; -} - -/** @param {Library} lib @param {[string, string]} reference */ -function episode(lib, [podcast, title]) { - const found = lib.feeds - .find((feed) => feed.feed.title.startsWith(podcast)) - ?.episodes.find((candidate) => candidate.title.startsWith(title)); - if (!found) throw new Error(`The fixture library has no episode "${title}" in ${podcast}.`); - return found; -} - -/** @param {{ name: string, episodes: [string, string][] }} list @param {Library} lib */ -function playlist({ name, episodes }, lib) { - return { - icon: "list-music", - name, - shouldEpisodeRemoveAfterPlay: false, - shouldRepeat: false, - episodes: episodes.map((reference) => episode(lib, reference)), - }; -} - -/** - * The playedEpisodes entry PodNotes keeps for an episode. - * @param {{ title: string, podcastName: string }} episode @param {number | "finished"} time @param {number} duration - * @returns {[string, { title: string, podcastName: string, time: number, duration: number, finished: boolean }]} - */ -function playedRecord({ title, podcastName }, time, duration) { - const finished = time === "finished"; - return [ - `${podcastName}::${title}`, - { title, podcastName, time: finished ? duration : time, duration, finished }, - ]; -} - -/** @type {Ingredient} */ -function libraryData(data, lib) { - data.savedFeeds = Object.fromEntries(lib.feeds.map(({ feed }) => [feed.title, feed])); - data.queue.episodes = QUEUE.map((reference) => episode(lib, reference)); - data.playlists = { [PLAYLIST.name]: playlist(PLAYLIST, lib) }; - data.playedEpisodes = Object.fromEntries( - PLAYED.map(([reference, time]) => { - const found = episode(lib, reference); - return playedRecord(found, time, found.duration); - }), - ); -} - -/** Restored at startup, so the mini player shows it paused. @type {Ingredient} */ -function listening(data, lib) { - data.favorites.episodes = FAVORITES.map((reference) => episode(lib, reference)); - for (const list of PLAYLISTS) data.playlists[list.name] = playlist(list, lib); - data.currentEpisode = episode(lib, NOW_PLAYING); - data.defaultPlaybackRate = 1.25; -} - -/** The entries src/playLocalFile.ts writes, for media written into the vault. @type {Ingredient} */ -async function localFiles(data) { - for (const [file, seconds] of LOCAL_FILES) { - const target = path.join(provision.vaultPath, file); - if (fs.existsSync(target)) continue; - const video = path.extname(file) === ".mp4"; - const scratch = path.join( - fs.mkdtempSync(path.join(os.tmpdir(), "podnotes-media-")), - path.basename(file), - ); - execFileSync("ffmpeg", [ - "-v", - "error", - "-f", - "lavfi", - "-i", - "anullsrc=r=8000:cl=mono", - ...(video ? ["-f", "lavfi", "-i", "color=c=0x334155:s=64x36:r=1"] : []), - "-t", - String(seconds), - ...ENCODERS[path.extname(file)], - scratch, - ]); - // Copied in whole, so Obsidian never indexes a file ffmpeg is still writing. - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.copyFileSync(scratch, target); - fs.rmSync(path.dirname(scratch), { recursive: true }); - } - const sizes = Object.fromEntries( - LOCAL_FILES.map(([file]) => [file, fs.statSync(path.join(provision.vaultPath, file)).size]), - ); - await waitFor( - `const sizes = ${JSON.stringify(sizes)}; - return Object.entries(sizes).every(([file, size]) => app.vault.getAbstractFileByPath(file)?.stat.size === size);`, - "Obsidian did not index the local media files", - 10000, - ); - /** @type {{ file: string, title: string, url: string, streamUrl: string, ctime: number }[]} */ - const files = await evaluate(` - return ${JSON.stringify(Object.keys(sizes))}.map((file) => { - const found = app.vault.getAbstractFileByPath(file); - return { file, title: found.basename, url: app.fileManager.generateMarkdownLink(found, ""), - streamUrl: app.vault.getResourcePath(found), ctime: found.stat.ctime }; - }); - `); - data.downloadedEpisodes["local file"] = files.map(({ file, title, url, streamUrl, ctime }) => ({ - title, - description: "", - content: "", - podcastName: "local file", - url, - streamUrl, - filePath: file, - episodeDate: new Date(ctime).toISOString(), - mediaType: path.extname(file) === ".mp4" ? "video" : "audio", - size: sizes[file], - })); - for (const [file, seconds, time] of LOCAL_FILES) { - if (time === undefined) continue; - const [key, record] = playedRecord( - { title: path.parse(file).name, podcastName: "local file" }, - time, - seconds, - ); - data.playedEpisodes[key] = record; - } -} - -/** Downloaded as PodNotes does, to its default download path; the fixture serves a WAV. @type {Ingredient} */ -function downloads(data, lib) { - for (const reference of DOWNLOADS) { - const found = episode(lib, reference); - const file = `PodNotes/${found.podcastName}/${found.title.replace(/[\\/:*?"<>|#^[\]]/g, "")}.wav`; - const target = path.join(provision.vaultPath, file); - if (!fs.existsSync(target)) execFileSync(process.execPath, [FIXTURES, "audio", file]); - (data.downloadedEpisodes[found.podcastName] ??= []).push({ - ...found, - filePath: file, - size: fs.statSync(target).size, - downloadedByPodNotes: true, - }); - } -} - -/** @param {string} scene @param {Ingredient} [extra] */ -async function seed(scene, extra) { - const ingredients = SCENES[scene]; - if (!ingredients) throw new Error(`Usage: seed <${Object.keys(SCENES).join("|")}>`); - /** @type {Data} */ - const data = structuredClone(/** @type {Data} */ (config.defaultData)); - const all = extra ? [...ingredients, extra] : ingredients; - if (all.length > 0) { - const lib = await library(); - for (const add of all) await add(data, lib); - } - await evaluate(` - const plugin = app.plugins.plugins.podnotes; - await app.plugins.disablePlugin("podnotes"); - // Unloading saves without waiting, and that save merges into the file written next. - await plugin?.flushSaves(); - for (const key of Object.keys(localStorage)) { - if (key.includes("podnotes:feed-cache")) localStorage.removeItem(key); - } - // A note left by an earlier capture shows as Open note and as a note mark in lists. - for (const note of app.vault.getMarkdownFiles()) { - if (note.path.startsWith("PodNotes/")) await app.vault.delete(note); - } - return true; - `); - fs.writeFileSync(DATA_PATH, `${JSON.stringify(data, null, "\t")}\n`); - await evaluate(`await app.plugins.enablePlugin("podnotes"); return true;`); - await waitFor( - "return Boolean(app.plugins.plugins.podnotes?.navigation);", - "PodNotes did not load", - 10000, - ); -} - -/** @param {Page} page */ -async function go(page) { - await evaluate(` - const plugin = app.plugins.plugins.podnotes; - plugin.navigation.replace(${JSON.stringify(page)}); - await plugin.activateView(); - return true; - `); -} - -const PLAYER_STATE = `let state; - app.plugins.plugins.podnotes.player.state.subscribe((value) => (state = value))();`; - -/** - * Plays the episode the scene restored and pauses it at `at` seconds once its media has - * loaded, saved there so lists show the same progress every time. "error" waits for the - * load to fail instead. - * @param {number | "error"} at - */ -async function playback(at) { - await evaluate( - `${PLAYER_STATE} app.plugins.plugins.podnotes.player.play(state.episode); return true;`, - ); - if (at === "error") { - await waitFor( - `${PLAYER_STATE} return state.status === "error";`, - "The episode's media did not fail to load", - ); - return; - } - await waitFor( - `${PLAYER_STATE} return state.status === "playing";`, - "The episode's media did not load and play", - ); - await evaluate(` - const { player } = app.plugins.plugins.podnotes; - player.pause(); - player.seek(${at}); - return true; - `); - await waitFor( - `const media = document.querySelector(".podnotes-media"); - return media.paused && !media.seeking && media.readyState >= 2 && media.currentTime === ${at};`, - `The episode did not pause at ${at} s`, - ); - await evaluate("app.plugins.plugins.podnotes.player.save(); return true;"); -} - -/** Captures once without the UI, so the episode note exists, as after an earlier capture. */ -async function episodeNote() { - const capture = await evaluate(` - // The last note open in an editor would take the capture. - app.workspace.detachLeavesOfType("markdown"); - return await app.plugins.plugins.podnotes.capture.timestamp(); - `); - if (capture?.destination !== "note") { - throw new Error(`The capture went to ${JSON.stringify(capture)}, not the episode note.`); - } -} - -/** - * Presses the full player's button that reads `text`, with real input. A panel switch or - * tab that is already on stays on. Fails at once without such a button. - * @param {string} text - */ -async function press(text) { - const button = `[...document.querySelectorAll("section.pn-player button")].find((button) => - button.getBoundingClientRect().width > 0 && button.innerText.trim() === ${JSON.stringify(text)})`; - // "true" or "false" for a panel switch or tab. - const state = `((button) => !button ? "missing" : - button.getAttribute("aria-pressed") ?? button.getAttribute("aria-selected") ?? "action")(${button})`; - const kind = await evaluate(`return ${state};`); - if (kind === "missing") { - throw new Error(`No button in the full player (section.pn-player) reads "${text}".`); - } - if (kind === "true") return; - execFileSync( - process.execPath, - [UI, "click", "section.pn-player button", "--text", text, "--exact"], - { stdio: "inherit" }, - ); - if (kind === "false") { - await waitFor(`return ${state} === "true";`, `"${text}" did not turn on`, 5000); - return; - } - // Timestamp reads "Added" for 1.2 s after a capture. - await sleep(1500); - await waitFor(`return Boolean(${button});`, `"${text}" did not come back`, 5000); -} - -/** @param {boolean} on */ -async function offline(on) { - execFileSync(process.execPath, [FIXTURES, on ? "stop" : "library"]); - const event = on ? "offline" : "online"; - await evaluate(` - window.__podnotesVerifyNetwork = navigator.onLine === ${on} - ? new Promise((resolve) => addEventListener("${event}", () => resolve("fired"), { once: true })) - : Promise.resolve("was already ${event}"); - return true; - `); - await cdp("Network.emulateNetworkConditions", { - offline: on, - latency: 0, - downloadThroughput: -1, - uploadThroughput: -1, - }); - const state = await evaluate(` - const event = await Promise.race([ - window.__podnotesVerifyNetwork, - new Promise((resolve) => setTimeout(() => resolve("did not fire"), 2000)), - ]); - // The first saved feed, sent the way requestUrl sends PodNotes' feed requests. - const url = Object.values(app.plugins.plugins.podnotes.settings.savedFeeds)[0]?.url; - const ipc = window.electron.ipcRenderer; - const reply = "podnotes-verify-" + Math.random(); - const feed = url === undefined ? "no saved feed" : await new Promise((resolve) => { - ipc.once(reply, (_event, response) => resolve(response.error ? "failed" : response.status)); - ipc.send("request-url", reply, { url, method: "GET" }); - setTimeout(() => resolve("no answer"), 5000); - }); - return { onLine: navigator.onLine, ${event}: event, feed }; - `); - console.log(JSON.stringify(state)); - const feedAsExpected = - state.feed === "no saved feed" || (typeof state.feed === "number") !== on; - if (state.onLine === on || state[event] === "did not fire" || !feedAsExpected) { - throw new Error(`offline ${on ? "on" : "off"} did not take: ${JSON.stringify(state)}`); - } -} - -/** Adds or removes an item in Obsidian's status bar, as a plugin's addStatusBarItem does. @param {boolean} on */ -async function statusItem(on) { - await evaluate(` - document.querySelector(".${STATUS_ITEM}")?.remove(); - if (${on}) document.querySelector(".status-bar").createDiv({ cls: "status-bar-item ${STATUS_ITEM}", text: "9 properties" }); - return true; - `); -} - -/** @param {string} [text] */ -async function settle(text = "") { - const deadline = Date.now() + 20000; - let previous = ""; - let stable = 0; - for (;;) { - const state = await evaluate(` - const view = document.querySelector(".podcast-view"); - if (!view) return { why: "no .podcast-view" }; - const box = view.getBoundingClientRect(); - const loading = [...view.querySelectorAll("img")].filter((img) => { - const rect = img.getBoundingClientRect(); - const visible = rect.bottom > box.top && rect.top < box.bottom && rect.width > 0; - return visible && !img.complete; - }).length; - const moving = document.getAnimations().filter( - (animation) => animation.playState === "running" && view.contains(animation.effect?.target), - ).length; - const shown = view.innerText.includes(${JSON.stringify(text)}); - return { loading, moving, shown, markup: view.innerHTML.length }; - `); - const ready = state.loading === 0 && state.moving === 0 && state.shown; - const markup = String(state.markup); - stable = ready && markup === previous ? stable + 1 : 0; - previous = markup; - if (stable >= 3) return; - if (Date.now() > deadline) { - throw new Error(`The view did not settle within 20 s: ${JSON.stringify(state)}`); - } - await sleep(250); - } -} - -/** - * `statusBar` keeps Obsidian's status bar in the capture, which then fails unless the - * whole bar lies inside it. `notice` keeps Obsidian's notices, and fails unless one says it. - * @param {string} file @param {string} [text] @param {string} [themeName] @param {boolean} [statusBar] @param {string} [notice] - */ -async function shot(file, text, themeName, statusBar = false, notice = "") { - await cdp("Input.dispatchMouseEvent", { type: "mouseMoved", x: -10, y: -10 }); - await evaluate(` - document.activeElement?.blur?.(); - document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove()); - return true; - `); - await settle(text); - const target = await evaluate(` - document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove()); - const notices = [...document.querySelectorAll(".notice")].map((notice) => { - const said = notice.innerText.trim(); - if (!${JSON.stringify(notice)}) notice.remove(); - return said; - }); - // Obsidian's status bar floats over the view's bottom-right corner. - const bar = document.querySelector(".status-bar"); - if (!${statusBar}) bar?.style.setProperty("visibility", "hidden"); - await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); - const rect = document.body.classList.contains("is-phone") - ? { x: 0, y: 0, width: innerWidth, height: innerHeight } - : document.querySelector(".podcast-view").getBoundingClientRect().toJSON(); - return { notices, rect, bar: bar?.getBoundingClientRect().toJSON(), scale: devicePixelRatio, theme: ${CURRENT_THEME} }; - `); - if (notice && !target.notices.some((/** @type {string} */ said) => said.includes(notice))) { - throw new Error(`No notice says "${notice}": ${JSON.stringify(target.notices)}`); - } - // Whole pixels, so the capture is not resampled. - const [x, y, width, height] = ["x", "y", "width", "height"].map((key) => - Math.round(target.rect[key]), - ); - const bar = target.bar; - if ( - statusBar && - !( - bar?.width > 0 && - Math.round(bar.left) >= x && - Math.round(bar.right) <= x + width && - Math.round(bar.top) >= y && - Math.round(bar.bottom) <= y + height - ) - ) { - throw new Error(`Obsidian's status bar ${JSON.stringify(bar)} is not inside the view.`); - } - const { data } = await cdp("Page.captureScreenshot", { - format: "png", - clip: { x, y, width, height, scale: 1 }, - }); - const after = await evaluate( - `document.querySelector(".status-bar")?.style.removeProperty("visibility"); return ${CURRENT_THEME};`, - ); - // The file name promises a theme, and a window reload can still be applying the saved one. - if (themeName && (target.theme !== themeName || after !== themeName)) { - throw new Error( - `The theme turned ${target.theme === themeName ? after : target.theme} around the capture of ${path.relative(root, path.resolve(file))}, not ${themeName}. Run it again.`, - ); - } - const png = Buffer.from(data, "base64"); - const size = pngSize(png); - if (size.width !== width * target.scale || size.height !== height * target.scale) { - throw new Error( - `The capture is ${size.width} x ${size.height}, not the ${width} x ${height} view.`, - ); - } - fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); - fs.writeFileSync(file, png); - console.log( - JSON.stringify({ - file: path.relative(root, path.resolve(file)), - ...size, - notices: target.notices, - }), - ); -} - -/** @param {Buffer} png */ -function pngSize(png) { - return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; -} - -/** @param {{ file: string, caption: string }[][]} rows @param {string} out */ -function contactSheet(rows, out) { - const GAP = 16; - const CAPTION = 28; - const cells = []; - const layout = []; - let y = GAP; - for (const row of rows) { - let x = GAP; - let tallest = 0; - for (const cell of row) { - const size = pngSize(fs.readFileSync(cell.file)); - cells.push(cell); - layout.push(`${x}_${y}`); - x += size.width + GAP; - tallest = Math.max(tallest, size.height + CAPTION); - } - y += tallest + GAP; - } - const labelled = cells.map( - (cell, i) => - `[${i}:v]format=rgb24,pad=iw:ih+${CAPTION}:0:${CAPTION}:color=0x5c5c5c,` + - `drawtext=font=Sans:fontsize=15:fontcolor=white:x=2:y=7:text='${cell.caption}'[c${i}]`, - ); - const stacked = - cells.length === 1 - ? "[c0]null" - : `${cells.map((_, i) => `[c${i}]`).join("")}xstack=inputs=${cells.length}:layout=${layout.join("|")}:fill=0x5c5c5c`; - execFileSync( - "ffmpeg", - [ - "-v", - "error", - "-y", - ...cells.flatMap((cell) => ["-i", cell.file]), - "-filter_complex", - `${labelled.join(";")};${stacked},pad=iw+${GAP}:ih+${GAP}:0:0:color=0x5c5c5c[out]`, - "-map", - "[out]", - "-frames:v", - "1", - out, - ], - { stdio: ["ignore", "ignore", "inherit"] }, - ); -} /** @param {string} state @param {string} label @param {string[]} widths @param {string[]} themes */ async function set(state, label, widths, themes) { diff --git a/.claude/skills/verify/screens/instance.mjs b/.claude/skills/verify/screens/instance.mjs new file mode 100644 index 00000000..b8709244 --- /dev/null +++ b/.claude/skills/verify/screens/instance.mjs @@ -0,0 +1,455 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + cliSocketExists, + execObsidian, + loadRunnerConfig, + obsidianCommandArgs, + resolveInstanceOptions, + resolveProvisionOptions, +} from "obsidian-e2e/runner"; +import { pngSize } from "./sheet.mjs"; + +/** @typedef {import("./scenes.mjs").Page} Page */ + +export const root = fileURLToPath(new URL("../../../..", import.meta.url)); +export const config = await loadRunnerConfig(root); +export const provision = resolveProvisionOptions({}, config, root); +const instance = resolveInstanceOptions(provision, {}, config, root); +export const UI = fileURLToPath(new URL("../ui.mjs", import.meta.url)); +export const FIXTURES = fileURLToPath(new URL("../fixtures.mjs", import.meta.url)); +const VIEW_TYPE = "podcast_player_view"; +const CALL_TIMEOUT_MS = 15000; +/** @type {Record} */ +export const WIDTHS = { sidebar: [320, 720], pane: [820, 680], phone: [390, 844] }; +/** @type {Record} */ +export const THEMES = { light: "moonstone", dark: "obsidian" }; +const CURRENT_THEME = `(document.body.classList.contains("theme-dark") ? "dark" : "light")`; + +/** @param {string[]} args */ +async function obsidian(args) { + if (!(await cliSocketExists(instance))) { + throw new Error( + "This worktree's Obsidian instance is not running: npm run start:e2e-obsidian", + ); + } + const started = Date.now(); + const { stdout } = await execObsidian( + instance, + obsidianCommandArgs(instance.vaultName, args), + {}, + { timeout: CALL_TIMEOUT_MS }, + ); + // The CLI exits 0 with no output when the timeout kills it, so only the clock tells. + if (Date.now() - started >= CALL_TIMEOUT_MS) { + throw new Error(`obsidian ${args[0]} did not answer within ${CALL_TIMEOUT_MS} ms.`); + } + return stdout.trim(); +} + +/** @param {string} body */ +export async function evaluate(body) { + const output = await obsidian([ + "eval", + `code=(async () => JSON.stringify((await (async () => {${body}})()) ?? null))()`, + ]); + if (!output.startsWith("=> ")) throw new Error(output || "eval printed nothing"); + return JSON.parse(output.slice(3)); +} + +/** @param {string} method @param {object} [params] */ +async function cdp(method, params = {}) { + const output = await obsidian([ + "dev:cdp", + `method=${method}`, + `params=${JSON.stringify(params)}`, + ]); + if (!output.startsWith("{")) throw new Error(`${method}: ${output}`); + return JSON.parse(output); +} + +/** @param {number} ms */ +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Errors count as false, because the window reloads under some of these waits. + * @param {string} body @param {string} what @param {number} [timeoutMs] + */ +export async function waitFor(body, what, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + let last = ""; + for (;;) { + try { + if ((await evaluate(body)) === true) return; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + if (Date.now() > deadline) throw new Error(`${what} after ${timeoutMs} ms. ${last}`); + await sleep(250); + } +} + +/** @param {boolean} on */ +async function setMobile(on) { + const { mobile, origin } = await evaluate( + "return { mobile: app.isMobile, origin: performance.timeOrigin };", + ); + if (mobile === on) return; + await evaluate(`setTimeout(() => app.emulateMobile(${on}), 50); return true;`); + await waitFor( + `return performance.timeOrigin !== ${origin} && app.isMobile === ${on} && + app.workspace.layoutReady && Boolean(app.plugins.plugins.podnotes);`, + `Obsidian did not reload ${on ? "into" : "out of"} mobile emulation`, + ); +} + +/** @param {string} name */ +export async function place(name) { + if (!(name in WIDTHS)) throw new Error("Usage: place "); + const [width, height] = WIDTHS[name]; + const sidebar = name === "sidebar"; + const phone = name === "phone"; + if (phone) { + await cdp("Emulation.setDeviceMetricsOverride", { + width, + height, + deviceScaleFactor: 1, + mobile: true, + screenWidth: width, + screenHeight: height, + }); + } else { + await cdp("Emulation.clearDeviceMetricsOverride"); + } + await setMobile(phone); + await evaluate(` + const { workspace } = app; + const inSidebar = (leaf) => leaf.getRoot() === workspace.rightSplit; + for (const leaf of workspace.getLeavesOfType("${VIEW_TYPE}")) { + if (inSidebar(leaf) !== ${sidebar}) leaf.detach(); + } + if (workspace.getLeavesOfType("${VIEW_TYPE}").length === 0) { + // Not getLeaf(false): it can return the leaf just detached when that leaf had focus. + let leaf = ${sidebar} ? workspace.getRightLeaf(false) : undefined; + workspace.iterateRootLeaves((candidate) => { + if (!${sidebar} && (!leaf || candidate.view.getViewType() === "empty")) leaf = candidate; + }); + await leaf.setViewState({ type: "${VIEW_TYPE}" }); + } + // Show player's path: it reveals the leaf, and on mobile mounts the view. + await app.plugins.plugins.podnotes.activateView(); + workspace.leftSplit.collapse(); + if (!${sidebar}) workspace.rightSplit.collapse(); + return true; + `); + const measure = async () => { + await waitFor( + `return document.querySelector(".podcast-view")?.getBoundingClientRect().width > 0;`, + "The PodNotes view did not appear", + 10000, + ); + return evaluate(` + await new Promise((resolve) => setTimeout(resolve, 300)); + const rect = document.querySelector(".podcast-view").getBoundingClientRect(); + return { width: rect.width, height: ${phone} ? innerHeight : rect.height, + phone: document.body.classList.contains("is-phone") }; + `); + }; + // Obsidian's chrome can leave the view a fraction of a pixel taller; a capture rounds it. + const fits = (/** @type {{ width: number, height: number }} */ view) => + view.width === width && Math.round(view.height) === height; + let measured = await measure(); + for (let attempt = 0; !phone && attempt < 3; attempt++) { + if (fits(measured)) break; + await evaluate(` + const win = require("electron").remote.getCurrentWindow(); + if (win.isFullScreen()) win.setFullScreen(false); + if (win.isMaximized()) win.unmaximize(); + const [contentWidth, contentHeight] = win.getContentSize(); + const dw = ${width} - ${measured.width}; + const dh = ${height} - ${measured.height}; + if (${sidebar}) app.workspace.rightSplit.setSize(app.workspace.rightSplit.size + dw); + win.setContentSize( + ${sidebar} ? Math.max(contentWidth, 1024) : Math.round(contentWidth + dw), + Math.round(contentHeight + dh), + ); + return true; + `); + measured = await measure(); + } + const problems = [ + measured.width !== width && `the view is ${measured.width} px wide, not ${width}`, + Math.round(measured.height) !== height && + `${phone ? "the screen" : "the view"} is ${measured.height} px tall, not ${height}`, + phone !== measured.phone && `body.is-phone is ${measured.phone}`, + ].filter(Boolean); + if (problems.length > 0) throw new Error(`place ${name}: ${problems.join("; ")}.`); + console.log(JSON.stringify({ place: name, width: measured.width, height: measured.height })); +} + +/** @param {string} name */ +export async function theme(name) { + if (!(name in THEMES)) throw new Error("Usage: theme "); + await evaluate(`app.changeTheme("${THEMES[name]}"); return true;`); + await waitFor( + `return document.body.classList.contains("theme-${name}");`, + `The ${name} theme did not apply`, + 5000, + ); +} + +/** @param {Page} page */ +export async function go(page) { + await evaluate(` + const plugin = app.plugins.plugins.podnotes; + plugin.navigation.replace(${JSON.stringify(page)}); + await plugin.activateView(); + return true; + `); +} + +const PLAYER_STATE = `let state; + app.plugins.plugins.podnotes.player.state.subscribe((value) => (state = value))();`; + +/** + * Plays the episode the scene restored and pauses it at `at` seconds once its media has + * loaded, saved there so lists show the same progress every time. "error" waits for the + * load to fail instead. + * @param {number | "error"} at + */ +export async function playback(at) { + await evaluate( + `${PLAYER_STATE} app.plugins.plugins.podnotes.player.play(state.episode); return true;`, + ); + if (at === "error") { + await waitFor( + `${PLAYER_STATE} return state.status === "error";`, + "The episode's media did not fail to load", + ); + return; + } + await waitFor( + `${PLAYER_STATE} return state.status === "playing";`, + "The episode's media did not load and play", + ); + await evaluate(` + const { player } = app.plugins.plugins.podnotes; + player.pause(); + player.seek(${at}); + return true; + `); + await waitFor( + `const media = document.querySelector(".podnotes-media"); + return media.paused && !media.seeking && media.readyState >= 2 && media.currentTime === ${at};`, + `The episode did not pause at ${at} s`, + ); + await evaluate("app.plugins.plugins.podnotes.player.save(); return true;"); +} + +/** Captures once without the UI, so the episode note exists, as after an earlier capture. */ +export async function episodeNote() { + const capture = await evaluate(` + // The last note open in an editor would take the capture. + app.workspace.detachLeavesOfType("markdown"); + return await app.plugins.plugins.podnotes.capture.timestamp(); + `); + if (capture?.destination !== "note") { + throw new Error(`The capture went to ${JSON.stringify(capture)}, not the episode note.`); + } +} + +/** + * Presses the full player's button that reads `text`, with real input. A panel switch or + * tab that is already on stays on. Fails at once without such a button. + * @param {string} text + */ +export async function press(text) { + const button = `[...document.querySelectorAll("section.pn-player button")].find((button) => + button.getBoundingClientRect().width > 0 && button.innerText.trim() === ${JSON.stringify(text)})`; + // "true" or "false" for a panel switch or tab. + const state = `((button) => !button ? "missing" : + button.getAttribute("aria-pressed") ?? button.getAttribute("aria-selected") ?? "action")(${button})`; + const kind = await evaluate(`return ${state};`); + if (kind === "missing") { + throw new Error(`No button in the full player (section.pn-player) reads "${text}".`); + } + if (kind === "true") return; + execFileSync( + process.execPath, + [UI, "click", "section.pn-player button", "--text", text, "--exact"], + { stdio: "inherit" }, + ); + if (kind === "false") { + await waitFor(`return ${state} === "true";`, `"${text}" did not turn on`, 5000); + return; + } + // Timestamp reads "Added" for 1.2 s after a capture. + await sleep(1500); + await waitFor(`return Boolean(${button});`, `"${text}" did not come back`, 5000); +} + +/** @param {boolean} on */ +export async function offline(on) { + execFileSync(process.execPath, [FIXTURES, on ? "stop" : "library"]); + const event = on ? "offline" : "online"; + await evaluate(` + window.__podnotesVerifyNetwork = navigator.onLine === ${on} + ? new Promise((resolve) => addEventListener("${event}", () => resolve("fired"), { once: true })) + : Promise.resolve("was already ${event}"); + return true; + `); + await cdp("Network.emulateNetworkConditions", { + offline: on, + latency: 0, + downloadThroughput: -1, + uploadThroughput: -1, + }); + const state = await evaluate(` + const event = await Promise.race([ + window.__podnotesVerifyNetwork, + new Promise((resolve) => setTimeout(() => resolve("did not fire"), 2000)), + ]); + // The first saved feed, sent the way requestUrl sends PodNotes' feed requests. + const url = Object.values(app.plugins.plugins.podnotes.settings.savedFeeds)[0]?.url; + const ipc = window.electron.ipcRenderer; + const reply = "podnotes-verify-" + Math.random(); + const feed = url === undefined ? "no saved feed" : await new Promise((resolve) => { + ipc.once(reply, (_event, response) => resolve(response.error ? "failed" : response.status)); + ipc.send("request-url", reply, { url, method: "GET" }); + setTimeout(() => resolve("no answer"), 5000); + }); + return { onLine: navigator.onLine, ${event}: event, feed }; + `); + console.log(JSON.stringify(state)); + const feedAsExpected = + state.feed === "no saved feed" || (typeof state.feed === "number") !== on; + if (state.onLine === on || state[event] === "did not fire" || !feedAsExpected) { + throw new Error(`offline ${on ? "on" : "off"} did not take: ${JSON.stringify(state)}`); + } +} + +const STATUS_ITEM = "podnotes-verify-status-item"; + +/** Adds or removes an item in Obsidian's status bar, as a plugin's addStatusBarItem does. @param {boolean} on */ +export async function statusItem(on) { + await evaluate(` + document.querySelector(".${STATUS_ITEM}")?.remove(); + if (${on}) document.querySelector(".status-bar").createDiv({ cls: "status-bar-item ${STATUS_ITEM}", text: "9 properties" }); + return true; + `); +} + +/** @param {string} [text] */ +async function settle(text = "") { + const deadline = Date.now() + 20000; + let previous = ""; + let stable = 0; + for (;;) { + const state = await evaluate(` + const view = document.querySelector(".podcast-view"); + if (!view) return { why: "no .podcast-view" }; + const box = view.getBoundingClientRect(); + const loading = [...view.querySelectorAll("img")].filter((img) => { + const rect = img.getBoundingClientRect(); + const visible = rect.bottom > box.top && rect.top < box.bottom && rect.width > 0; + return visible && !img.complete; + }).length; + const moving = document.getAnimations().filter( + (animation) => animation.playState === "running" && view.contains(animation.effect?.target), + ).length; + const shown = view.innerText.includes(${JSON.stringify(text)}); + return { loading, moving, shown, markup: view.innerHTML.length }; + `); + const ready = state.loading === 0 && state.moving === 0 && state.shown; + const markup = String(state.markup); + stable = ready && markup === previous ? stable + 1 : 0; + previous = markup; + if (stable >= 3) return; + if (Date.now() > deadline) { + throw new Error(`The view did not settle within 20 s: ${JSON.stringify(state)}`); + } + await sleep(250); + } +} + +/** + * `statusBar` keeps Obsidian's status bar in the capture, which then fails unless the + * whole bar lies inside it. `notice` keeps Obsidian's notices, and fails unless one says it. + * @param {string} file @param {string} [text] @param {string} [themeName] @param {boolean} [statusBar] @param {string} [notice] + */ +export async function shot(file, text, themeName, statusBar = false, notice = "") { + await cdp("Input.dispatchMouseEvent", { type: "mouseMoved", x: -10, y: -10 }); + await evaluate(` + document.activeElement?.blur?.(); + document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove()); + return true; + `); + await settle(text); + const target = await evaluate(` + document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove()); + const notices = [...document.querySelectorAll(".notice")].map((notice) => { + const said = notice.innerText.trim(); + if (!${JSON.stringify(notice)}) notice.remove(); + return said; + }); + // Obsidian's status bar floats over the view's bottom-right corner. + const bar = document.querySelector(".status-bar"); + if (!${statusBar}) bar?.style.setProperty("visibility", "hidden"); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const rect = document.body.classList.contains("is-phone") + ? { x: 0, y: 0, width: innerWidth, height: innerHeight } + : document.querySelector(".podcast-view").getBoundingClientRect().toJSON(); + return { notices, rect, bar: bar?.getBoundingClientRect().toJSON(), scale: devicePixelRatio, theme: ${CURRENT_THEME} }; + `); + if (notice && !target.notices.some((/** @type {string} */ said) => said.includes(notice))) { + throw new Error(`No notice says "${notice}": ${JSON.stringify(target.notices)}`); + } + // Whole pixels, so the capture is not resampled. + const [x, y, width, height] = ["x", "y", "width", "height"].map((key) => + Math.round(target.rect[key]), + ); + const bar = target.bar; + if ( + statusBar && + !( + bar?.width > 0 && + Math.round(bar.left) >= x && + Math.round(bar.right) <= x + width && + Math.round(bar.top) >= y && + Math.round(bar.bottom) <= y + height + ) + ) { + throw new Error(`Obsidian's status bar ${JSON.stringify(bar)} is not inside the view.`); + } + const { data } = await cdp("Page.captureScreenshot", { + format: "png", + clip: { x, y, width, height, scale: 1 }, + }); + const after = await evaluate( + `document.querySelector(".status-bar")?.style.removeProperty("visibility"); return ${CURRENT_THEME};`, + ); + // The file name promises a theme, and a window reload can still be applying the saved one. + if (themeName && (target.theme !== themeName || after !== themeName)) { + throw new Error( + `The theme turned ${target.theme === themeName ? after : target.theme} around the capture of ${path.relative(root, path.resolve(file))}, not ${themeName}. Run it again.`, + ); + } + const png = Buffer.from(data, "base64"); + const size = pngSize(png); + if (size.width !== width * target.scale || size.height !== height * target.scale) { + throw new Error( + `The capture is ${size.width} x ${size.height}, not the ${width} x ${height} view.`, + ); + } + fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }); + fs.writeFileSync(file, png); + console.log( + JSON.stringify({ + file: path.relative(root, path.resolve(file)), + ...size, + notices: target.notices, + }), + ); +} diff --git a/.claude/skills/verify/screens/scenes.mjs b/.claude/skills/verify/screens/scenes.mjs new file mode 100644 index 00000000..5149b955 --- /dev/null +++ b/.claude/skills/verify/screens/scenes.mjs @@ -0,0 +1,464 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { FIXTURE_HOST } from "../../../../scripts/e2e-instance.mjs"; +import { config, evaluate, FIXTURES, provision, waitFor } from "./instance.mjs"; + +const DATA_PATH = path.join(provision.vaultPath, ".obsidian/plugins/podnotes/data.json"); + +/** + * @typedef {{ title: string, podcastName: string, duration: number, episodeDate: string }} Episode + * @typedef {{ feed: { title: string }, missing: boolean, episodes: Episode[] }} LibraryFeed + * @typedef {{ feeds: LibraryFeed[], add: { title: string, url: string } }} Library + * @typedef {{ kind: string, key?: string, name?: string }} Page + * @typedef {Record} Data + * @typedef {(data: Data, library: Library) => void | Promise} Ingredient + */ + +/** + * @typedef {object} Recipe + * @property {string} scene + * @property {Ingredient} [extra] Applied after the scene's ingredients. + * @property {(library: Library) => Page} page + * @property {(library: Library) => string} [text] Text the view shows once it is ready. + * @property {boolean} [paste] + * @property {boolean} [offline] + * @property {boolean} [statusBar] + * @property {boolean} [open] Opens the full player. + * @property {number | "error"} [play] The second the current episode pauses at, or "error" + * when its media fails to load. + * @property {boolean} [note] The episode note already holds a capture. + * @property {string} [press] The full player's button to press, by its text. + * @property {string} [notice] Text an Obsidian notice shows, which the shot keeps. + */ + +/** The episode the listener is in the middle of, as in the prototype. @type {[string, string]} */ +const NOW_PLAYING = ["Field Notes", "#213"]; +const NOW_PLAYING_AT = 2294; +/** @type {[string, string]} */ +const NO_CHAPTERS = ["Signal & Noise", "Ep. 87"]; +/** @type {[string, string]} */ +const VIDEO = ["Signal & Noise", "Ep. 86"]; +/** @type {[string, string]} */ +const BROKEN_MEDIA = ["Signal & Noise", "Ep. 85"]; +/** @type {Recipe} */ +const FULL_PLAYER = { + scene: "lists", + page: () => ({ kind: "library" }), + open: true, + play: NOW_PLAYING_AT, +}; + +/** @type {Record} */ +export const STATES = { + "first-run": { scene: "empty", page: () => ({ kind: "library" }) }, + "add-podcast": { + scene: "library", + page: () => ({ kind: "add" }), + text: (library) => library.add.title, + paste: true, + }, + library: { scene: "library", page: () => ({ kind: "library" }) }, + episodes: { + scene: "episodes", + page: (library) => ({ kind: "feed", key: longestTitle(library).podcastName }), + text: (library) => longestTitle(library).title.slice(0, 40), + }, + "error-feed": { + scene: "library", + page: (library) => ({ kind: "feed", key: missingFeed(library).feed.title }), + }, + queue: { scene: "lists", page: () => ({ kind: "queue" }), text: () => QUEUE[0][1] }, + playlists: { + scene: "lists", + page: () => ({ kind: "playlists" }), + text: () => PLAYLISTS[PLAYLISTS.length - 1].name, + }, + playlist: { + scene: "lists", + page: () => ({ kind: "playlist", name: PLAYLIST.name }), + text: () => PLAYLIST.episodes[0][1], + }, + latest: { scene: "lists", page: () => ({ kind: "latest" }), text: newestTitle }, + played: { scene: "lists", page: () => ({ kind: "played" }), text: () => PLAYED[1][0][1] }, + "local-files": { + scene: "local", + page: () => ({ kind: "local" }), + text: () => path.parse(LOCAL_FILES[0][0]).name, + }, + offline: { + scene: "offline", + page: () => ({ kind: "latest" }), + text: newestTitle, + offline: true, + }, + "status-bar": { + scene: "lists", + page: () => ({ kind: "queue" }), + text: () => QUEUE[0][1], + statusBar: true, + }, + // The chapters load after the player opens. This one is #213's at NOW_PLAYING_AT. + "now-playing": { ...FULL_PLAYER, text: () => "The method of loci in practice" }, + "mini-player": { + scene: "lists", + page: (library) => ({ kind: "feed", key: episode(library, NOW_PLAYING).podcastName }), + play: NOW_PLAYING_AT, + text: (library) => episode(library, NOW_PLAYING).title.slice(0, 40), + }, + // The capture's notice, which has to outlast the shot. + capture: { ...FULL_PLAYER, note: true, press: "Timestamp", notice: "Timestamp added to" }, + // #213's last chapter. + chapters: { ...FULL_PLAYER, press: "Chapters", text: () => "Listener question" }, + "up-next": { ...FULL_PLAYER, press: "Up next", text: () => QUEUE[0][1] }, + "up-next-empty": { + ...FULL_PLAYER, + extra: (data) => { + data.queue.episodes = []; + }, + press: "Up next", + }, + "no-chapters": { + ...FULL_PLAYER, + extra: (data, library) => { + data.currentEpisode = episode(library, NO_CHAPTERS); + }, + play: 1140, + text: () => NO_CHAPTERS[1], + }, + video: { + ...FULL_PLAYER, + extra: (data, library) => { + data.currentEpisode = episode(library, VIDEO); + data.queue.episodes = latest(library).slice(0, 14); + }, + play: 4, + text: () => VIDEO[1], + }, + "local-player": { + ...FULL_PLAYER, + scene: "local", + extra: (data) => { + data.currentEpisode = data.downloadedEpisodes["local file"].find( + (/** @type {{ filePath: string }} */ file) => file.filePath === LOCAL_FILES[0][0], + ); + }, + play: 1283, + text: () => path.parse(LOCAL_FILES[0][0]).name, + }, + "media-error": { + ...FULL_PLAYER, + extra: (data, library) => { + data.currentEpisode = episode(library, BROKEN_MEDIA); + }, + play: "error", + text: () => BROKEN_MEDIA[1], + }, +}; + +// What a listener of the fixture library did with it, as in the design prototype. +// Episodes are named by the start of their podcast's title and of their own. +/** @type {[string, string][]} */ +const QUEUE = [ + ["Slow Thinking", "The case for boredom"], + ["Lunch Break History", "Who invented the index?"], + ["Signal & Noise", "Ep. 88"], + ["Night Owl Science", "Sleep, memory"], +]; +const PLAYLIST = { + name: "Commute", + /** @type {[string, string][]} */ + episodes: [ + ["Lunch Break History", "Who invented the index?"], + ["Making Sense of Markets", "Rates"], + ["Signal & Noise", "Ep. 87"], + ["Slow Thinking", "Walking"], + ["Small Rooms", "A desk"], + ["Lunch Break History", "Medieval"], + ], +}; +/** @type {[[string, string], number | "finished"][]} */ +const PLAYED = [ + [NOW_PLAYING, NOW_PLAYING_AT], + [["Field Notes", "#212"], "finished"], + [["Field Notes", "#210"], "finished"], + [["Field Notes", "#209"], 760], + [["Field Notes", "#208"], "finished"], + [["Field Notes", "#206"], "finished"], + [["Signal & Noise", "Ep. 88"], 760], + [["Night Owl Science", "Sleep, memory"], 600], +]; +/** @type {[string, string][]} */ +const FAVORITES = [ + ["Field Notes", "#212"], + ["The Archive Hour", "Luhmann"], + ["Slow Thinking", "Attention"], + ["Lunch Break History", "The library of Alexandria"], + ["Field Notes", "#208"], +]; +const PLAYLISTS = [ + { + name: "Memory research", + /** @type {[string, string][]} */ + episodes: [ + ["Field Notes", "#213"], + ["Night Owl Science", "Sleep, memory"], + ["The Archive Hour", "Commonplace books"], + ["Field Notes", "#209"], + ], + }, + { + name: "Long walks, slow ideas", + /** @type {[string, string][]} */ + episodes: [ + ["Slow Thinking", "Walking"], + ["Field Notes", "#211"], + ["The Archive Hour", "Luhmann"], + ], + }, +]; +/** @type {[string, string][]} */ +const DOWNLOADS = [ + ["Field Notes", "#213"], + ["Field Notes", "#211"], + ["Slow Thinking", "The case for boredom"], + ["Night Owl Science", "Sleep, memory"], +]; +/** Vault media: path, length in seconds, and the played position. @type {[string, number, (number | "finished")?][]} */ +const LOCAL_FILES = [ + ["Recordings/Family/Interview with grandma.m4a", 3850], + ["University/Psychology/Lecture 07 - Cognitive load theory.mp4", 2913, 1032], + ["Inbox/Voice memo 2026-09-14.webm", 221], + ["Recordings/Book club/Book club - The Overstory, chapters 1-4.mp3", 5460, "finished"], +]; +/** Silent media small enough for a vault, by container. @type {Record} */ +const ENCODERS = { + ".m4a": ["-c:a", "aac", "-b:a", "8k"], + ".mp4": ["-c:v", "libx264", "-crf", "51", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "8k"], + ".webm": ["-c:a", "libopus", "-b:a", "6k"], + ".mp3": ["-c:a", "libmp3lame", "-b:a", "8k"], +}; + +/** Each scene's ingredients, applied in order to the default data.json. @type {Record} */ +const SCENES = { + empty: [], + library: [libraryData], + episodes: [libraryData], + lists: [libraryData, listening], + local: [libraryData, listening, localFiles], + offline: [libraryData, listening, downloads], +}; + +/** @returns {Promise} */ +export async function library() { + const url = execFileSync(process.execPath, [FIXTURES, "library"], { encoding: "utf8" }).trim(); + const response = await fetch(url.replace(`//${FIXTURE_HOST}:`, "//127.0.0.1:")); + if (!response.ok) { + throw new Error( + `${url} answered ${response.status}. Restart the fixture server: node .claude/skills/verify/fixtures.mjs stop`, + ); + } + return response.json(); +} + +/** @param {Library} lib */ +function longestTitle(lib) { + return lib.feeds + .flatMap((feed) => feed.episodes) + .reduce((longest, episode) => + episode.title.length > longest.title.length ? episode : longest, + ); +} + +/** @param {Library} lib */ +function missingFeed(lib) { + const feed = lib.feeds.find((candidate) => candidate.missing); + if (!feed) throw new Error("The fixture library has no feed that answers 404."); + return feed; +} + +/** What Latest episodes lists, newest first: Night Owl Science's feed answers 404. @param {Library} lib */ +function latest(lib) { + return lib.feeds + .filter((feed) => !feed.missing) + .flatMap((feed) => feed.episodes) + .sort((a, b) => b.episodeDate.localeCompare(a.episodeDate)); +} + +/** @param {Library} lib */ +function newestTitle(lib) { + return latest(lib)[0].title; +} + +/** @param {Library} lib @param {[string, string]} reference */ +function episode(lib, [podcast, title]) { + const found = lib.feeds + .find((feed) => feed.feed.title.startsWith(podcast)) + ?.episodes.find((candidate) => candidate.title.startsWith(title)); + if (!found) throw new Error(`The fixture library has no episode "${title}" in ${podcast}.`); + return found; +} + +/** @param {{ name: string, episodes: [string, string][] }} list @param {Library} lib */ +function playlist({ name, episodes }, lib) { + return { + icon: "list-music", + name, + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: episodes.map((reference) => episode(lib, reference)), + }; +} + +/** + * The playedEpisodes entry PodNotes keeps for an episode. + * @param {{ title: string, podcastName: string }} episode @param {number | "finished"} time @param {number} duration + * @returns {[string, { title: string, podcastName: string, time: number, duration: number, finished: boolean }]} + */ +function playedRecord({ title, podcastName }, time, duration) { + const finished = time === "finished"; + return [ + `${podcastName}::${title}`, + { title, podcastName, time: finished ? duration : time, duration, finished }, + ]; +} + +/** @type {Ingredient} */ +function libraryData(data, lib) { + data.savedFeeds = Object.fromEntries(lib.feeds.map(({ feed }) => [feed.title, feed])); + data.queue.episodes = QUEUE.map((reference) => episode(lib, reference)); + data.playlists = { [PLAYLIST.name]: playlist(PLAYLIST, lib) }; + data.playedEpisodes = Object.fromEntries( + PLAYED.map(([reference, time]) => { + const found = episode(lib, reference); + return playedRecord(found, time, found.duration); + }), + ); +} + +/** Restored at startup, so the mini player shows it paused. @type {Ingredient} */ +function listening(data, lib) { + data.favorites.episodes = FAVORITES.map((reference) => episode(lib, reference)); + for (const list of PLAYLISTS) data.playlists[list.name] = playlist(list, lib); + data.currentEpisode = episode(lib, NOW_PLAYING); + data.defaultPlaybackRate = 1.25; +} + +/** The entries src/playLocalFile.ts writes, for media written into the vault. @type {Ingredient} */ +async function localFiles(data) { + for (const [file, seconds] of LOCAL_FILES) { + const target = path.join(provision.vaultPath, file); + if (fs.existsSync(target)) continue; + const video = path.extname(file) === ".mp4"; + const scratch = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), "podnotes-media-")), + path.basename(file), + ); + execFileSync("ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "anullsrc=r=8000:cl=mono", + ...(video ? ["-f", "lavfi", "-i", "color=c=0x334155:s=64x36:r=1"] : []), + "-t", + String(seconds), + ...ENCODERS[path.extname(file)], + scratch, + ]); + // Copied in whole, so Obsidian never indexes a file ffmpeg is still writing. + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(scratch, target); + fs.rmSync(path.dirname(scratch), { recursive: true }); + } + const sizes = Object.fromEntries( + LOCAL_FILES.map(([file]) => [file, fs.statSync(path.join(provision.vaultPath, file)).size]), + ); + await waitFor( + `const sizes = ${JSON.stringify(sizes)}; + return Object.entries(sizes).every(([file, size]) => app.vault.getAbstractFileByPath(file)?.stat.size === size);`, + "Obsidian did not index the local media files", + 10000, + ); + /** @type {{ file: string, title: string, url: string, streamUrl: string, ctime: number }[]} */ + const files = await evaluate(` + return ${JSON.stringify(Object.keys(sizes))}.map((file) => { + const found = app.vault.getAbstractFileByPath(file); + return { file, title: found.basename, url: app.fileManager.generateMarkdownLink(found, ""), + streamUrl: app.vault.getResourcePath(found), ctime: found.stat.ctime }; + }); + `); + data.downloadedEpisodes["local file"] = files.map(({ file, title, url, streamUrl, ctime }) => ({ + title, + description: "", + content: "", + podcastName: "local file", + url, + streamUrl, + filePath: file, + episodeDate: new Date(ctime).toISOString(), + mediaType: path.extname(file) === ".mp4" ? "video" : "audio", + size: sizes[file], + })); + for (const [file, seconds, time] of LOCAL_FILES) { + if (time === undefined) continue; + const [key, record] = playedRecord( + { title: path.parse(file).name, podcastName: "local file" }, + time, + seconds, + ); + data.playedEpisodes[key] = record; + } +} + +/** Downloaded as PodNotes does, to its default download path; the fixture serves a WAV. @type {Ingredient} */ +function downloads(data, lib) { + for (const reference of DOWNLOADS) { + const found = episode(lib, reference); + const file = `PodNotes/${found.podcastName}/${found.title.replace(/[\\/:*?"<>|#^[\]]/g, "")}.wav`; + const target = path.join(provision.vaultPath, file); + if (!fs.existsSync(target)) execFileSync(process.execPath, [FIXTURES, "audio", file]); + (data.downloadedEpisodes[found.podcastName] ??= []).push({ + ...found, + filePath: file, + size: fs.statSync(target).size, + downloadedByPodNotes: true, + }); + } +} + +/** @param {string} scene @param {Ingredient} [extra] */ +export async function seed(scene, extra) { + const ingredients = SCENES[scene]; + if (!ingredients) throw new Error(`Usage: seed <${Object.keys(SCENES).join("|")}>`); + /** @type {Data} */ + const data = structuredClone(/** @type {Data} */ (config.defaultData)); + const all = extra ? [...ingredients, extra] : ingredients; + if (all.length > 0) { + const lib = await library(); + for (const add of all) await add(data, lib); + } + await evaluate(` + const plugin = app.plugins.plugins.podnotes; + await app.plugins.disablePlugin("podnotes"); + // Unloading saves without waiting, and that save merges into the file written next. + await plugin?.flushSaves(); + for (const key of Object.keys(localStorage)) { + if (key.includes("podnotes:feed-cache")) localStorage.removeItem(key); + } + // A note left by an earlier capture shows as Open note and as a note mark in lists. + for (const note of app.vault.getMarkdownFiles()) { + if (note.path.startsWith("PodNotes/")) await app.vault.delete(note); + } + return true; + `); + fs.writeFileSync(DATA_PATH, `${JSON.stringify(data, null, "\t")}\n`); + await evaluate(`await app.plugins.enablePlugin("podnotes"); return true;`); + await waitFor( + "return Boolean(app.plugins.plugins.podnotes?.navigation);", + "PodNotes did not load", + 10000, + ); +} diff --git a/.claude/skills/verify/screens/sheet.mjs b/.claude/skills/verify/screens/sheet.mjs new file mode 100644 index 00000000..f3c40c9f --- /dev/null +++ b/.claude/skills/verify/screens/sheet.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; + +/** @param {Buffer} png */ +export function pngSize(png) { + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +/** @param {{ file: string, caption: string }[][]} rows @param {string} out */ +export function contactSheet(rows, out) { + const GAP = 16; + const CAPTION = 28; + const cells = []; + const layout = []; + let y = GAP; + for (const row of rows) { + let x = GAP; + let tallest = 0; + for (const cell of row) { + const size = pngSize(fs.readFileSync(cell.file)); + cells.push(cell); + layout.push(`${x}_${y}`); + x += size.width + GAP; + tallest = Math.max(tallest, size.height + CAPTION); + } + y += tallest + GAP; + } + const labelled = cells.map( + (cell, i) => + `[${i}:v]format=rgb24,pad=iw:ih+${CAPTION}:0:${CAPTION}:color=0x5c5c5c,` + + `drawtext=font=Sans:fontsize=15:fontcolor=white:x=2:y=7:text='${cell.caption}'[c${i}]`, + ); + const stacked = + cells.length === 1 + ? "[c0]null" + : `${cells.map((_, i) => `[c${i}]`).join("")}xstack=inputs=${cells.length}:layout=${layout.join("|")}:fill=0x5c5c5c`; + execFileSync( + "ffmpeg", + [ + "-v", + "error", + "-y", + ...cells.flatMap((cell) => ["-i", cell.file]), + "-filter_complex", + `${labelled.join(";")};${stacked},pad=iw+${GAP}:ih+${GAP}:0:0:color=0x5c5c5c[out]`, + "-map", + "[out]", + "-frames:v", + "1", + out, + ], + { stdio: ["ignore", "ignore", "inherit"] }, + ); +} diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 94866121..375d86df 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -710,6 +710,47 @@ describe("switching episodes (#353)", () => { }); }); +describe("playNext", () => { + test("plays the next other queued episode and queues the one it leaves last", () => { + const { player, media, state } = setup(); + player.play(A); + metadata(media()); + reach(media(), 42); + queue.add(A); + queue.add(B); + queue.add(C); + + player.playNext(); + + expect([state().episode?.title, queued(), progress(A)?.time]).toEqual([ + "B", + ["C", "A"], + 42, + ]); + }); + + test("with auto-queue off, plays the queue in order and queues nothing", () => { + const { player, state } = setup({ autoQueue: false }); + player.play(A); + queue.add(B); + queue.add(C); + + player.playNext(); + + expect([state().episode?.title, queued()]).toEqual(["B", ["C"]]); + }); + + test("does nothing when the queue holds only the current episode", () => { + const { player, state } = setup(); + player.play(A); + queue.add(A); + + player.playNext(); + + expect([state().episode?.title, queued()]).toEqual(["A", ["A"]]); + }); +}); + describe("a new load shows its own position and length until its metadata loads (#94)", () => { test("after a switch away from a playing episode", () => { saved(B, 300, 1800); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index b5c106b8..7f354962 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -216,6 +216,22 @@ export class PlayerController { return true; } + /** + * Plays the queue's next episode other than the current one. The episode it leaves + * goes last rather than first, so pressing again moves on through the queue. + */ + playNext(): void { + const outgoing = this.snapshot.episode; + const key = getEpisodeKey(outgoing); + const next = get(queue).episodes.find((episode) => getEpisodeKey(episode) !== key); + if (!next || !this.play(next) || !outgoing) return; + const [first] = get(queue).episodes; + if (first && getEpisodeKey(first) === key) { + queue.remove(first); + queue.add(first); + } + } + /** Continues the current episode without selecting it. */ resume(): void { const { status, episode } = this.snapshot; diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index e9035245..6ff2cba5 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -1,5 +1,4 @@ import { get, type Unsubscriber } from "svelte/store"; -import { queue } from "src/store"; import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; import type { NextTrackAction, PreviousTrackAction } from "src/types/Headset"; @@ -69,7 +68,7 @@ function positionOf({ currentTime, duration, playbackRate }: PlayerState) { } export function bindMediaSession( - player: Pick, + player: Pick, host: MediaSessionHost, ): Unsubscriber { if (!host.mediaSession) return () => {}; @@ -98,26 +97,13 @@ export function bindMediaSession( if (start !== undefined) player.seek(start); } - function playNextInQueue(): void { - const outgoing = get(player.state).episode; - const next = get(queue).episodes.find( - (episode) => getEpisodeKey(episode) !== getEpisodeKey(outgoing), - ); - if (!next || !player.play(next) || !outgoing) return; - const [first] = get(queue).episodes; - if (first && getEpisodeKey(first) === getEpisodeKey(outgoing)) { - queue.remove(first); - queue.add(first); - } - } - const run: Record void> = { captureTimestamp: () => host.captureTimestamp(), skipBackward: () => host.skipBackward(), skipForward: () => host.skipForward(), previousChapter: () => void seekChapter(-1), nextChapter: () => void seekChapter(1), - nextInQueue: playNextInQueue, + nextInQueue: () => player.playNext(), }; const actions: Array<[MediaSessionAction, MediaSessionActionHandler]> = [ diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 5ac8820c..057f3ffe 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -90,12 +90,7 @@ export class FeedRepository implements Readable> private readonly state = writable>(new Map()); private hydrated = false; - constructor( - private readonly deps: FeedRepositoryDeps & { - /** Ignored: feeds are always cached. Tests of the view still pass it. */ - cache?: unknown; - }, - ) { + constructor(private readonly deps: FeedRepositoryDeps) { // Earlier versions' caches can hold private feed URLs, so they go as soon as // PodNotes loads, not when a feed first loads (on mobile the view may stay closed). try { diff --git a/src/ui/PodcastView/AddPodcastPage.svelte b/src/ui/PodcastView/AddPodcastPage.svelte index ad485d86..ccd26f6a 100644 --- a/src/ui/PodcastView/AddPodcastPage.svelte +++ b/src/ui/PodcastView/AddPodcastPage.svelte @@ -67,6 +67,7 @@ value={$search.query} placeholder="Search or paste a link" autofocus + large oninput={(value) => { search.set(value); if (value.trim()) navigation.go({ kind: "add" }); @@ -87,24 +88,200 @@ {$search.results.length} {$search.results.length === 1 ? "show" : "shows"} - {#each $search.results as result (result.feed.url)} - {@const added = actions.savedKey($savedFeeds, result.feed.url) !== undefined} - - {/each} + + + {/each} + {/if} + + diff --git a/src/ui/PodcastView/AddPodcastPage.test.ts b/src/ui/PodcastView/AddPodcastPage.test.ts index 0ebc9cd0..39ee8578 100644 --- a/src/ui/PodcastView/AddPodcastPage.test.ts +++ b/src/ui/PodcastView/AddPodcastPage.test.ts @@ -55,7 +55,6 @@ function setUp(secrets = new obsidian.SecretStorage()) { load: loadFeed, resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const app = new obsidian.App(); diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 8c7859b7..75c54ac2 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -2,6 +2,7 @@ import type { Chapter } from "src/types/Chapter"; import { formatDuration } from "src/utility/formatDuration"; import { chapterAt } from "./chapters"; + import Panel from "./Panel.svelte"; let { chapters, @@ -13,7 +14,7 @@ onseek(time: number): void; } = $props(); - let list: HTMLOListElement | undefined = $state(); + let list: HTMLElement | undefined = $state(); const current = $derived(chapterAt(chapters, position)); // Scrolls only the panel: scrollIntoView would also scroll the view and an editor around it. @@ -25,32 +26,25 @@ }); -
-
    - {#each chapters as chapter, index (index)} -
  1. - -
  2. - {/each} -
-
+ + {#each chapters as chapter, index (index)} +
  • + +
  • + {/each} +
    diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte new file mode 100644 index 00000000..d077407a --- /dev/null +++ b/src/ui/PodcastView/Deck.svelte @@ -0,0 +1,484 @@ + + +
    +
    +

    {episode.title}

    + {#if sourcePage} + {@const page = sourcePage} + + {:else} + {source} + {/if} +
    +
    + player.seek(time)} + /> +
    + {formatDuration(elapsed)} + {#if current >= 0} + + {:else} + + {/if} + -{formatDuration(Math.max(0, duration - elapsed))} +
    +
    +
    + +
    + + + +
    + + {#if volumeOpen} +
    + player.setVolume(Number(event.currentTarget.value))} + /> + {Math.round($playerState.volume * 100)}% +
    + {/if} +
    +
    + + +
    + {@render children?.()} +
    + + diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index d6ac9bd7..956e85fc 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -12,6 +12,8 @@ cover = false, date = !cover, resolved = true, + queue = false, + dragging = false, onplay, onmenu, leading, @@ -23,6 +25,10 @@ date?: boolean; /** False for a Played record that matched no episode. */ resolved?: boolean; + /** A queue row: centered, flush left, without a progress bar. */ + queue?: boolean; + /** Lifted while its grip drags it. */ + dragging?: boolean; onplay(): void; onmenu(at: MenuAnchor): void; /** Before the row's button, such as the queue's grip. */ @@ -60,6 +66,8 @@ class:is-played={row.played} class:is-current={row.current} class:is-unavailable={row.unavailable} + class:is-queue={queue} + class:is-dragging={dragging} role="listitem" > {@render leading?.()} @@ -72,10 +80,8 @@ onclick={onplay} oncontextmenu={menu} > - {#if row.tile} - - {:else if cover} - + {#if cover} + {/if} {episode.title} @@ -119,3 +125,247 @@ > {/if} + + diff --git a/src/ui/PodcastView/EpisodesPage.svelte b/src/ui/PodcastView/EpisodesPage.svelte index 76373262..c38547cc 100644 --- a/src/ui/PodcastView/EpisodesPage.svelte +++ b/src/ui/PodcastView/EpisodesPage.svelte @@ -120,3 +120,125 @@ {query || $hidePlayedEpisodes ? "No episodes match" : "No episodes"} {/if} + + diff --git a/src/ui/PodcastView/EpisodesPage.test.ts b/src/ui/PodcastView/EpisodesPage.test.ts index e1d11df2..c392c29b 100644 --- a/src/ui/PodcastView/EpisodesPage.test.ts +++ b/src/ui/PodcastView/EpisodesPage.test.ts @@ -54,7 +54,6 @@ function renderPage(key: string, feeds: PodcastFeed[] = [show], { online = true : null : feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); ({ services, dispose } = createTestServices({ feeds: repository })); diff --git a/src/ui/PodcastView/LibraryPage.svelte b/src/ui/PodcastView/LibraryPage.svelte index f428d9fd..18a9430d 100644 --- a/src/ui/PodcastView/LibraryPage.svelte +++ b/src/ui/PodcastView/LibraryPage.svelte @@ -48,3 +48,67 @@ {/each} + + diff --git a/src/ui/PodcastView/LibraryPage.test.ts b/src/ui/PodcastView/LibraryPage.test.ts index 75ddd445..3eac0869 100644 --- a/src/ui/PodcastView/LibraryPage.test.ts +++ b/src/ui/PodcastView/LibraryPage.test.ts @@ -37,7 +37,6 @@ const repositoryWith = ({ load, resolveUrl, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); const unreachable = () => repositoryWith(); diff --git a/src/ui/PodcastView/ListPage.test.ts b/src/ui/PodcastView/ListPage.test.ts index 3cdffca8..447bba29 100644 --- a/src/ui/PodcastView/ListPage.test.ts +++ b/src/ui/PodcastView/ListPage.test.ts @@ -60,7 +60,6 @@ function renderPage( : null : feed.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }); ({ services, dispose } = createTestServices({ feeds: repository })); diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index 27872e0f..aa5b8b4a 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -129,21 +129,6 @@ padding: 0 var(--size-4-2); } - .pn-mini-prog { - position: absolute; - left: 0; - right: 0; - top: -1px; - height: 2px; - } - - .pn-mini-prog span, - .pn-mini-track span { - display: block; - height: 100%; - background: var(--interactive-accent); - } - .pn-mini-open { flex: 1; min-width: 0; @@ -166,80 +151,109 @@ background: var(--background-modifier-hover); } - .is-wide .pn-mini-open { - flex: 0 1 300px; - } + .pn-mini :global { + .pn-mini-prog { + position: absolute; + left: 0; + right: 0; + top: -1px; + height: 2px; + } - .pn-mini-cover { - flex: none; - width: 36px; - height: 36px; - border-radius: 5px; - object-fit: cover; - } + .pn-mini-prog span, + .pn-mini-track span { + display: block; + height: 100%; + background: var(--interactive-accent); + } - .is-wide .pn-mini-cover, - :global(.is-phone) .pn-mini-cover { - width: 44px; - height: 44px; - } + .pn-mini-cover { + flex: none; + width: 36px; + height: 36px; + border-radius: 5px; + object-fit: cover; + } - .pn-mini-txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } + .pn-mini-txt { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } - .pn-mini-t { - font-weight: var(--font-medium); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-mini-t { + font-weight: var(--font-medium); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .pn-mini-s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-variant-numeric: tabular-nums; - } + .pn-mini-s { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; + } - .pn-mini-mid { - flex: 1; - display: flex; - align-items: center; - gap: var(--size-4-2); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-variant-numeric: tabular-nums; - } + .pn-mini-mid { + flex: 1; + display: flex; + align-items: center; + gap: var(--size-4-2); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-variant-numeric: tabular-nums; + } - .pn-mini-track { - flex: 1; - height: 4px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } + .pn-mini-track { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--background-modifier-border-hover); + overflow: hidden; + } - .pn-mini .pn-iconbtn { - opacity: 1; - color: var(--text-normal); - } + button.pn-iconbtn { + opacity: 1; + color: var(--text-normal); + } + + button.pn-mini-capture { + color: var(--text-accent); + } - .pn-mini .pn-mini-capture { - color: var(--text-accent); + .pn-mini-toggle svg { + fill: currentColor; + } + + .pn-btn-icon { + display: flex; + --icon-size: var(--icon-s); + --icon-stroke: var(--icon-s-stroke-width); + } } - .pn-mini-toggle :global(svg) { - fill: currentColor; + .pn-mini.is-wide :global { + .pn-mini-open { + flex: 0 1 300px; + } + + .pn-mini-cover { + width: 44px; + height: 44px; + } + + button.pn-btn { + height: 30px; + } } - .is-wide .pn-btn { - height: 30px; + :global(.is-phone) .pn-mini :global(.pn-mini-cover) { + width: 44px; + height: 44px; } diff --git a/src/ui/PodcastView/Panel.svelte b/src/ui/PodcastView/Panel.svelte new file mode 100644 index 00000000..675faf22 --- /dev/null +++ b/src/ui/PodcastView/Panel.svelte @@ -0,0 +1,92 @@ + + +
    + {#if head} +
    {@render head()}
    + {/if} + {#if list} +
      {@render children()}
    + {:else} +
    {@render children()}
    + {/if} +
    + + diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index 23748738..bd60ecd4 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -2,42 +2,39 @@ import { tick } from "svelte"; import type { Chapter } from "src/types/Chapter"; import type { Episode } from "src/types/Episode"; - import { formatDuration } from "src/utility/formatDuration"; - import { isLocalFile } from "src/utility/isLocalFile"; - import { ownRecord } from "src/utility/ownRecord"; - import { normalizeSkipLength } from "src/utility/skipLength"; import { icon } from "../obsidian/icon"; - import { cachedChapters, chapterAt, loadChapters } from "./chapters"; + import { cachedChapters, loadChapters } from "./chapters"; import ChaptersPanel from "./ChaptersPanel.svelte"; import { useServices } from "./context"; - import { sourceOf } from "./episodeRow"; + import Deck from "./Deck.svelte"; import type { Layout } from "./layout"; - import { anchorOf, episodeMenu, noteEntry, rateMenu, showMenu } from "./menus"; - import type { Page } from "./pages"; - import Scrubber from "./Scrubber.svelte"; + import { anchorOf, episodeMenu, showMenu } from "./menus"; import UpNextPanel from "./UpNextPanel.svelte"; - type Panel = "chapters" | "queue"; + type PanelKind = "chapters" | "queue"; + + /** Arrow keys, Home and End move between the wide player's tabs, each showing its panel. */ + const TAB_KEYS: Record number> = { + ArrowLeft: (at, count) => (at + count - 1) % count, + ArrowRight: (at, count) => (at + 1) % count, + Home: () => 0, + End: (_at, count) => count - 1, + }; let { viewRoot, layout }: { viewRoot: HTMLElement; layout: Layout } = $props(); const services = useServices(); - const { api, player, navigation, settings, library, rowHasNote, captureTimestamp, scope } = - services; + const { player, navigation, scope } = services; const playerState = player.state; - const { savedFeeds } = library; const id = $props.id(); let section: HTMLElement | undefined = $state(); - let volumeButton: HTMLButtonElement | undefined = $state(); + let deck: Deck | undefined = $state(); let videoFrame: HTMLElement | undefined = $state(); let inMainWindow = $state(true); let chapters: readonly Chapter[] = $state([]); /** The panel the user chose. None shows the artwork at compact widths. */ - let panel = $state(); - let volumeOpen = $state(false); + let panel = $state(); let failedArtworkUrl = $state(""); - let added = $state(false); - let addedTimer: number | undefined; const episode = $derived($playerState.episode as Episode); const wide = $derived(layout === "wide"); @@ -46,31 +43,14 @@ ); const failed = $derived($playerState.status === "error"); const video = $derived($playerState.mediaType === "video"); - const duration = $derived($playerState.duration); - const elapsed = $derived(Math.min($playerState.currentTime, duration)); - const panels = $derived(chapters.length > 0 ? ["chapters", "queue"] : ["queue"]); + const elapsed = $derived(Math.min($playerState.currentTime, $playerState.duration)); + const panels = $derived(chapters.length > 0 ? ["chapters", "queue"] : ["queue"]); const shown = $derived.by(() => { // At compact widths a media error takes the stage, so no panel shows meanwhile. if (failed && !wide) return undefined; if (panel && panels.includes(panel)) return panel; return wide ? panels[0] : undefined; }); - const current = $derived(chapterAt(chapters, elapsed)); - const back = $derived(normalizeSkipLength($settings.skipBackwardLength)); - const forward = $derived(normalizeSkipLength($settings.skipForwardLength)); - const source = $derived(sourceOf(episode)); - const sourcePage = $derived( - isLocalFile(episode) - ? { kind: "local" } - : ownRecord($savedFeeds)[episode.podcastName] - ? { kind: "feed", key: episode.podcastName } - : undefined, - ); - const note = $derived.by(() => { - // A note made or removed since, also by a capture, changes the store. - void $rowHasNote; - return noteEntry(episode, services); - }); const chaptersUrl = $derived(episode.chaptersUrl); $effect.pre(() => { @@ -85,18 +65,11 @@ return () => (latest = false); }); - $effect(() => { - if (failed) volumeOpen = false; - }); - // The view's own scope gets the keys typed while the view's leaf is active, which focus // or a press in the view makes it. A menu or modal over the player takes Escape first. $effect(() => { const escape = scope.register(null, "Escape", () => { - if (volumeOpen) { - volumeOpen = false; - volumeButton?.focus(); - } else void collapse(); + if (!deck?.closeVolume()) void collapse(); return false; }); const space = scope.register(null, " ", (evt) => { @@ -135,20 +108,14 @@ } } - function closeOutside(popover: HTMLElement) { - const close = (event: PointerEvent) => { - const target = event.target as Node; - if (!popover.contains(target) && !volumeButton?.contains(target)) volumeOpen = false; - }; - popover.doc.addEventListener("pointerdown", close, true); - return () => popover.doc.removeEventListener("pointerdown", close, true); - } - - async function capture() { - if (!(await captureTimestamp())) return; - added = true; - window.clearTimeout(addedTimer); - addedTimer = window.setTimeout(() => (added = false), 1200); + function moveTab(event: KeyboardEvent, at: number) { + const step = TAB_KEYS[event.key]; + if (!step) return; + event.preventDefault(); + const index = step(at, panels.length); + panel = panels[index]; + const tab = (event.currentTarget as HTMLElement).parentElement?.children[index]; + (tab as HTMLElement | undefined)?.focus(); } function fullscreen() { @@ -161,7 +128,7 @@ } -{#snippet panelView(kind: Panel)} +{#snippet panelView(kind: PanelKind)} {#if kind === "chapters"} (panel = !tabs && shown === kind ? undefined : kind)} - >{kind === "chapters" ? "Chapters" : "Up next"} - {/each} +{#snippet tabs()} +
    + {#each panels as kind, index (kind)} + + {/each} +
    +{/snippet} + +{#snippet switcher()} +
    + {#each panels as kind (kind)} + + {/each} +
    {/snippet}
    @@ -263,138 +247,19 @@ > {/if} -
    -
    -

    {episode.title}

    - {#if sourcePage} - {@const page = sourcePage} - - {:else} - {source} - {/if} -
    -
    - player.seek(time)} - /> -
    - {formatDuration(elapsed)} - {#if current >= 0} - - {:else} - - {/if} - -{formatDuration(Math.max(0, duration - elapsed))} -
    -
    -
    - -
    - - - -
    - - {#if volumeOpen} -
    - - player.setVolume(Number(event.currentTarget.value))} - /> - {Math.round($playerState.volume * 100)}% -
    - {/if} -
    -
    - - -
    + (panel = "chapters")} + > {#if !wide} -
    - {@render choices(false)} -
    + {@render switcher()} {/if} -
    + {#if wide && shown}
    -
    - {@render choices(true)} -
    + {@render tabs()}
    diff --git a/src/ui/PodcastView/Player.test.ts b/src/ui/PodcastView/Player.test.ts index 3a6c9092..62d03714 100644 --- a/src/ui/PodcastView/Player.test.ts +++ b/src/ui/PodcastView/Player.test.ts @@ -419,6 +419,41 @@ describe("the player's panels", () => { expect(screen.getByRole("tabpanel", { name: "Up next" })).toBeInTheDocument(); }); + test("at wide width, arrow keys, Home and End move focus between the tabs and show each one's panel", async () => { + await openPlayer(withChapters, 820); + await vi.waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); + const tabs = () => + screen.getAllByRole("tab").map((tab) => ({ + name: tab.textContent, + selected: tab.getAttribute("aria-selected"), + tabIndex: tab.tabIndex, + focused: tab === document.activeElement, + })); + const shown = () => screen.getByRole("tabpanel").getAttribute("aria-labelledby"); + const key = async (key: string) => { + await fireEvent.keyDown(document.activeElement!, { key }); + return [tabs().find((tab) => tab.focused)?.name, shown()]; + }; + screen.getByRole("tab", { name: "Chapters" }).focus(); + + expect(await key("ArrowRight")).toEqual([ + "Up next", + screen.getByRole("tab", { name: "Up next" }).id, + ]); + expect(tabs()).toEqual([ + { name: "Chapters", selected: "false", tabIndex: -1, focused: false }, + { name: "Up next", selected: "true", tabIndex: 0, focused: true }, + ]); + expect(within(screen.getByRole("tabpanel")).getByText("Queue is empty")).toBeVisible(); + expect((await key("ArrowRight"))[0]).toBe("Chapters"); + expect((await key("ArrowLeft"))[0]).toBe("Up next"); + expect((await key("Home"))[0]).toBe("Chapters"); + expect((await key("Home"))[0]).toBe("Chapters"); + expect((await key("End"))[0]).toBe("Up next"); + expect((await key("End"))[0]).toBe("Up next"); + expect((await key("ArrowDown"))[0]).toBe("Up next"); + }); + test("a reopened player shows the chapters it loaded on its first frame", async () => { await openPlayer({ ...episode, chaptersUrl: "https://pod.test/reopened.json" }, 820); await vi.waitFor(() => expect(screen.getByRole("tab", { name: "Chapters" })).toBeVisible()); diff --git a/src/ui/PodcastView/PlaylistsPage.svelte b/src/ui/PodcastView/PlaylistsPage.svelte index 91915579..835df9aa 100644 --- a/src/ui/PodcastView/PlaylistsPage.svelte +++ b/src/ui/PodcastView/PlaylistsPage.svelte @@ -35,7 +35,7 @@ {/snippet} -
    +
    {@render row($favorites, "heart", true)} {#each Object.values($playlists) as playlist (playlist.name)} {@render row(playlist, playlist.icon, false)} @@ -45,3 +45,83 @@ New playlist
    + + diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index 50a8d8e7..a57f5f66 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -129,19 +129,17 @@ let storage = new MemoryStorage(); let unsubscribeFeeds = () => {}; /** Builds the plugin the view reads, with a repository fed by `mockLoad` and `storage`. */ -function usePlugin(fields: Record = {}, cacheEnabled = false) { +function usePlugin(fields: Record = {}) { const feedUrls = new FeedUrlRepository(new obsidian.SecretStorage()); const feeds = new FeedRepository({ load: (url) => mockLoad(url), resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls), storage, - cache: () => ({ enabled: cacheEnabled, ttlHours: 6 }), now: Date.now, }); unsubscribeFeeds(); unsubscribeFeeds = savedFeeds.subscribe((saved) => feeds.setSubscriptions(saved)); plugin.set({ - settings: { feedCache: { enabled: cacheEnabled, ttlHours: 6 } }, ...fields, feeds, player, @@ -158,7 +156,6 @@ async function cacheFeeds(...entries: [PodcastFeed, Episode[]][]) { }, resolveUrl: (saved) => saved.url, storage, - cache: () => ({ enabled: true, ttlHours: 6 }), now: Date.now, }); earlier.setSubscriptions(Object.fromEntries(entries.map(([feed]) => [feed.title, feed]))); @@ -478,7 +475,7 @@ describe("issue #174 feed cache cap regression", () => { beforeEach(async () => { await cacheFeeds([testFeed, fullFeed]); respondByUrl({ [testFeed.url]: fullFeed }); - usePlugin({}, true); + usePlugin(); }); test("opening a show bypasses the truncated cache and loads older episodes", async () => { @@ -578,7 +575,7 @@ describe("issue #174 feed cache cap regression", () => { finished: true, }, }); - usePlugin({}, true); + usePlugin(); renderView(); diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index 6a5f072d..e21802ec 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -378,7 +378,6 @@ describe("offline", () => { load, resolveUrl: (saved) => saved.url, storage: new MemoryStorage(), - cache: () => ({ enabled: false, ttlHours: 6 }), now: Date.now, }), })); diff --git a/src/ui/PodcastView/PodcastView.svelte b/src/ui/PodcastView/PodcastView.svelte index ed393263..7181655e 100644 --- a/src/ui/PodcastView/PodcastView.svelte +++ b/src/ui/PodcastView/PodcastView.svelte @@ -348,140 +348,177 @@ --pn-skip: 48px; } - .pn-body { - position: relative; - flex: 1; - min-height: 0; - display: flex; - } + .podcast-view :global { + .pn-body { + position: relative; + flex: 1; + min-height: 0; + display: flex; + } - .pn-body.pn-split { - display: grid; - grid-template-columns: 216px minmax(0, 1fr); - } + .pn-body.pn-split { + display: grid; + grid-template-columns: 216px minmax(0, 1fr); + } - .pn-main { - position: relative; - flex: 1; - min-width: 0; - min-height: 0; - display: flex; - flex-direction: column; - } + .pn-main { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + } - .pn-split .pn-main { - --pn-bg: var(--background-primary); - background: var(--pn-bg); - } + .pn-split .pn-main { + --pn-bg: var(--background-primary); + background: var(--pn-bg); + } - .pn-strip { - flex: none; - display: flex; - align-items: center; - gap: var(--size-4-2); - height: 32px; - padding: 0 var(--pn-pad); - background: var(--background-modifier-border); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-weight: var(--font-medium); - } + .pn-strip { + flex: none; + display: flex; + align-items: center; + gap: var(--size-4-2); + height: 32px; + padding: 0 var(--pn-pad); + background: var(--background-modifier-border); + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-weight: var(--font-medium); + } - .pn-strip-icon { - display: flex; - --icon-size: 14px; - } + .pn-strip-icon { + display: flex; + --icon-size: 14px; + } - .pn-head { - flex: none; - display: flex; - align-items: center; - gap: var(--size-2-2); - height: var(--pn-head); - padding: 0 var(--size-4-2) 0 var(--size-4-1); - } + .pn-head { + flex: none; + display: flex; + align-items: center; + gap: var(--size-2-2); + height: var(--pn-head); + padding: 0 var(--size-4-2) 0 var(--size-4-1); + } - .is-wide .pn-head { - padding: 0 var(--size-4-4) 0 var(--size-4-3); - } + .pn-head-title { + margin: 0 0 0 var(--size-4-2); + font-size: var(--font-ui-medium); + line-height: 1.2; + font-weight: var(--font-semibold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } - .pn-split .pn-head { - height: 60px; - padding-top: var(--size-4-1); - } + .pn-head .flair { + align-self: center; + margin-left: var(--size-4-2); + padding-top: 3px; + } - .pn-head-title { - margin: 0 0 0 var(--size-4-2); - font-size: var(--font-ui-medium); - line-height: 1.2; - font-weight: var(--font-semibold); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-head > .pn-search:first-child { + margin-left: var(--size-4-3); + } - .is-wide .pn-head-title { - margin-left: var(--size-4-3); - font-size: var(--font-ui-large); - letter-spacing: -0.01em; - } + .pn-back { + display: inline-flex; + align-items: center; + gap: var(--size-2-1); + min-width: 0; + max-width: 70%; + height: var(--pn-btn); + padding: 0 var(--size-4-2) 0 var(--size-2-1); + border: 0; + border-radius: var(--radius-s); + background: none; + box-shadow: none; + color: var(--text-muted); + font-size: var(--font-ui-small); + cursor: var(--cursor); + } - .pn-back-icon { - display: flex; - flex: none; - } + .pn-back:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + } - .pn-back-label { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .pn-back-icon { + display: flex; + flex: none; + } + + .pn-back-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pn-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 var(--pn-pad) var(--size-4-6); + } + + .pn-title { + display: flex; + align-items: baseline; + gap: var(--size-4-2); + min-width: 0; + margin: var(--size-4-1) 0 var(--size-4-3); + } + + .pn-title h1 { + margin: 0; + font-size: var(--font-ui-large); + font-weight: var(--font-semibold); + line-height: 1.2; + letter-spacing: -0.01em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pn-first-run { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + } - .pn-scroll { - flex: 1; - min-height: 0; - overflow-y: auto; - padding: 0 var(--pn-pad) var(--size-4-6); + .pn-first-run .pn-first-settings { + position: absolute; + top: var(--size-4-1); + right: var(--size-4-2); + } } /* Without the mini player, a page ends under Obsidian's status bar. */ - .podcast-view:not(:has(> :global(.pn-mini))) .pn-scroll { + .podcast-view:not(:has(> :global(.pn-mini))) :global(.pn-scroll) { padding-bottom: calc(var(--size-4-6) + var(--pn-status-bar)); } - .pn-title { - display: flex; - align-items: baseline; - gap: var(--size-4-2); - min-width: 0; - margin: var(--size-4-1) 0 var(--size-4-3); - } - - .pn-title h1 { - margin: 0; - font-size: var(--font-ui-large); - font-weight: var(--font-semibold); - line-height: 1.2; - letter-spacing: -0.01em; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } + .is-wide :global { + .pn-head { + padding: 0 var(--size-4-4) 0 var(--size-4-3); + } - .pn-first-run { - position: relative; - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - } + .pn-split .pn-head { + height: 60px; + padding-top: var(--size-4-1); + } - .pn-first-run .pn-first-settings { - position: absolute; - top: var(--size-4-1); - right: var(--size-4-2); + .pn-head-title { + margin-left: var(--size-4-3); + font-size: var(--font-ui-large); + letter-spacing: -0.01em; + } } + /* Classes that several of the view's components share. */ .podcast-view :global { .pn-spacer { flex: 1; @@ -527,6 +564,11 @@ cursor: default; } + .pn-iconbtn[aria-pressed="true"] { + color: var(--icon-color-active); + opacity: 1; + } + .pn-btn { display: inline-flex; align-items: center; @@ -551,12 +593,6 @@ background: var(--interactive-accent-hover); } - .pn-btn-icon { - display: flex; - --icon-size: var(--icon-s); - --icon-stroke: var(--icon-s-stroke-width); - } - .pn-nav { display: flex; flex-direction: column; @@ -626,925 +662,76 @@ font-variant-numeric: tabular-nums; } - .pn-head .flair { - align-self: center; - margin-left: var(--size-4-2); - padding-top: 3px; - } - .pn-navitem .flair { font-size: var(--font-ui-smaller); } - .pn-back, - .pn-alt { - display: inline-flex; - align-items: center; - height: var(--pn-btn); - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; + .pn-count { + margin: var(--size-2-2) 0 var(--size-4-2); color: var(--text-muted); - font-size: var(--font-ui-small); - cursor: var(--cursor); - } - - .pn-back:hover, - .pn-alt:hover { - background: var(--background-modifier-hover); - color: var(--text-normal); - } - - .pn-back { - gap: var(--size-2-1); - min-width: 0; - max-width: 70%; - padding: 0 var(--size-4-2) 0 var(--size-2-1); - } - - .pn-alt { - gap: var(--size-4-2); - padding: 0 var(--size-4-2); - } - - .pn-alt > span { - display: flex; - --icon-size: var(--icon-s); - --icon-stroke: var(--icon-s-stroke-width); + font-size: var(--font-ui-smaller); } - .pn-alts { + .pn-panel-empty { display: flex; flex-direction: column; align-items: center; - gap: var(--size-2-2); - margin-top: var(--size-4-4); - } - - .pn-scroll > .pn-alts { - align-items: flex-start; - margin-top: var(--size-4-2); - } - - .pn-search { - position: relative; - flex: 1; - min-width: 0; + gap: var(--size-4-3); + padding: var(--size-4-8) var(--size-4-3); + color: var(--text-muted); + text-align: center; } - .pn-search-icon { - position: absolute; - left: var(--size-4-2); - top: 50%; - transform: translateY(-50%); + .pn-panel-empty-icon { display: flex; - color: var(--text-muted); - pointer-events: none; - --icon-size: var(--icon-xs); - --icon-stroke: var(--icon-xs-stroke-width); + --icon-size: 28px; + --icon-stroke: 1.5px; } - .pn-input { - width: 100%; - padding: 0 28px; + .pn-list { + display: flex; + flex-direction: column; + margin: 0 calc(-1 * var(--size-4-2)); } - .pn-input::placeholder { + .pn-filetile { + display: flex; + align-items: center; + justify-content: center; + background: var(--background-modifier-border); color: var(--text-muted); + --icon-size: 18px; } - .pn-input::-webkit-search-cancel-button { - display: none; - } - - .pn-clear { - position: absolute; - right: 2px; - top: 50%; - transform: translateY(-50%); - width: 24px; - height: 24px; + .pn-iconbtn:focus-visible, + .pn-btn:focus-visible, + .pn-ep-main:focus-visible, + .pn-gridcard:focus-visible, + .pn-navitem:focus-visible, + .pn-plrow:focus-visible, + .pn-back:focus-visible, + .pn-alt:focus-visible, + .pn-result:focus-visible, + .pn-railsearch:focus-visible, + .pn-mini-open:focus-visible, + .pn-textbtn:focus-visible, + .pn-player button:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 1px; + box-shadow: none; } + } - .pn-head > .pn-search:first-child { - margin-left: var(--size-4-3); - } - - .pn-cover { - display: block; - flex: none; - width: 100%; - aspect-ratio: 1; - border-radius: 6px; - object-fit: cover; - } - - .pn-cover.is-text { - display: flex; - align-items: flex-end; - overflow: hidden; - background: var(--background-modifier-border); - color: var(--text-muted); - font-weight: var(--font-bold); - line-height: 1; - text-align: left; - overflow-wrap: anywhere; - container-type: inline-size; - } - - /* cqw is the cover's width. A percentage padding on the cover is its row's. */ - .pn-cover.is-text > span { - padding: 10cqw; - font-size: 12cqw; - } - - .pn-empty { - flex: 1; - min-height: 0; - overflow-y: auto; - display: flex; - flex-direction: column; - align-items: center; - padding: var(--size-4-6) var(--pn-pad) 72px; - text-align: center; - } - - /* Centered, yet scrollable from its top when the view is short. */ - .pn-empty > :first-child { - margin-top: auto; - } - - .pn-empty > :last-child { - margin-bottom: auto; - } - - .pn-empty-mark { - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - margin-bottom: var(--size-4-4); - border-radius: 16px; - background: hsla(var(--interactive-accent-hsl), 0.14); - color: var(--text-accent); - --icon-size: 28px; - --icon-stroke: 1.75px; - } - - .pn-empty h1 { - margin: 0 0 var(--size-4-4); - font-size: var(--font-ui-large); - font-weight: var(--font-semibold); - letter-spacing: -0.01em; - } - - .pn-empty .pn-search { - flex: none; - width: 100%; - max-width: 360px; - } - - .pn-empty .pn-input { - height: 36px; - padding-left: 32px; - } - - .pn-empty .pn-search-icon { - left: 10px; - } - - .pn-count { - margin: var(--size-2-2) 0 var(--size-4-2); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - } - - .pn-panel-empty { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--size-4-3); - padding: var(--size-4-8) var(--size-4-3); - color: var(--text-muted); - text-align: center; - } - - .pn-panel-empty-icon { - display: flex; - --icon-size: 28px; - --icon-stroke: 1.5px; - } - - .pn-panel { - width: 100%; - height: 100%; - min-height: 0; - display: flex; - flex-direction: column; - } - - .pn-panel-scroll { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - overflow-y: auto; - padding: var(--size-4-2) 0 var(--size-4-4); - mask-image: linear-gradient( - to bottom, - transparent, - #000 12px, - #000 calc(100% - 20px), - transparent - ); - } - - .pn-panel-scroll > .pn-panel-empty { - margin: auto 0; - } - - .pn-panel-head { - flex: none; - display: flex; - align-items: center; - gap: var(--size-4-2); - min-height: 32px; - padding-bottom: var(--size-2-2); - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-weight: var(--font-medium); - white-space: nowrap; - } - - .pn-textbtn { - height: auto; - padding: var(--size-2-2) var(--size-4-2); - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-accent); - font-size: var(--font-ui-small); - font-weight: var(--font-medium); - } - - .pn-textbtn:hover { - background: var(--background-modifier-hover); - } - - /* Obsidian's buttons keep their text on one line; these wrap their titles. */ - .pn-result, - .pn-ep-main, - .pn-gridcard { - white-space: normal; - } - - .pn-result, - .pn-ep-main { - display: flex; - gap: var(--size-4-3); - height: auto; - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-normal); - font-size: var(--font-ui-small); - text-align: left; - cursor: var(--cursor); - } - - .pn-result { - align-items: center; - width: 100%; - padding: var(--size-4-2); - } - - .pn-result:hover { - background: var(--background-modifier-hover); - } - - .pn-result .pn-cover { - width: 44px; - } - - .pn-result .txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .pn-result .t, - .pn-ep-title { - font-weight: var(--font-medium); - line-height: 1.35; - } - - /* Long titles take two lines and break anywhere, never under a button beside them. */ - .pn-result .t, - .pn-ep-title, - .pn-hero h1 { - display: -webkit-box; - -webkit-line-clamp: 2; - line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; - overflow-wrap: anywhere; - } - - .pn-result .s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-result .end { - flex: none; - display: flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - color: var(--text-muted); - --icon-size: 18px; - } - - .pn-result .end.added { - color: var(--text-success); - } - - .pn-result:hover .end:not(.added) { - color: var(--text-accent); - } - - .pn-linkchip { - display: inline-flex; - align-items: center; - gap: 4px; - vertical-align: bottom; - --icon-size: 12px; - } - - .pn-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); - gap: var(--size-4-2); - } - - .pn-gridcard { - position: relative; - display: block; - width: 100%; - height: auto; - padding: 0; - border: 0; - border-radius: 8px; - background: none; - box-shadow: none; - cursor: var(--cursor); - transition: transform 120ms ease; - } - - .pn-gridcard .pn-cover { - border-radius: 8px; - } - - .pn-gridcard:hover .pn-cover { - box-shadow: var(--shadow-s); - } - - .pn-gridcard:active { - transform: scale(0.98); - } - - .pn-badge { - position: absolute; - left: 6%; - top: 6%; - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--background-primary); - color: var(--text-error); - box-shadow: var(--shadow-s); - --icon-size: 12px; - --icon-stroke: 2.25px; - } - - .pn-iconbtn[aria-pressed="true"] { - color: var(--icon-color-active); - opacity: 1; - } - - .pn-filter { - position: sticky; - top: 0; - z-index: 2; - display: flex; - align-items: center; - gap: var(--size-2-2); - padding: var(--size-4-2) 0; - margin-bottom: var(--size-2-2); - background: var(--pn-bg); - } - - .pn-list { - display: flex; - flex-direction: column; - margin: 0 calc(-1 * var(--size-4-2)); - } - - .pn-ep { - display: flex; - align-items: flex-start; - border-radius: var(--radius-s); - } - - .pn-ep:hover { - background: var(--background-modifier-hover); - } - - .pn-ep-main { - flex: 1; - min-width: 0; - align-items: flex-start; - padding: var(--size-4-2) 0 var(--size-4-2) var(--size-4-2); - } - - .pn-ep-main .pn-cover { - width: 40px; - margin-top: 1px; - border-radius: 5px; - } - - .pn-ep-text { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 3px; - } - - .pn-ep-meta { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 3px 6px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-variant-numeric: tabular-nums; - --icon-size: 13px; - --icon-stroke: 2px; - } - - .pn-ep-meta .sep { - color: var(--text-faint); - } - - .pn-ep-meta .pod { - min-width: 0; - overflow-wrap: anywhere; - } - - .pn-brk { - flex-basis: 100%; - height: 0; - } - - .pn-brk + .sep { - display: none; - } - - .pn-filetile { - display: flex; - align-items: center; - justify-content: center; - background: var(--background-modifier-border); - color: var(--text-muted); - --icon-size: 18px; - } - - .pn-bar { - width: 32px; - height: 3px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } - - .pn-bar span { - display: block; - height: 100%; - background: var(--interactive-accent); - } - - .pn-ep.is-played .pn-ep-title { - color: var(--text-muted); - font-weight: var(--font-normal); - } - - .pn-ep.is-current .pn-ep-title, - .pn-ep.is-current .pn-ep-meta { - color: var(--text-accent); - } - - .pn-ep.is-unavailable .pn-ep-main { - opacity: 0.45; - cursor: default; - } - - .pn-ep-more { - margin: var(--size-4-2) var(--size-2-2) 0 0; - opacity: 0.55; - } - - .pn-ep:hover .pn-ep-more, - .pn-ep-more:focus-visible { - opacity: 1; - } - - .pn-queue .pn-ep { - align-items: center; - gap: var(--size-2-2); - } - - .pn-queue .pn-ep-main { - align-items: center; - padding-left: 0; - } - - .pn-queue .pn-ep-main .pn-cover { - margin-top: 0; - } - - .pn-queue .pn-bar { - display: none; - } - - .pn-grip { - cursor: grab; - touch-action: none; - color: var(--text-muted); - opacity: 1; - --icon-size: 16px; - } - - .pn-ep:has(.pn-grip.is-dragging) { - position: relative; - z-index: 2; - background: var(--pn-surface); - box-shadow: var(--shadow-s); - } - - .pn-ep-x { - margin-right: var(--size-2-2); - opacity: 0.55; - } - - .pn-ep:hover .pn-ep-x, - .pn-ep-x:focus-visible { - opacity: 1; - } - - .pn-eq { - display: flex; - align-items: flex-end; - gap: 1.5px; - height: 10px; - } - - .pn-eq i { - width: 2px; - height: 100%; - border-radius: 1px; - background: currentColor; - transform-origin: bottom; - animation: pn-eq 1s ease-in-out infinite; - } - - .pn-eq i:first-child { - animation-delay: -0.2s; - } - - .pn-eq i:last-child { - animation-delay: -0.8s; - } - - .pn-eq.paused i { - animation: none; - transform: scaleY(0.6); - } - - .pn-hero { - display: flex; - align-items: center; - gap: var(--size-4-3); - margin: var(--size-2-2) 0 var(--size-4-2); - } - - .pn-hero .pn-cover { - width: 64px; - border-radius: 8px; - } - - .pn-hero > div { - min-width: 0; - } - - .pn-hero h1 { - margin: 0; - font-size: var(--font-ui-medium); - font-weight: var(--font-semibold); - line-height: 1.3; - } - - .pn-hero .by { - margin-top: 2px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-banner { - display: flex; - align-items: flex-start; - gap: var(--size-4-2); - margin: var(--size-4-2) 0; - padding: var(--size-4-2) var(--size-4-2) var(--size-4-2) var(--size-4-3); - border: 1px solid rgba(var(--color-red-rgb), 0.25); - border-radius: var(--radius-m); - background: rgba(var(--color-red-rgb), 0.08); - } - - .pn-banner-icon { - display: flex; - margin-top: 6px; - color: var(--text-error); - --icon-size: var(--icon-s); - } - - .pn-banner .txt { - flex: 1; - min-width: 0; - padding-top: 5px; - } - - .pn-banner .h { - font-weight: var(--font-semibold); - } - - .pn-banner .d { - margin-top: 2px; - color: var(--text-muted); - font-size: var(--font-ui-smaller); - font-family: var(--font-monospace); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-plrow { - display: flex; - align-items: center; - gap: var(--size-4-3); - width: 100%; - height: auto; - padding: var(--size-4-2); - border: 0; - border-radius: var(--radius-s); - background: none; - box-shadow: none; - color: var(--text-normal); - font-size: var(--font-ui-small); - font-weight: inherit; - text-align: left; - cursor: var(--cursor); - } - - .pn-plrow:hover { - background: var(--background-modifier-hover); - } - - .pn-plicon { - display: flex; - align-items: center; - justify-content: center; - flex: none; - width: 44px; - height: 44px; - border-radius: 8px; - background: var(--background-modifier-border); - color: var(--text-muted); - --icon-size: 20px; - } - - .pn-plicon.is-fav { - background: rgba(var(--color-pink-rgb), 0.16); - color: var(--color-pink); - } - - .pn-plrow .txt { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; - } - - .pn-plrow .t { - font-weight: var(--font-medium); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-plrow .s { - color: var(--text-muted); - font-size: var(--font-ui-smaller); - } - - .pn-plchevron { - display: flex; - color: var(--text-muted); - --icon-size: 16px; - } - - .pn-plrow.is-new .pn-plicon { - background: none; - border: 1px dashed var(--background-modifier-border-hover); - } - - .pn-plrow.is-new .t { - color: var(--text-muted); - font-weight: var(--font-normal); - } - - .pn-iconbtn:focus-visible, - .pn-btn:focus-visible, - .pn-ep-main:focus-visible, - .pn-gridcard:focus-visible, - .pn-navitem:focus-visible, - .pn-plrow:focus-visible, - .pn-back:focus-visible, - .pn-alt:focus-visible, - .pn-result:focus-visible, - .pn-railsearch:focus-visible, - .pn-mini-open:focus-visible, - .pn-textbtn:focus-visible, - .pn-player button:focus-visible { - outline: 2px solid var(--interactive-accent); - outline-offset: 1px; - box-shadow: none; - } - } - - @keyframes pn-eq { - 0%, - 100% { - transform: scaleY(0.4); - } - 50% { - transform: scaleY(1); - } - } - - @media (prefers-reduced-motion: reduce) { - .podcast-view :global(.pn-eq i) { - animation: none; - } - } - - .is-wide :global { - .pn-player .pn-panel-scroll { - padding: var(--size-4-3) var(--size-4-4) var(--size-4-4); - } - - .pn-player .pn-panel-head { - padding: var(--size-4-2) var(--size-4-4) 0 var(--size-4-6); - } - - .pn-ep-meta { - flex-wrap: nowrap; - white-space: nowrap; - } - - .pn-ep-meta > * { - flex: none; - } - - .pn-ep-meta .pod { - flex: 0 1 auto; - min-width: 24px; - overflow: hidden; - text-overflow: ellipsis; - } - - .pn-brk { - display: none; - } - - .pn-brk + .sep { - display: inline; - } - - .pn-grid { - grid-template-columns: repeat(auto-fill, minmax(108px, 1fr)); - gap: var(--size-4-4); - } - - .pn-hero { - gap: var(--size-4-5); - margin: var(--size-4-2) 0 var(--size-4-3); - } - - .pn-hero .pn-cover { - width: 112px; - border-radius: 10px; - } - - .pn-hero h1 { - font-size: var(--font-ui-large); - line-height: 1.25; - letter-spacing: -0.01em; - } - - .pn-hero .by { - margin-top: var(--size-4-1); - font-size: var(--font-ui-small); - } - } - - .is-phone :global { - .pn-panel-head { - min-height: 44px; - } - - .pn-grid { - grid-template-columns: repeat(3, 1fr); - gap: var(--size-4-3); - } - - .pn-navitem { - gap: var(--size-4-3); - padding: 0 var(--size-4-2); + .is-phone :global { + .pn-navitem { + gap: var(--size-4-3); + padding: 0 var(--size-4-2); } .pn-navicon { --icon-size: 20px; } - .pn-input { - height: 44px; - padding-left: 34px; - } - - .pn-search-icon { - left: 12px; - --icon-size: 16px; - } - - .pn-clear { - right: 0; - width: 44px; - height: 44px; - } - - .pn-empty .pn-input { - height: 48px; - padding-left: 36px; - } - - .pn-result .pn-cover { - width: 52px; - } - - .pn-ep-main { - padding-top: var(--size-4-3); - padding-bottom: var(--size-4-3); - } - - .pn-ep-main .pn-cover { - width: 48px; - } - - .pn-ep-more { - margin-top: var(--size-4-1); - } - - .pn-hero .pn-cover { - width: 76px; - } - .pn-btn { height: 44px; } diff --git a/src/ui/PodcastView/QueueRow.svelte b/src/ui/PodcastView/QueueRow.svelte index 11256d5c..ca89f260 100644 --- a/src/ui/PodcastView/QueueRow.svelte +++ b/src/ui/PodcastView/QueueRow.svelte @@ -45,6 +45,8 @@ openEpisode(services, episode)} onmenu={(at) => showMenu(episodeMenu(episode, services, { place: "list", at }), at)} > @@ -75,3 +77,23 @@ > {/snippet} + + diff --git a/src/ui/PodcastView/Scrubber.svelte b/src/ui/PodcastView/Scrubber.svelte index ce664fc1..24119dcb 100644 --- a/src/ui/PodcastView/Scrubber.svelte +++ b/src/ui/PodcastView/Scrubber.svelte @@ -114,37 +114,39 @@ cursor: default; } - .pn-segs { - display: flex; - gap: 2px; - width: 100%; - height: 4px; - } - - .pn-seg { - flex: 1 1 0; - min-width: 2px; - border-radius: 2px; - background: var(--background-modifier-border-hover); - overflow: hidden; - } - - .pn-seg span { - display: block; - height: 100%; - background: var(--interactive-accent); - } - - .pn-thumb { - position: absolute; - top: 50%; - width: 12px; - height: 12px; - margin: -6px 0 0 -6px; - border-radius: 50%; - background: var(--interactive-accent); - box-shadow: 0 0 0 2px var(--pn-bg); - pointer-events: none; + .pn-scrub :global { + .pn-segs { + display: flex; + gap: 2px; + width: 100%; + height: 4px; + } + + .pn-seg { + flex: 1 1 0; + min-width: 2px; + border-radius: 2px; + background: var(--background-modifier-border-hover); + overflow: hidden; + } + + .pn-seg span { + display: block; + height: 100%; + background: var(--interactive-accent); + } + + .pn-thumb { + position: absolute; + top: 50%; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border-radius: 50%; + background: var(--interactive-accent); + box-shadow: 0 0 0 2px var(--pn-bg); + pointer-events: none; + } } :global(.is-phone) .pn-scrub { @@ -152,13 +154,15 @@ margin: -8px 0; } - :global(.is-phone) .pn-segs { - height: 5px; - } + :global(.is-phone) .pn-scrub :global { + .pn-segs { + height: 5px; + } - :global(.is-phone) .pn-thumb { - width: 16px; - height: 16px; - margin: -8px 0 0 -8px; + .pn-thumb { + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + } } diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 568cd38c..3b8561d7 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -6,17 +6,20 @@ value, placeholder, autofocus = false, + large = false, oninput, }: { value: string; placeholder: string; autofocus?: boolean; + /** The first run's wider, taller field. */ + large?: boolean; oninput(value: string): void; } = $props(); let input: HTMLInputElement; -