visualizer fixes

This commit is contained in:
2026-08-27 13:46:41 -04:00
parent 02c957f584
commit 895138357f
9 changed files with 196 additions and 21 deletions

View File

@@ -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}`;

View File

@@ -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<void> {
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<void> {
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<void> {
// 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;

View File

@@ -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;
}

View File

@@ -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);
}
};

View File

@@ -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

22
src/utils/feed-resolve.ts Normal file
View 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))
);
}

View 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);
};
}