visualizer fixes
This commit is contained in:
@@ -22,6 +22,7 @@ import { useFeedStore } from "@/stores/feed";
|
|||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit, on } from "@/utils/event-bus";
|
import { emit, on } from "@/utils/event-bus";
|
||||||
|
import { feedForEpisode } from "@/utils/feed-resolve";
|
||||||
import { LayerGraph } from "@/utils/layer-graph";
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
@@ -222,9 +223,7 @@ export function Shell() {
|
|||||||
const ep = audio.currentEpisode();
|
const ep = audio.currentEpisode();
|
||||||
if (!ep) return null;
|
if (!ep) return null;
|
||||||
const feeds = feedStore.getFilteredFeeds();
|
const feeds = feedStore.getFilteredFeeds();
|
||||||
const feed =
|
const feed = feedForEpisode(feeds, ep);
|
||||||
feeds.find((f) => f.podcast.id === ep.podcastId) ??
|
|
||||||
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
|
|
||||||
return feed
|
return feed
|
||||||
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
||||||
: `♪ ${ep.title}`;
|
: `♪ ${ep.title}`;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
saveLastPlayerSync,
|
saveLastPlayerSync,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
import type { Episode, Progress } from "../types/episode";
|
import type { Episode, Progress } from "../types/episode";
|
||||||
|
import { feedForEpisode } from "../utils/feed-resolve";
|
||||||
import { useAudioNavStore } from "../stores/audio-nav";
|
import { useAudioNavStore } from "../stores/audio-nav";
|
||||||
import { useDownloadStore } from "../stores/download";
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
@@ -359,8 +360,7 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = storeSpeed || speed();
|
const spd = storeSpeed || speed();
|
||||||
|
|
||||||
const feedStore = useFeedStore();
|
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
// Play the downloaded file when present (offline + no network stalls);
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
// otherwise stream. Cover resolves to the feed art, falling back to the
|
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||||
@@ -468,8 +468,7 @@ async function load(episode: Episode): Promise<void> {
|
|||||||
setSpeed(storeSpeed || speed());
|
setSpeed(storeSpeed || speed());
|
||||||
|
|
||||||
// Surface the loaded-but-paused track to the OS media controls.
|
// Surface the loaded-but-paused track to the OS media controls.
|
||||||
const feedStore = useFeedStore();
|
const feed = feedForEpisode(useFeedStore().feeds(), episode);
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setNowPlaying({
|
media.setNowPlaying({
|
||||||
@@ -688,10 +687,7 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
// Resume playback if we were playing
|
// Resume playback if we were playing
|
||||||
if (wasPlaying && ep && ep.audioUrl) {
|
if (wasPlaying && ep && ep.audioUrl) {
|
||||||
try {
|
try {
|
||||||
const feedStore = useFeedStore();
|
const feed = feedForEpisode(useFeedStore().feeds(), ep);
|
||||||
const feed = feedStore
|
|
||||||
.feeds()
|
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const url =
|
const url =
|
||||||
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { onCleanup } from "solid-js";
|
||||||
|
import { setupTerminalRecovery } from "./utils/terminal-recovery";
|
||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
@@ -238,6 +240,7 @@ if (cliArgs.query !== null || cliArgs.play !== null) {
|
|||||||
function RendererSetup(props: { children: unknown }) {
|
function RendererSetup(props: { children: unknown }) {
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
renderer.disableStdoutInterception();
|
renderer.disableStdoutInterception();
|
||||||
|
onCleanup(setupTerminalRecovery(renderer));
|
||||||
return props.children;
|
return props.children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
let lastPosMoveAt = 0;
|
let lastPosMoveAt = 0;
|
||||||
|
|
||||||
// Resume point: the position a paused pipeline was re-armed at. The
|
// Resume point: the position a paused pipeline was re-armed at. The
|
||||||
// loading state set by resume only clears once the position clock has
|
// loading state set by resume clears once the position clock has MOVED
|
||||||
// advanced PAST this — while the player is still re-buffering, the
|
// from this (either direction) — while the player is still re-buffering
|
||||||
// cache can serve the same window forever and the stale pre-pause bars
|
// the clock is frozen, and the cache serving the same window must not
|
||||||
// must not masquerade as live data. -1 = cold start (clear on the
|
// let stale pre-pause bars masquerade as live data. -1 = cold start
|
||||||
// first produced frame, regardless of the clock).
|
// (clear on the first produced frame, regardless of the clock).
|
||||||
let resumePos = -1;
|
let resumePos = -1;
|
||||||
|
|
||||||
// What the running pipeline was started with — lets the playback effect
|
// 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
|
// Normalize against the running peak and copy to a new array
|
||||||
setBarData(scaler(output));
|
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
|
// the resume point: while the player is still re-buffering after a
|
||||||
// long pause, the cache serves the same window and the spinner must
|
// long pause, the cache serves the same window and the spinner must
|
||||||
// stay in place of the stale bars. Cold starts (resumePos < 0)
|
// stay in place of the stale bars. Any move counts — including a
|
||||||
// clear on the first frame as before.
|
// backward seek, whose window is live data for the new position and
|
||||||
if (isLoading() && (resumePos < 0 || rawPos > resumePos)) {
|
// 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);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -383,7 +383,13 @@ export class MpvBackend implements AudioBackend {
|
|||||||
this.proc = Bun.spawn(
|
this.proc = Bun.spawn(
|
||||||
[
|
[
|
||||||
"mpv",
|
"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",
|
"--no-terminal",
|
||||||
"--really-quiet",
|
"--really-quiet",
|
||||||
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
||||||
|
|||||||
22
src/utils/feed-resolve.ts
Normal file
22
src/utils/feed-resolve.ts
Normal file
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/utils/terminal-recovery.ts
Normal file
47
src/utils/terminal-recovery.ts
Normal file
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
72
tests/feed-for-episode.test.ts
Normal file
72
tests/feed-for-episode.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -342,6 +342,34 @@ test.skipIf(skip)(
|
|||||||
{ timeout: 20000 },
|
{ 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 ─────────────────────────────────────────────────────────────
|
// ── Teardown ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user