feat(shell): now-playing marquee in status bar (podcast — episode)

The bottom-bar now-playing segment shows '<podcast> — <episode>' (custom name
when set, feed resolved like advanceEpisode), takes the full remaining status-bar
width, and marquee-scrolls on a 300ms timer when the text overflows instead of
truncating at 40 chars. Static when it fits; no interval runs on fit/narrow bars.
This commit is contained in:
2026-08-10 16:50:03 -04:00
parent 67032460ff
commit d2c46631ef

View File

@@ -11,8 +11,8 @@
* event bus. There is no sidebar pane. * event bus. There is no sidebar pane.
*/ */
import { createSignal, Show, For } from "solid-js"; import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
import { useKeyboard, useRenderer } from "@opentui/solid"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext"; import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext"; import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext"; import { useNavigation, NavMode } from "@/context/NavigationContext";
@@ -216,11 +216,18 @@ export function Shell() {
); );
// ── Status bar fragments ────────────────────────────────────────────────── // ── Status bar fragments ──────────────────────────────────────────────────
const nowPlaying = () => { // Now-playing text carries the podcast name (custom name when set) when
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
const nowPlayingText = () => {
const ep = audio.currentEpisode(); const ep = audio.currentEpisode();
if (!ep) return null; if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title; const feeds = feedStore.getFilteredFeeds();
return `${title}`; const feed =
feeds.find((f) => f.podcast.id === ep.podcastId) ??
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
return feed
? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`;
}; };
const modeLabel = () => const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`; nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
@@ -230,6 +237,45 @@ export function Shell() {
.map((s) => s.key) .map((s) => s.key)
.join(" "); .join(" ");
// ── Now-playing marquee ────────────────────────────────────────────────────
// The now-playing segment takes the full remaining status-bar width and
// marquee-scrolls on a 300ms timer when its text overflows; when it fits
// (or the bar is too narrow to show anything) it renders statically.
const dims = useTerminalDimensions();
const GAP = 3;
const [scrollOffset, setScrollOffset] = createSignal(0);
const leftFixed = () =>
modeLabel().length +
(nav.selectedIds().length > 0
? 4 + String(nav.selectedIds().length).length
: 0);
const rightFixed = () => k.pending().map((p) => p.key).join(" ").length + 3;
const availableWidth = () =>
Math.max(0, dims().width - leftFixed() - rightFixed() - 2);
const visible = () => {
const text = nowPlayingText();
const avail = availableWidth();
if (!text || avail <= 0) return "";
if (text.length <= avail) return text;
// Double the text with a gap so the wrap is seamless: the window
// slides over text + gap + text without ever hitting the tail.
return (text + " ".repeat(GAP) + text).slice(
scrollOffset(),
scrollOffset() + avail,
);
};
createEffect(() => {
const text = nowPlayingText();
const avail = availableWidth();
setScrollOffset(0);
if (!text || avail <= 0 || text.length <= avail) return;
const cycle = text.length + GAP - avail;
const id = setInterval(() => {
setScrollOffset((o) => (o + 1) % cycle);
}, 150);
onCleanup(() => clearInterval(id));
});
return ( return (
<box <box
flexDirection="column" flexDirection="column"
@@ -290,12 +336,14 @@ export function Shell() {
{nav.selectedIds().length} {nav.selectedIds().length}
</text> </text>
</Show> </Show>
<Show when={nowPlaying()}> <Show when={nowPlayingText()}>
<text fg={t.primary} paddingLeft={1}> <box flexGrow={1} paddingLeft={1}>
{nowPlaying()} {/* content prop (not a text child): the babel-preset-solid JSX
</text> * transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text fg={t.primary} content={visible()} />
</box>
</Show> </Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}> <text fg={t.textMuted} paddingRight={1}>
{pendingLabel()} {pendingLabel()}
</text> </text>