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:
@@ -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 (`<` → `<`),
|
||||||
|
* 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user