diff --git a/src/components/Shell.tsx b/src/components/Shell.tsx index c57709b..0e81d67 100644 --- a/src/components/Shell.tsx +++ b/src/components/Shell.tsx @@ -22,6 +22,7 @@ import { useFeedStore } from "@/stores/feed"; import { useAppStore } from "@/stores/app"; import { useToast } from "@/ui/toast"; import { emit, on } from "@/utils/event-bus"; +import { feedForEpisode } from "@/utils/feed-resolve"; import { LayerGraph } from "@/utils/layer-graph"; import { TABS } from "@/utils/navigation"; import { createDispatcher } from "@/utils/dispatch"; @@ -222,9 +223,7 @@ export function Shell() { const ep = audio.currentEpisode(); if (!ep) return null; const feeds = feedStore.getFilteredFeeds(); - const feed = - feeds.find((f) => f.podcast.id === ep.podcastId) ?? - feeds.find((f) => f.episodes.some((e) => e.id === ep.id)); + const feed = feedForEpisode(feeds, ep); return feed ? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}` : `♪ ${ep.title}`; diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 3927513..c2007e4 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -55,6 +55,7 @@ import { saveLastPlayerSync, } from "../utils/app-persistence"; import type { Episode, Progress } from "../types/episode"; +import { feedForEpisode } from "../utils/feed-resolve"; import { useAudioNavStore } from "../stores/audio-nav"; import { useDownloadStore } from "../stores/download"; import { useFeedStore } from "../stores/feed"; @@ -359,8 +360,7 @@ async function play(episode: Episode): Promise { const vol = volume(); const spd = storeSpeed || speed(); - const feedStore = useFeedStore(); - const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); + const feed = feedForEpisode(useFeedStore().feeds(), episode); const podcastTitle = feed?.customName || feed?.podcast.title || ""; // Play the downloaded file when present (offline + no network stalls); // otherwise stream. Cover resolves to the feed art, falling back to the @@ -468,8 +468,7 @@ async function load(episode: Episode): Promise { setSpeed(storeSpeed || speed()); // Surface the loaded-but-paused track to the OS media controls. - const feedStore = useFeedStore(); - const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); + const feed = feedForEpisode(useFeedStore().feeds(), episode); const podcastTitle = feed?.customName || feed?.podcast.title || ""; const media = useMediaRegistry(); media.setNowPlaying({ @@ -688,10 +687,7 @@ async function switchBackend(name: BackendName): Promise { // Resume playback if we were playing if (wasPlaying && ep && ep.audioUrl) { try { - const feedStore = useFeedStore(); - const feed = feedStore - .feeds() - .find((f) => f.podcast.id === ep.podcastId); + const feed = feedForEpisode(useFeedStore().feeds(), ep); const podcastTitle = feed?.customName || feed?.podcast.title || ""; const url = useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl; diff --git a/src/index.tsx b/src/index.tsx index 11fc724..3cbf656 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,5 @@ +import { onCleanup } from "solid-js"; +import { setupTerminalRecovery } from "./utils/terminal-recovery"; import type { Feed } from "./types/feed" import type { Episode } from "./types/episode" @@ -238,6 +240,7 @@ if (cliArgs.query !== null || cliArgs.play !== null) { function RendererSetup(props: { children: unknown }) { const renderer = useRenderer(); renderer.disableStdoutInterception(); + onCleanup(setupTerminalRecovery(renderer)); return props.children; } diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts index a6f1a03..2b401ec 100644 --- a/src/stores/visualizer.ts +++ b/src/stores/visualizer.ts @@ -132,11 +132,11 @@ function createVisualizerStore(): VisualizerStore { let lastPosMoveAt = 0; // Resume point: the position a paused pipeline was re-armed at. The - // loading state set by resume only clears once the position clock has - // advanced PAST this — while the player is still re-buffering, the - // cache can serve the same window forever and the stale pre-pause bars - // must not masquerade as live data. -1 = cold start (clear on the - // first produced frame, regardless of the clock). + // loading state set by resume clears once the position clock has MOVED + // from this (either direction) — while the player is still re-buffering + // the clock is frozen, and the cache serving the same window must not + // let stale pre-pause bars masquerade as live data. -1 = cold start + // (clear on the first produced frame, regardless of the clock). let resumePos = -1; // What the running pipeline was started with — lets the playback effect @@ -379,12 +379,14 @@ function createVisualizerStore(): VisualizerStore { // Normalize against the running peak and copy to a new array setBarData(scaler(output)); - // Fresh frames only count once the position clock has moved past + // Fresh frames only count once the position clock has MOVED from // the resume point: while the player is still re-buffering after a // long pause, the cache serves the same window and the spinner must - // stay in place of the stale bars. Cold starts (resumePos < 0) - // clear on the first frame as before. - if (isLoading() && (resumePos < 0 || rawPos > resumePos)) { + // stay in place of the stale bars. Any move counts — including a + // backward seek, whose window is live data for the new position and + // would strand the spinner forever under a `>` gate. Cold starts + // (resumePos < 0) clear on the first frame as before. + if (isLoading() && (resumePos < 0 || rawPos !== resumePos)) { setIsLoading(false); } }; diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 4c792e9..619f1de 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -383,7 +383,13 @@ export class MpvBackend implements AudioBackend { this.proc = Bun.spawn( [ "mpv", - "--no-video", + // --vo=null (not --no-video): the albumart track must stay the + // CURRENT video track or macOS Now Playing shows no artwork. + // --no-video drops it to unselected (albumart:true, selected:false), + // so the system media center renders no cover. --vo=null is equally + // headless — no window, no rendering — but keeps the cover current + // so Now Playing gets the art. + "--vo=null", "--no-terminal", "--really-quiet", // Stay alive after finishing/unloading files; PodTUI owns one mpv diff --git a/src/utils/feed-resolve.ts b/src/utils/feed-resolve.ts new file mode 100644 index 0000000..f122fc6 --- /dev/null +++ b/src/utils/feed-resolve.ts @@ -0,0 +1,22 @@ +/** + * Feed resolution for an episode. `episode.podcastId` is the RSS feed url + * (rss-parser), which differs from `podcast.id` (the iTunes directory id) for + * iTunes-added shows — so a strict `podcast.id` match fails and the feed (and + * its cover) is never found. Match by podcast id, then feed url, then episode + * membership, in that order. + */ + +import type { Feed } from "../types/feed"; +import type { Episode } from "../types/episode"; + +/** The feed backing `episode`, by podcast id, then feed url, then membership. */ +export function feedForEpisode( + feeds: Feed[], + episode: Episode, +): Feed | undefined { + return ( + feeds.find((f) => f.podcast.id === episode.podcastId) ?? + feeds.find((f) => f.podcast.feedUrl === episode.podcastId) ?? + feeds.find((f) => f.episodes.some((e) => e.id === episode.id)) + ); +} diff --git a/src/utils/terminal-recovery.ts b/src/utils/terminal-recovery.ts new file mode 100644 index 0000000..033cc37 --- /dev/null +++ b/src/utils/terminal-recovery.ts @@ -0,0 +1,47 @@ +/** + * Terminal recovery for suspend/resume and system sleep/wake cycles. + * + * The renderer enters the alternate screen, enables raw mode and attaches its + * stdin listener exactly once at startup. The diff renderer also keeps + * `currentRenderBuffer` as its model of what is on screen and only writes the + * cells that changed against that model. + * + * When the session is suspended (Ctrl-Z) or the system sleeps and the process + * is later resumed, the terminal screen can desync from that model: the stale + * buffer makes the diff rewrite only "changed" cells, leaving garbled or + * previous content on screen, and the raw-mode / stdin wiring can be dropped. + * The result is a frozen, non-interactive screen that shows raw markup instead + * of the UI. + * + * SIGCONT is the standard signal delivered when a stopped process resumes. + * On it we call `renderer.resume()`, the library's own recovery path, which: + * - re-enters the alternate screen (native resumeRenderer) + * - re-enables raw mode, re-attaches the stdin listener and flushes stale input + * - clears currentRenderBuffer so the next frame performs a full repaint + */ + +import type { CliRenderer } from "@opentui/core"; + +/** + * Register a SIGCONT handler that recovers the terminal after suspend/resume. + * + * @param renderer - the active CLI renderer + * @returns cleanup function that removes the handler + */ +export function setupTerminalRecovery(renderer: CliRenderer): () => void { + const onContinue = () => { + // Best-effort: resume() re-establishes terminal state and forces a full + // repaint by clearing the render buffer. Idempotent if fired repeatedly. + try { + renderer.resume(); + } catch { + // recovery is best-effort; never crash on the recovery path itself + } + }; + + process.on("SIGCONT", onContinue); + + return () => { + process.off("SIGCONT", onContinue); + }; +} diff --git a/tests/feed-for-episode.test.ts b/tests/feed-for-episode.test.ts new file mode 100644 index 0000000..09c8298 --- /dev/null +++ b/tests/feed-for-episode.test.ts @@ -0,0 +1,72 @@ +/** + * Unit test for feedForEpisode: resolving the feed behind an episode. + * + * The critical case is the reported regression — an iTunes show's episode has + * `podcastId` set to the RSS feed url (rss-parser:163) while the feed's + * `podcast.id` is the iTunes directory id. Those differ, so a strict + * `podcast.id` match loses the feed (and its cover, stalling Now Playing art). + */ + +import { describe, expect, test } from "bun:test"; +import { feedForEpisode } from "../src/utils/feed-resolve"; +import { FeedVisibility } from "../src/types/feed"; +import type { Episode } from "../src/types/episode"; +import type { Feed } from "../src/types/feed"; + +function makeFeed(id: string, feedUrl: string, title = `Show ${id}`): Feed { + return { + id, + podcast: { + id, + title, + description: "", + feedUrl, + coverUrl: `https://cover/${id}.jpg`, + lastUpdated: new Date(), + isSubscribed: true, + }, + episodes: [], + visibility: FeedVisibility.PUBLIC, + sourceId: "test", + lastUpdated: new Date(), + isPinned: false, + }; +} + +const episode = (podcastId: string, id = "ep"): Episode => ({ + id, + podcastId, + title: "Episode", + description: "", + audioUrl: "https://audio/ep.mp3", + duration: 60, + pubDate: new Date(), +}); + +describe("feedForEpisode", () => { + test("matches when episode.podcastId equals the feed's podcast.id", () => { + const f = makeFeed("id-a", "http://a/feed.xml"); + expect(feedForEpisode([f], episode("id-a"))?.podcast.id).toBe("id-a"); + }); + + test("matches an iTunes show by feed url (podcastId != podcast.id)", () => { + // The regression: feed.podcast.id is the directory id, podcastId the + // RSS url — a strict id match loses the feed. + const feedUrl = "http://itunes.example/feed.xml"; + const f = makeFeed("itunes-1177068388", feedUrl); + const got = feedForEpisode([f], episode(feedUrl)); + expect(got?.podcast.id).toBe("itunes-1177068388"); + }); + + test("falls back to episode membership when neither id nor feedUrl match", () => { + const f = makeFeed("id-b", "http://b/feed.xml"); + const ep = episode("unrelated", "ep-42"); + f.episodes = [ep]; + expect(feedForEpisode([f], ep)?.podcast.id).toBe("id-b"); + }); + + test("returns undefined when no feed matches", () => { + const f = makeFeed("id-c", "http://c/feed.xml"); + expect(feedForEpisode([f], episode("nowhere"))).toBeUndefined(); + }); +}); diff --git a/tests/visualizer-store.test.ts b/tests/visualizer-store.test.ts index 13a4536..e0183d0 100644 --- a/tests/visualizer-store.test.ts +++ b/tests/visualizer-store.test.ts @@ -342,6 +342,34 @@ test.skipIf(skip)( { timeout: 20000 }, ); +// The post-resume loading gate must be "the clock MOVED from the resume +// point", not "the clock moved PAST it". Gating on `>` strands the spinner +// forever when the user seeks BACKWARD during the resume spinner (the +// classic "missed that, rewind" while a network stream re-buffers): the +// position never again exceeds the resume point, the cache serves live +// frames for the new position, and the loading state never clears. +test.skipIf(skip)( + "backward seek during the resume loading state clears it", + async () => { + const viz = useVisualizer(); + await startPlaying(); + expect(viz.isLoading()).toBe(false); + + // Pause, then resume against the still-covered position: spinner. + setIsPlaying(false); + await waitFor(() => !viz.isRunning(), 10000); + setIsPlaying(true); + await waitFor(() => viz.isLoading(), 5000); + + // User seeks BACKWARD while the player re-buffers. The position is + // inside decoded coverage, so fresh bars must replace the spinner. + setPosition(1); + await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 3000); + expect(viz.barData().length).toBe(64); + }, + { timeout: 20000 }, +); + // ── Teardown ───────────────────────────────────────────────────────────── afterAll(() => {