20 Commits

Author SHA1 Message Date
6134dea044 feat(settings): configurable selection marker, default off
Adds a Selection Marker toggle under Settings → Preferences that
controls the ❯ cursor glyph on the focused row of every list.
Default off; rows keep a leading space for alignment either way.
Every list pane (tab strip, feed, my shows, discover, search,
settings, whitelist editor) reads the glyph through the shared
useSelectionMarker hook.

Also aligns the lead column across panes: page list rows drop
their extra box paddingLeft so labels start at the same column
as the tab strip (2 cells without nerd icons, 4 with), keeping
episode sub-rows aligned to the title.
2026-08-10 20:54:39 -04:00
93d5925dfd fix(waveform): add -readrate_initial_burst to eliminate audio lag
The decode head lagged the player by a constant ε (ffmpeg startup
latency) because ffmpeg paced at -readrate <speed> started behind mpv
and, advancing at the same rate, never caught up — bars were a few
seconds behind for the entire playback.

Add -readrate_initial_burst LEAD_SECONDS so ffmpeg emits 3s of audio
immediately on start, then paces at realtime after. The decode head
leads the player by a stable ~3s from the very first frame; read()
samples at the exact player position and always finds fresh samples.

Add a sustained render-loop test that simulates ~5s of real playback,
asserting ffmpeg stays alive, the decode head maintains a positive
lead, and read() returns full windows. Uses real wall-clock time
(documented exception) since ffmpeg's decode pacing can't be tested
deterministically.
2026-08-10 19:02:32 -04:00
de6d0ccbf6 feat(audio): podcast cover art in system Now Playing, CLI --play included
The cover was previously only wired through the UI hook; --play skipped it.
Also: Bun's fetch hangs in compiled binaries (Bun 1.3.8) — every cover
download silently failed in shipped builds, timing out against any host.
Cover staging now shells out to curl (present on macOS/Linux, 8s bound),
shared via src/utils/cover-art.ts so the UI and CLI paths stage the same
temp file and pass it to mpv as --cover-art-files (albumart track).
2026-08-10 18:18:58 -04:00
cda29bcb95 fix(macos): sign nested mpv with bundle identifier for Now Playing attribution
mediaremoted resolves the Now Playing client from the registering process's
code-signing identifier, not its bundle. codesign derives the identifier of
a plist-less executable from its basename, so the bundled mpv was stamped
'mpv' regardless of PodTui.app — the audio center kept showing a blank
placeholder. Signing mpv last with --identifier com.mikefreno.podtui (after
the bundle deep-seal, which would otherwise re-derive the basename) makes
the session register as PodTui: icon + name. Identity overridable via
PODTUI_CODESIGN_IDENTITY for Developer ID release builds.
2026-08-10 18:07:26 -04:00
3775a9801d icons 2026-08-10 18:04:09 -04:00
a9589e7686 feat(macos): ship PodTui.app bundle so Now Playing shows our icon
mpv owns the macOS Now Playing session (it plays the audio), and an
unbundled binary renders as a blank placeholder for the source-app icon.
macOS has no public API for a third party to claim session ownership
(MPNowPlayingSession is iOS-only; the private MRMediaRemoteSetNowPlayingApplication
was removed from the shared cache), so instead we make the OWNING process
carry our bundle: the darwin tarball now includes PodTui.app with mpv copied
into Contents/MacOS. AudioPlayer resolves the sibling mpv first (falling
back to PATH), LaunchServices attributes the process to com.mikefreno.podtui,
and Control Center shows the PodTui icon + name with podcast cover art.
AppIcon.icns generated from the Xcode icon-composer exports.
2026-08-10 17:56:54 -04:00
c52fa14e42 fix(player): correct click-to-seek by accounting for pane offset
MouseEvent coordinates are terminal-absolute, so the bar's seek handler
must subtract its own absolute left edge (renderable ref) instead of
treating the mouse x as bar-local. Clicking was off by the width of the
parent/Up pane (~20%) plus chrome. Also drop the empty played-text node
at position 0, which rendered a phantom space column and shifted the
drawn bar one char when playback started.
2026-08-10 17:53:46 -04:00
116d095ad5 feat(packaging): bundle app icon + Linux desktop entry; wire into AUR package
- release tarballs now ship podtui.png (assets/App Icon/App Icon.png, 512px)
  on every platform; Linux tarballs additionally carry podtui.desktop
  (Terminal=true so launchers open the TUI in a terminal)
- AUR PKGBUILD installs both: icon to hicolor 512x512, entry to applications/
- build.ts bundles the icon/desktop into dist before tarballing
2026-08-10 17:08:15 -04:00
b280af484c feat(ui): nerd font icons on hard-defined list rows, graceful degrade
Tabs, Discover categories (replacing unused placeholder glyphs), Settings
sections, and the Feed 'Fetch More' row get Nerd Font glyphs (Font Awesome PUA
codepoints). When the terminal font isn't Nerd Font capable the glyphs render
nothing at all — no tofu, no layout gaps — via supportsNerdFonts() (env
allowlist + PODTUI_NERD_FONTS=1/0 override). Documented in README (Configuration
-> Fonts).
2026-08-10 16:50:21 -04:00
5dce21c038 feat(media): expose podcast name + cover art to system Now Playing
- mpv: --force-media-title '<podcast> — <episode>' so macOS Now Playing shows
  the podcast name instead of the download-hash filename (mediaTitle PlayOption)
- media registry: artist is now the human podcast title (customName ||
  podcast.title), falling back to podcastId
- downloads: write the podcast cover as a <base>.jpg sibling so mpv's
  cover-art-auto=exact picks it up for artwork; delete it with the download
2026-08-10 16:50:08 -04:00
d2c46631ef 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.
2026-08-10 16:50:03 -04:00
67032460ff feat(player): click-to-seek progress bar; 2-row waveform bars with peak normalization
- ProgressBar: full-width played/remaining bar in the player pane, click-to-seek;
  waveform no longer handles seeking or the played/future color split (pure visual)
- bars: 2 terminal rows per bar (16 levels) via barChars, partial block in the
  top row so the column renders continuously
- fix bars maxing at audio start: disable cava autosens (silence gain-ramp),
  pre-warm the malloc'd FFT window with zeros, skip partial FFT windows
- createBarScaler peak follower + power curve replaces cava autosens for
  level-to-height mapping (src/utils/bar-mapping.ts, unit-tested)
2026-08-10 16:49:57 -04:00
0f3ffcf934 fix: whitelist editor keyboard/mouse navigation + highlight gating, My Shows w on focused show
Whitelist editor (Preferences > Auto Download Whitelist):
- input focus driven by nav.inputFocused() (SearchPage pattern) so Esc
  deterministically defocuses and j/k/Space/Enter browse the suggestions
- suggestions react to mouse: click focuses the row and toggles membership
- no accent bg / no marker on any suggestion while the input is focused
- s re-enters typing mode; suggestion rows scroll into view

Search page: recent-searches rows get no bg and no marker while the query
input is focused (previously the focused row fell into the inactive branch
and rendered the border color as a background).

My Shows: w toggles the focused show in/out of the auto-download whitelist
at the show list (depth 0), with a whitelist marker on the row and a hint in
the preview pane (scope: whitelist).
2026-08-10 16:40:00 -04:00
c813949d48 fix: remove fake RSS placeholder source; migrate persisted configs 2026-08-10 15:53:25 -04:00
eb220386ce feat: global auto-download with count/scope/whitelist + d/D/w keybinds 2026-08-10 15:53:25 -04:00
19eae4fd5a feat: sync waveform to live player position (mpv time-pos, position-window reads, smooth clock) 2026-08-10 15:51:02 -04:00
e70469b1ec fix: theme command palette input with accent 2026-08-10 15:50:51 -04:00
30b7ff57d3 refactor: seek via keybind router (< / >), free arrows for navigation 2026-08-10 15:50:51 -04:00
fd689aba04 fix: opentui subtree disposal on depth swap; theme inputs; recent-click 2026-08-10 15:46:53 -04:00
8eaca82ce9 chore: bump CI action versions; ignore notes.md 2026-08-10 15:46:48 -04:00
84 changed files with 2305 additions and 538 deletions

View File

@@ -37,7 +37,7 @@ jobs:
plat: darwin
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Bun
uses: oven-sh/setup-bun@v2
@@ -78,7 +78,7 @@ jobs:
./podtui-*/podtui --version
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
path: dist/podtui-*.tar.gz
@@ -89,12 +89,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download all binaries
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
- name: Publish release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
files: |

1
.gitignore vendored
View File

@@ -34,3 +34,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
.DS_Store
.harness/
.ralpi
notes.md

View File

@@ -54,6 +54,14 @@ Linux (arm64/x64). Pick whichever fits your platform.
brew install mikefreno/tap/podtui
```
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
through a copy of mpv that lives **inside the bundle**, so macOS attributes
the Now Playing session to PodTui — the Control Center / lock-screen entry
shows the PodTui name and icon, and podcast cover art as its artwork —
rather than a blank placeholder for an unbundled binary. Installers can drop
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
### 2. Standalone tarball (all platforms)
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
@@ -161,6 +169,9 @@ All keys are remappable — edit `keybinds.jsonc` in your config directory
| `.` | Toggle hidden |
| `r` | Refresh |
| `x` | Unsubscribe the focused show (My Shows) |
| `d` | Download the focused episode (Feed / My Shows detail pane) |
| `D` | Delete the focused episode's download (if one exists) |
| `w` | Toggle the focused show in/out of the auto-download whitelist (My Shows, whitelist scope) |
**Audio**
@@ -185,7 +196,16 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
into `config.json` on first run.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`.
**Auto-download** — in Settings → Preferences: `Auto Download` (master
toggle) downloads the `Auto Download Count` most recent episodes (default 2,
any positive integer — type it in the editor) of every show in the `Auto
Download Scope` (all / none / whitelist, default all). With the whitelist
scope, a search field appears under the setting to pick shows (Space toggles
a suggestion in/out), and `w` in My Shows adds/removes the focused show.
Env overrides: `PODTUI_AUDIO_BACKEND`, `XDG_CONFIG_HOME`, `PODTUI_NERD_FONTS`.
**Fonts** — PodTui prepends Nerd Font glyphs to non-episode/show list rows (tabs, Discover categories, Settings sections, the Feed "Fetch More" row). Icons are hidden automatically when your terminal font is not Nerd Font capable (no tofu, no layout gaps); detection is heuristic (terminal type), so force it with `PODTUI_NERD_FONTS=1` or `=0` if it guesses wrong. A Nerd Font-patched font (e.g. JetBrainsMono Nerd Font) is recommended.
## Troubleshooting

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 759 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1 @@
<svg width="400" xmlns="http://www.w3.org/2000/svg" height="125.714" id="screenshot-993fe4cb-279d-80e0-8008-769a7b1374b8" viewBox="0 0 400 125.714" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-769a7b1374b8" rx="0" ry="0"><g id="shape-993fe4cb-279d-80e0-8008-769a7a62c77d"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a62c77d"><rect rx="6.857142857142833" ry="6.857142857142833" x="0" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a89c956"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a89c956"><rect rx="15" ry="15" x="45.71428571428572" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7a9fe4f2"><rect rx="15" ry="15" x="91.42857142857144" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ab46172"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ab46172"><rect rx="15" ry="15" x="137.1428571428571" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ac82b71"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ac82b71"><rect rx="15" ry="15" x="182.85714285714283" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.285714285714334" height="125.71428571428578" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7ad6c7b7"><rect rx="15" ry="15" x="228.57142857142856" y="18.285714285714306" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="89.14285714285714" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7aea70cd"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7aea70cd"><rect rx="15" ry="15" x="274.28571428571433" y="33.14285714285711" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="59.428571428571445" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7af77904"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7af77904"><rect rx="15" ry="15" x="320" y="45.714285714285666" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="34.285714285714334" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g><g id="shape-993fe4cb-279d-80e0-8008-769a7b04705e"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-769a7b04705e"><rect rx="6.857142857142833" ry="6.857142857142833" x="365.7142857142858" y="56" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="34.28571428571428" height="13.714285714285666" style="fill: rgb(59, 66, 82); fill-opacity: 1;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1 @@
<svg width="80" xmlns="http://www.w3.org/2000/svg" height="256" id="screenshot-993fe4cb-279d-80e0-8008-76a1e45d35f1" viewBox="0 0 80 256" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1"><g id="shape-993fe4cb-279d-80e0-8008-76a1e45d35f1"><g class="fills" id="fills-993fe4cb-279d-80e0-8008-76a1e45d35f1"><rect rx="10" ry="10" x="0" y="0" transform="matrix(1.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000)" width="80" height="256" style="fill: rgb(136, 192, 208); fill-opacity: 1;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,51 @@
{
"fill" : {
"solid" : "display-p3:0.18481,0.20325,0.24683,1.00000"
},
"groups" : [
{
"layers" : [
{
"glass" : false,
"image-name" : "Podcast Waveform.svg",
"name" : "Podcast Waveform",
"position" : {
"scale" : 2,
"translation-in-points" : [
0,
0
]
}
},
{
"blend-mode" : "normal",
"fill" : "automatic",
"glass" : false,
"image-name" : "Terminal Cursor.svg",
"name" : "Terminal Cursor",
"position" : {
"scale" : 2,
"translation-in-points" : [
320.00000000000006,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

126
build.ts
View File

@@ -116,6 +116,132 @@ if (COMPILE) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(tarRoot, lib));
}
// App icon: bundled into every platform tarball; Linux also gets the
// desktop entry so the AUR package can install both system-wide
// (icon to hicolor, entry to applications/).
const iconSrc = join("assets", "App Icon", "App Icon.png");
if (existsSync(iconSrc)) {
copyFileSync(iconSrc, join(tarRoot, "podtui.png"));
}
if (platform === "linux") {
const desktopSrc = join("packaging", "podtui.desktop");
if (existsSync(desktopSrc)) {
copyFileSync(desktopSrc, join(tarRoot, "podtui.desktop"));
}
}
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
// Playing session to PodTui — the source-app icon + name in Control
// Center / lock screen — instead of a blank placeholder for an
// unbundled binary. AudioPlayer's resolver prefers this sibling.
if (platform === "darwin") {
const appRoot = join(tarRoot, "PodTui.app");
const macosDir = join(appRoot, "Contents", "MacOS");
const resDir = join(appRoot, "Contents", "Resources");
mkdirSync(macosDir, { recursive: true });
mkdirSync(resDir, { recursive: true });
copyFileSync(outfile, join(macosDir, "podtui"));
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
const s = join("dist", lib);
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
}
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
const mpvPath =
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
if (mpvPath) {
copyFileSync(mpvPath, join(macosDir, "mpv"));
} else {
console.warn(
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
);
}
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
if (existsSync(icnsSrc)) {
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
} else {
console.warn(
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
);
}
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
Bun.write(
join(appRoot, "Contents", "Info.plist"),
`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>PodTui</string>
<key>CFBundleDisplayName</key>
<string>PodTui</string>
<key>CFBundleIdentifier</key>
<string>com.mikefreno.podtui</string>
<key>CFBundleExecutable</key>
<string>podtui</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleShortVersionString</key>
<string>0.3.1</string>
<key>CFBundleVersion</key>
<string>0.3.1</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
</dict>
</plist>
`,
);
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
// ID cert for release builds); default ad-hoc.
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
const sign = Bun.spawnSync([
"codesign",
"--force",
"--deep",
"-s",
signIdentity,
appRoot,
]);
if (sign.exitCode !== 0) {
console.warn(
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
);
}
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
// resolves the Now Playing client from the registering process's
// code-signing identifier — without an explicit --identifier codesign
// stamps "mpv" (its basename) and the audio center shows a blank
// placeholder. Must run after the bundle sign above (a later bundle
// re-seal would re-derive the basename identifier).
const signMpv = Bun.spawnSync([
"codesign",
"--force",
"-s",
signIdentity,
"--identifier",
"com.mikefreno.podtui",
join(macosDir, "mpv"),
]);
if (signMpv.exitCode !== 0) {
console.warn(
`Warning: nested mpv signing failed (${signMpv.stderr
.toString()
.trim()}) — Now Playing attribution won't work`,
);
}
console.log(`App bundle: ${appRoot}`);
}
const tar = Bun.spawnSync([
"tar",
"-czf",

View File

@@ -51,5 +51,13 @@ package() {
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
# App icon + desktop entry ship inside the release tarball; Terminal=true
# makes launchers drop the TUI into a terminal window.
install -Dm644 "${srcdir}/${libdir}/podtui.png" \
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/podtui.png"
install -Dm644 "${srcdir}/${libdir}/podtui.desktop" \
"${pkgdir}/usr/share/applications/podtui.desktop"
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

11
packaging/podtui.desktop Normal file
View File

@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=PodTui
GenericName=Podcast Player
Comment=Terminal podcast and audio player with waveform visualization
Exec=podtui
Icon=podtui
Terminal=true
Categories=Audio;AudioVideo;Player;
Keywords=podcast;audio;player;terminal;
StartupNotify=false

View File

@@ -18,22 +18,21 @@ const DEBUG = import.meta.env.DEBUG;
export function App() {
const nav = useNavigation();
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const themeContext = useTheme();
const theme = themeContext.theme;
const keybind = useKeybinds();
// Multimedia keys (physical play/seek keys) still feed the audio backend
// regardless of the on-screen yazi keybinds.
// Multimedia keys (physical play/volume/speed keys) still feed the audio
// backend regardless of the on-screen yazi keybinds. Seek lives on the
// keybind router (< / > = shift+, / shift+.), so arrows stay on navigation.
useMultimediaKeys({
playerFocused: () =>
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
? true
: false,
inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
// Mouse text-selection → clipboard (unchanged from the old shell).

View File

@@ -11,8 +11,8 @@
* event bus. There is no sidebar pane.
*/
import { createSignal, Show, For } from "solid-js";
import { useKeyboard, useRenderer } from "@opentui/solid";
import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
import { useTheme } from "@/context/ThemeContext";
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
import { useNavigation, NavMode } from "@/context/NavigationContext";
@@ -216,11 +216,18 @@ export function Shell() {
);
// ── 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();
if (!ep) return null;
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
return `${title}`;
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));
return feed
? `${feed.customName || feed.podcast.title}${ep.title}`
: `${ep.title}`;
};
const modeLabel = () =>
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
@@ -230,6 +237,45 @@ export function Shell() {
.map((s) => s.key)
.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 (
<box
flexDirection="column"
@@ -290,12 +336,14 @@ export function Shell() {
{nav.selectedIds().length}
</text>
</Show>
<Show when={nowPlaying()}>
<text fg={t.primary} paddingLeft={1}>
{nowPlaying()}
</text>
<Show when={nowPlayingText()}>
<box flexGrow={1} paddingLeft={1}>
{/* content prop (not a text child): the babel-preset-solid JSX
* transform HTML-escapes static string children (`<` → `&lt;`),
* which opentui renders verbatim; content bypasses that. */}
<text fg={t.primary} content={visible()} />
</box>
</Show>
<box flexGrow={1} />
<text fg={t.textMuted} paddingRight={1}>
{pendingLabel()}
</text>

View File

@@ -19,7 +19,9 @@ import { For } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import { TABS } from "@/utils/navigation";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
const TAB_LABEL: Record<TABS, string> = {
[TABS.FEED]: "Feed",
@@ -30,14 +32,27 @@ const TAB_LABEL: Record<TABS, string> = {
[TABS.SETTINGS]: "Settings",
};
/** Nerd Font glyph per tab (rendered only when the terminal supports them). */
const TAB_ICON: Record<TABS, string> = {
[TABS.FEED]: NF_ICONS.feed,
[TABS.MYSHOWS]: NF_ICONS.shows,
[TABS.DISCOVER]: NF_ICONS.discover,
[TABS.SEARCH]: NF_ICONS.search,
[TABS.PLAYER]: NF_ICONS.player,
[TABS.SETTINGS]: NF_ICONS.settings,
};
/** Numeric TABS values, in declaration order (1..TabsCount). */
const TAB_ORDER = Object.values(TABS).filter(
(v): v is TABS => typeof v === "number",
) as TABS[];
export function TabListPane(props: { muted?: boolean }) {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const { theme } = useTheme();
const nav = useNavigation();
const marker = useSelectionMarker();
const cursor = () => nav.tabCursor();
const activeTab = () => nav.activeTab();
@@ -91,7 +106,12 @@ export function TabListPane(props: { muted?: boolean }) {
}}
>
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
<text fg={focusFg(tab)}>{isCursor() ? "" : " "}</text>
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
{nerd && (
<text fg={focusFg(tab)} paddingRight={1}>
{TAB_ICON[tab]}
</text>
)}
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
<text fg={labelFg()} paddingLeft={1}>
{TAB_LABEL[tab]}

View File

@@ -66,12 +66,17 @@
"refresh": ["r"],
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
// ── Downloads & auto-download whitelist ───────────────────────────────────
"download": ["d"], // download the focused episode (detail pane)
"delete-download": ["D"], // delete the focused episode's download (if any)
"whitelist-toggle": ["w"], // add/remove the focused show from the auto-download whitelist (My Shows)
// ── Audio transport (preserved) ──────────────────────────────────────────
// Kept on shifted single keys so they never collide with the yazi core
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
"audio-toggle": ["P"], // play / pause (shift+p)
"audio-next": ["N"], // next episode (shift+n)
"audio-prev": ["B"], // prev episode (shift+b)
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
"audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
"audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
}

View File

@@ -68,6 +68,9 @@ export type KeybindActionName =
| "toggle-hidden"
| "refresh"
| "unsubscribe"
| "download"
| "delete-download"
| "whitelist-toggle"
| "audio-toggle"
| "audio-next"
| "audio-prev"

View File

@@ -13,6 +13,8 @@
*/
import { createSignal, onCleanup } from "solid-js";
import { unlinkSync } from "fs";
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
import {
createAudioBackend,
detectPlayers,
@@ -109,6 +111,11 @@ function registerExitTeardown(): void {
} catch {
/* best-effort at exit */
}
try {
unlinkSync(coverTempPath());
} catch {
/* best-effort at exit */
}
};
process.on("exit", teardown);
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
@@ -122,17 +129,21 @@ function registerExitTeardown(): void {
function startPolling(): void {
stopPolling();
pollCount = 0;
// Guard against overlapping ticks if a socket read ever outlives the
// interval (getPosition opens a fresh mpv IPC connection per call).
let pollInFlight = false;
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return;
if (!backend || !isPlaying() || pollInFlight) return;
pollInFlight = true;
try {
const pos = await backend.getPosition();
const dur = await backend.getDuration();
setPosition(pos);
if (dur > 0) setDuration(dur);
// Save progress every ~5 seconds (10 ticks * 500ms)
// Save progress every ~5 seconds (33 ticks * 150ms)
pollCount++;
if (pollCount % 10 === 0) {
if (pollCount % 33 === 0) {
const ep = currentEpisode();
if (ep) {
const progressStore = useProgressStore();
@@ -156,8 +167,10 @@ function startPolling(): void {
}
} catch {
// Backend may have been disposed
} finally {
pollInFlight = false;
}
}, 500);
}, 150);
}
function stopPolling(): void {
@@ -167,6 +180,11 @@ function stopPolling(): void {
}
}
// ── Cover art for system Now Playing ─────────────────────────────────────────
// macOS shows the media session's albumart in the audio center; mpv reads it
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
async function play(episode: Episode): Promise<void> {
const b = ensureBackend();
setError(null);
@@ -183,6 +201,13 @@ 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 podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id);
let startPos = 0;
@@ -194,6 +219,8 @@ async function play(episode: Episode): Promise<void> {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
mediaTitle: podcastTitle ? `${podcastTitle}${episode.title}` : episode.title,
coverArtPath: coverArtPath ?? undefined,
});
setCurrentEpisode(episode);
@@ -206,7 +233,7 @@ async function play(episode: Episode): Promise<void> {
const media = useMediaRegistry();
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
artist: podcastTitle || episode.podcastId,
duration: episode.duration,
});
media.setPlaybackState(true);
@@ -357,10 +384,22 @@ 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 podcastTitle = feed?.customName || feed?.podcast.title || "";
const coverArtPath = feed?.podcast.coverUrl
? await fetchCoverArt(feed.podcast.coverUrl)
: null;
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
mediaTitle: podcastTitle
? `${podcastTitle}${ep.title}`
: ep.title,
coverArtPath: coverArtPath ?? undefined,
});
setIsPlaying(true);
startPolling();
@@ -421,14 +460,6 @@ export function useAudio(): AudioControls {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
});
const unsubMediaSeekFwd = on("media.seekForward", async () => {
await seekRelative(10);
});
const unsubMediaSeekBack = on("media.seekBackward", async () => {
await seekRelative(-10);
});
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
await doSetSpeed(next);
@@ -515,8 +546,6 @@ export function useAudio(): AudioControls {
unsubMediaToggle();
unsubMediaVolUp();
unsubMediaVolDown();
unsubMediaSeekFwd();
unsubMediaSeekBack();
unsubMediaSpeed();
if (refCount <= 0) {

View File

@@ -1,13 +1,14 @@
/**
* Global multimedia key handler hook.
*
* Captures media-related key events (play/pause, volume, seek, speed)
* Captures media-related key events (play/pause, volume, speed)
* regardless of which component is focused. Uses the event bus to
* decouple key detection from audio control logic.
*
* Volume and speed are app-level settings — adjustable with or without
* an episode loaded (they apply to the next playback and persist). Seek
* is playback-dependent, so it still requires a loaded episode.
* is NOT handled here: it lives on the yazi keybind router (`<` / `>` =
* shift+, / shift+.), so the arrow keys stay free for navigation.
*/
import { useKeyboard } from "@opentui/solid";
@@ -17,8 +18,6 @@ export type MediaKeyAction =
| "media.toggle"
| "media.volumeUp"
| "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle";
export interface MultimediaKeysOptions {
@@ -26,8 +25,6 @@ export interface MultimediaKeysOptions {
playerFocused?: () => boolean;
/** When true, skip handling (text input has focus) */
inputFocused?: () => boolean;
/** Whether an episode is currently loaded */
hasEpisode?: () => boolean;
}
/**
@@ -59,16 +56,6 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
emit("media.volumeDown", {});
break;
case "left":
if (!options.hasEpisode?.()) return;
emit("media.seekBackward", {});
break;
case "right":
if (!options.hasEpisode?.()) return;
emit("media.seekForward", {});
break;
case "s":
emit("media.speedCycle", {});
break;

View File

@@ -0,0 +1,16 @@
/**
* useSelectionMarker — reactive accessor for the row-selection marker glyph.
*
* When the `showSelectionMarker` setting is on, the focused row of every list
* renders ``; when off (the default), it renders a space so column alignment
* is preserved. Every list pane in the app reads the marker through this hook
* so the setting applies consistently everywhere.
*/
import { useAppStore } from "@/stores/app";
export function useSelectionMarker(): () => string {
const app = useAppStore();
return () =>
app.state().settings.showSelectionMarker ? "" : " ";
}

View File

@@ -182,9 +182,18 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
try {
const { createAudioBackend } = await import("./utils/audio-player")
const { fetchCoverArt } = await import("./utils/cover-art")
const backend = createAudioBackend()
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl)
// Stage the podcast cover so the system Now Playing shows
// artwork (mpv --cover-art-files), like the UI path does.
const coverArtPath = feedResult.podcast.coverUrl
? await fetchCoverArt(feedResult.podcast.coverUrl)
: null
await backend.play(episodeResult.audioUrl, {
mediaTitle: `${feedResult.podcast.title}${episodeResult.title}`,
coverArtPath: coverArtPath ?? undefined,
})
console.log("Playback started (use the UI to control)")
} else {
console.log("No audio URL available for this episode")

View File

@@ -27,19 +27,24 @@ import {
type DepthFrame,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import { supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const DiscoverPaneCount = 1;
function DiscoverPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const discoverStore = useDiscoverStore();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const depth = nav.currentDepth;
const focus = (d: number = depth()) => nav.depthFocus(d);
@@ -159,34 +164,46 @@ function DiscoverPage() {
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
// Stable <Show> gate (not a ternary root swap) so the parent list
// mounts/unmounts cleanly on depth change.
// Sibling <Show> blocks per depth (the known-good opentui disposal
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? "" : " "}
</text>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
<>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<For each={categories()}>
{(cat, index) => {
const lf = () => nav.depthFocus(0);
const ref = useScrollIntoView(() => index() === lf());
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{index() === nav.depthFocus(0) ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
{cat.name}
</text>
</box>
);
}}
</For>
</Show>
</>
);
// ── current pane ───────────────────────────────────────────────────────────
@@ -203,7 +220,6 @@ function DiscoverPage() {
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -213,8 +229,13 @@ function DiscoverPage() {
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(index(), lf(), isActive())}>
{cat.icon}
</text>
)}
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
</box>
);
@@ -247,7 +268,6 @@ function DiscoverPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -257,7 +277,7 @@ function DiscoverPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{podcast.title}

View File

@@ -32,6 +32,7 @@ import {
} from "@/context/NavigationContext";
import { useAudio } from "@/hooks/useAudio";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
@@ -39,12 +40,15 @@ import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const FeedPaneCount = 1;
type EpItem = { episode: Episode; feed: Feed };
function FeedPage() {
// Static: detection never changes mid-session.
const nerd = supportsNerdFonts();
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
@@ -52,6 +56,7 @@ function FeedPage() {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
const episodes = createMemo<EpItem[]>(
@@ -171,6 +176,18 @@ function FeedPage() {
const item = focusedItem();
if (item) nav.toggleSelected(item.episode.id);
},
download: () => {
const item = focusedItem();
if (item) downloadStore.startDownload(item.episode, item.feed.id);
},
"delete-download": () => {
const item = focusedItem();
if (!item) return;
const id = item.episode.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
refresh: () => {
feedStore.refreshAllFeeds().catch(() => {});
},
@@ -242,7 +259,6 @@ function FeedPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -252,7 +268,7 @@ function FeedPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{item.episode.episodeNumber
@@ -289,7 +305,6 @@ function FeedPage() {
ref={moreRef}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
onMouseDown={() => {
@@ -298,8 +313,13 @@ function FeedPage() {
}}
>
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{focusedOnMore() ? "" : " "}
{focusedOnMore() ? marker() : " "}
</text>
{nerd && (
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
{NF_ICONS.more}
</text>
)}
<Show
when={!feedStore.isLoadingMore()}
fallback={<LoadingIndicator label="Fetching…" />}
@@ -378,7 +398,14 @@ function FeedPage() {
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h back</text>
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(item().episode.id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}{" "}
· space: select · h back
</text>
</box>
)}
</Show>

View File

@@ -14,6 +14,7 @@
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { useAppStore } from "@/stores/app";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
@@ -34,17 +35,20 @@ import { LoadingIndicator } from "@/components/LoadingIndicator";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const MyShowsPaneCount = 1;
export function MyShowsPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const app = useAppStore();
const audioNav = useAudioNavStore();
const audio = useAudio();
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
@@ -161,6 +165,33 @@ export function MyShowsPage() {
if (ep) nav.toggleSelected(ep.id);
}
},
download: () => {
if (depth() < 1) return;
const ep = focusedEpisode();
if (ep) downloadStore.startDownload(ep, drilledShowId());
},
"delete-download": () => {
if (depth() < 1) return;
const ep = focusedEpisode();
if (!ep) return;
const id = ep.id;
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
downloadStore.cancelDownload(id);
downloadStore.removeDownload(id).catch(() => {});
},
"whitelist-toggle": () => {
const prefs = app.state().preferences;
if (prefs.autoDownloadScope !== "whitelist") return;
// depth 0: the focused show; depth ≥1: the drilled show.
const id = depth() >= 1 ? drilledShowId() : selectedShow()?.id;
if (!id) return;
const cur = prefs.autoDownloadWhitelist ?? [];
const next = cur.includes(id)
? cur.filter((x) => x !== id)
: [...cur, id];
app.updatePreferences({ autoDownloadWhitelist: next });
feedStore.runAutoDownload();
},
refresh: () => {
const show = selectedShow();
if (show) feedStore.refreshFeed(show.id).catch(() => {});
@@ -225,12 +256,11 @@ export function MyShowsPage() {
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), false)}
>
<text fg={focusFg(index(), lf(), false)}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
<text fg={muted()}>({feed.episodes.length})</text>
@@ -260,12 +290,16 @@ export function MyShowsPage() {
{(feed, index) => {
const lf = () => focusedShowIdx();
const ref = useScrollIntoView(() => index() === lf());
const wlScope =
app.state().preferences.autoDownloadScope === "whitelist";
const wlInList = (
app.state().preferences.autoDownloadWhitelist ?? []
).includes(feed.id);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -274,7 +308,7 @@ export function MyShowsPage() {
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{showTitle(feed)}
@@ -282,6 +316,19 @@ export function MyShowsPage() {
<text fg={index() === lf() ? theme.surface : muted()}>
({feed.episodes.length})
</text>
<Show when={wlScope}>
<text
fg={
index() === lf()
? theme.surface
: wlInList
? theme.warning
: muted()
}
>
{wlInList ? "●" : "○"}
</text>
</Show>
</box>
);
}}
@@ -307,7 +354,6 @@ export function MyShowsPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
onMouseDown={() => {
@@ -317,7 +363,7 @@ export function MyShowsPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
{index() === lf() ? marker() : " "}
</text>
<text fg={focusFg(index(), lf(), isActive())}>
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
@@ -381,7 +427,16 @@ export function MyShowsPage() {
{show().podcast.description?.slice(0, 400) ?? "No description."}
</text>
<box height={1} />
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
<text fg={muted()}>
enter/l: open · h: back · x: unsubscribe
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ??
[]
).includes(show().id)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}
</text>
</box>
)}
</Show>
@@ -421,7 +476,21 @@ export function MyShowsPage() {
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
</text>
<box height={1} />
<text fg={muted()}>enter: play · space: select · h: back</text>
<text fg={muted()}>
enter: play · d: download
{downloadStore.getDownloadStatus(ep().id) !==
DownloadStatus.NONE
? " · D: delete"
: ""}
{app.state().preferences.autoDownloadScope === "whitelist"
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
drilledShowId(),
)
? " · w: un-whitelist"
: " · w: whitelist"
: ""}{" "}
· space: select · h: back
</text>
</box>
)}
</Show>

View File

@@ -12,6 +12,7 @@
import { Show } from "solid-js";
import { PlaybackControls } from "./PlaybackControls";
import { ProgressBar } from "./ProgressBar";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
@@ -79,6 +80,8 @@ export function PlayerPage() {
{ep().description?.slice(0, 500) ?? "No description available."}
</text>
<ProgressBar />
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
@@ -110,7 +113,7 @@ export function PlayerPage() {
<box height={1} />
<text fg={muted()}>
{"P play/pause N next B prev ◀▶ seek h back"}
{"P play/pause N next B prev < > seek h back"}
</text>
</box>
);

View File

@@ -0,0 +1,68 @@
/**
* ProgressBar — one-row, click-to-seek playback progress bar for the
* player pane. Played portion renders as full blocks (█) in the theme's
* primary color, the remainder as light shade blocks (░) in the muted
* color. The header time/percent text lives in PlayerPage — this is only
* the bar itself.
*/
import { useTerminalDimensions } from "@opentui/solid";
import type { Renderable } from "@opentui/core";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
// ── Component ────────────────────────────────────────────────────────
export function ProgressBar() {
const audio = useAudio();
const { theme } = useTheme();
const dimensions = useTerminalDimensions();
// The bar's renderable, captured for its absolute left edge: MouseEvent.x
// is terminal-absolute (not bar-relative), so local x needs the offset
// of the bar inside the 2-pane row (parent pane ≈ 20% of the width).
let bar: Renderable | undefined;
// Full content width of the player pane: the player is a 2-pane row
// (parent 1/5 + current 4/5 of the terminal width). Subtract ~8 chars
// of border/padding chrome (same math as RealtimeWaveform's numBars).
const width = () => Math.max(8, Math.floor((dimensions().width * 4) / 5) - 8);
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
const playedChars = () => {
const duration = audio.duration();
if (duration <= 0) return 0;
return Math.round(clamp01(audio.position() / duration) * width());
};
const remainingColor = theme.muted || theme.text;
return (
<box
border
borderColor={theme.border}
padding={0}
flexDirection="row"
gap={0}
ref={(el) => {
bar = el;
}}
onMouseDown={(e: { x: number }) => {
const duration = audio.duration();
if (duration <= 0 || !bar) return;
// localX = 0 is the box border; content starts at localX = 1.
const localX = e.x - bar.x;
const ratio = Math.max(0, Math.min(1, (localX - 1) / width()));
void audio.seek(ratio * duration);
}}
>
{playedChars() > 0 && (
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
)}
<text fg={remainingColor}>
{"\u2591".repeat(width() - playedChars())}
</text>
</box>
);
}

View File

@@ -15,6 +15,7 @@ import {
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
import { PANE_RATIO } from "@/utils/navigation";
@@ -25,19 +26,6 @@ export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
@@ -53,6 +41,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
// frame against the running peak so a loud start can't pin every bar
// at full height and quiet content still gets normalized up.
const scaler = createBarScaler();
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
@@ -88,6 +81,29 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
return true;
};
// ── Smooth position clock ──────────────────────────────────────────
//
// audio.position() updates at the useAudio poll rate (~150ms). Between
// polls, interpolate the position from wall time so the FFT window
// tracks the audio continuously instead of stepping. The 0.5s cap
// prevents extrapolating far beyond reality when the player stalls
// (e.g. network re-buffering).
let lastPolledPosition = 0;
let lastPolledAt = 0;
const smoothPosition = () => {
const pos = audio.position();
const now = performance.now();
if (pos !== lastPolledPosition) {
lastPolledPosition = pos;
lastPolledAt = now;
return pos;
}
if (lastPolledAt === 0) return pos;
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
@@ -98,14 +114,26 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// Initialize cavacore with current resolution + any overrides.
// bars is width-derived (see numBars); visualizerConfig supplies the
// audio-processing params (noise reduction, cutoffs, etc.).
// autosens is disabled (after the spread so it always wins): cava's
// autosens gain-ramps during silence then clips everything to 1.0
// when audio arrives — the JS peak scaler handles dynamics instead.
const config: CavaCoreConfig = {
bars: numBars(),
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
autosens: 0,
};
cava.init(config);
// Pre-warm the FFT window: libcavacore's window is malloc'd
// uninitialized, so the first real frame would FFT garbage and
// render full-scale bars. One zero frame the size of the whole
// input buffer clears it (at 44.1kHz mono the window is 8192
// samples — FFTbassbufferSize × channels; a 512-sample frame would
// leave the tail garbage).
cava.execute(new Float64Array(8192));
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
@@ -139,17 +167,19 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Sample the FFT window at the player's position, not the decode
// head — the reader decodes independently (paced at the player's
// clock rate with a LEAD_SECONDS burst head start) and only the
// position clock ties the bars to what's actually playing.
const target = smoothPosition();
const count = reader.read(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
const output = cava.execute(sampleBuffer);
// Copy bar values to a new array for the signal
setBarData(Array.from(output as Float64Array));
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
};
createEffect(
@@ -209,11 +239,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => {
const bars = barData();
const count = numBars();
@@ -221,51 +246,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
if (bars.length === 0) {
const placeholder = ".".repeat(count);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
<box flexDirection="column" gap={0}>
<text fg={theme.primary}>{placeholder}</text>
<text fg={theme.primary}>{placeholder}</text>
</box>
);
}
const played = Math.floor(count * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = bars
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
const top = pairs.map((pair) => pair.top).join("");
const bottom = pairs.map((pair) => pair.bottom).join("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
<box flexDirection="column" gap={0}>
<text fg={theme.primary}>{top}</text>
<text fg={theme.primary}>{bottom}</text>
</box>
);
};
const handleClick = (event: { x: number }) => {
const count = numBars();
const ratio = event.x / count;
const next = Math.max(
0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
);
audio.seek(next);
};
return (
<box
border
borderColor={theme.border}
padding={1}
onMouseDown={handleClick}
>
<box border borderColor={theme.border} padding={1}>
{renderLine()}
</box>
);

View File

@@ -42,6 +42,7 @@ import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const SearchPaneCount = 1;
@@ -52,6 +53,7 @@ function SearchPage() {
const { theme } = useTheme();
const muted = () => theme.muted || theme.text;
const nav = useNavigation();
const marker = useSelectionMarker();
const stack = nav.depthStack;
const depth = nav.currentDepth;
@@ -74,12 +76,16 @@ function SearchPage() {
// j/k (yielding to a non-existent input) and only the scrollbox's native
// scroll responds.
//
// The effect only re-runs on a depth transition, so Escape (defocus) and
// `s` (refocus) at the same depth are not clobbered.
// The depth STACK signal is also written by focus moves (setDepthFocus),
// so gate the sync on the depth VALUE via a memo: the effect must re-run
// only on an actual depth transition. Without the memo every j/k at the
// query depth re-focuses the input (undoing Escape), which keeps the
// recents list unreachable by keyboard.
onMount(() => nav.setInputFocused(depth() === 0));
onCleanup(() => nav.setInputFocused(false));
const isQueryDepth = createMemo(() => depth() === 0);
createEffect(() => {
nav.setInputFocused(depth() === 0);
nav.setInputFocused(isQueryDepth());
});
// ── results (depth 1) ─────────────────────────────────────────────────────
@@ -213,15 +219,25 @@ function SearchPage() {
: theme.text;
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
// Sibling <Show> blocks per depth (the known-good opentui disposal
// pattern, mirrors Settings): a STABLE fragment root whose inner <Show>
// children toggle on depth change, so the old subtree is disposed instead
// of left orphaned next to the new one (single <Show with fallback> and
// ternary root swaps both leak the previous root).
const parentContent = () => (
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={muted()}>h: back to query</text>
</box>
</Show>
<>
<Show when={depth() === 0}>
<TabListPane muted />
</Show>
<Show when={depth() >= 1}>
<box flexDirection="column" gap={1} padding={1}>
<text fg={theme.textSecondary}>Query</text>
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
<box height={1} />
<text fg={muted()}>h: back to query</text>
</box>
</Show>
</>
);
// ── current pane ────────────────────────────────────────────────────────────
@@ -239,6 +255,9 @@ function SearchPage() {
placeholder="Enter podcast name..."
focused={inputActive()}
width={28}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<Show when={searchStore.isSearching()}>
@@ -263,23 +282,47 @@ function SearchPage() {
{(query, index) => {
const lf = () => focus(0);
const ref = useScrollIntoView(() => index() === lf());
// While the input is focused (typing), the list is not
// in focus: no bg, no accent fg, no `` on any entry.
const typing = () => inputActive();
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), lf(), isActive())}
backgroundColor={
typing()
? undefined
: focusBg(index(), lf(), isActive())
}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
nav.setDepthFocus(index(), 0);
// A recent is an action, not an item: clicking
// it re-runs that search (focus-only would be
// invisible — the input still owns the keys).
selectRecent(query);
}}
>
<text fg={focusFg(index(), lf(), isActive())}>
{index() === lf() ? "" : " "}
<text
fg={
typing()
? theme.text
: focusFg(index(), lf(), isActive())
}
>
{index() === lf() && !typing() ? marker() : " "}
</text>
<text
fg={
typing()
? theme.text
: focusFg(index(), lf(), isActive())
}
>
{query}
</text>
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
</box>
);
}}
@@ -323,7 +366,6 @@ function SearchPage() {
ref={ref}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={focusBg(index(), fi(), isActive())}
onMouseDown={() => {
@@ -333,7 +375,7 @@ function SearchPage() {
>
<box flexDirection="row" gap={1}>
<text fg={focusFg(index(), fi(), isActive())}>
{index() === fi() ? "" : " "}
{index() === fi() ? marker() : " "}
</text>
<text fg={focusFg(index(), fi(), isActive())}>
{result.podcast.title}

View File

@@ -32,6 +32,9 @@ export function ExportDialog() {
value={filename[0]()}
onInput={filename[1]}
style={{ width: 30 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
</box>
<box style={{ flexDirection: "row", gap: 1 }}>

View File

@@ -21,6 +21,9 @@ export function FilePicker(props: FilePickerProps) {
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
textColor={theme.text}
focusedTextColor={theme.accent}
cursorColor={theme.accent}
/>
<text fg={theme.text}>Format: {format}</text>
</box>

View File

@@ -2,10 +2,36 @@
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
* driven by the Shell router via nav.action.
*
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
* • Auto Download — master toggle (default: off)
* • Auto Download Count — X most recent episodes per show (default: 2,
* any positive integer — type it in the editor)
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
* • Auto Download Whitelist — shown only when scope is "whitelist": search
* field over subscribed shows; suggestions toggle
* in/out with Space (j/k to move, Esc to browse).
*/
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
import { RenderableEvents, type InputRenderable } from "@opentui/core";
import { useAppStore } from "@/stores/app";
import type { ThemeName } from "@/types/settings";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
import {
NavMode,
useNavigation,
DEPTH_CENTER_PANE,
type PaneId,
} from "@/context/NavigationContext";
import { on } from "@/utils/event-bus";
import type { KeybindActionName } from "@/context/KeybindContext";
import { TABS } from "@/utils/navigation";
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
import type { Feed } from "@/types/feed";
import type { SettingItem } from "./types";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
@@ -17,13 +43,24 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "custom", label: "Custom" },
];
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
{ value: "all", label: "All" },
{ value: "none", label: "None" },
{ value: "whitelist", label: "Whitelist" },
];
function scopeLabel(scope: AutoDownloadScope): string {
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
}
export function usePreferencesItems(): SettingItem[] {
const app = useAppStore();
const feedStore = useFeedStore();
const settings = () => app.state().settings;
const prefs = () => app.state().preferences;
return [
const items: SettingItem[] = [
{
id: "theme",
label: "Theme",
@@ -52,6 +89,18 @@ export function usePreferencesItems(): SettingItem[] {
transparentBackground: !settings().transparentBackground,
}),
},
{
id: "showSelectionMarker",
label: "Selection Marker",
kind: "toggle",
display: () => (settings().showSelectionMarker ? "On" : "Off"),
help: () =>
`Show the marker on the focused row of every list (tabs, shows, episodes, results).\nType: toggle\nDefault: off\nCurrent: ${settings().showSelectionMarker ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () =>
app.updateSettings({
showSelectionMarker: !settings().showSelectionMarker,
}),
},
{
id: "fontSize",
label: "Font Size",
@@ -97,11 +146,52 @@ export function usePreferencesItems(): SettingItem[] {
kind: "toggle",
display: () => (prefs().autoDownload ? "On" : "Off"),
help: () =>
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
toggle: () =>
app.updatePreferences({
autoDownload: !prefs().autoDownload,
}),
`Download the ${prefs().autoDownloadCount} most recent episodes of your shows automatically (see Count/Scope below).\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload ? "On" : "Off"}\nSpace/Enter to toggle.`,
toggle: () => {
app.updatePreferences({ autoDownload: !prefs().autoDownload });
feedStore.runAutoDownload();
},
},
{
id: "autoDownloadCount",
label: "Auto Download Count",
kind: "number",
display: () => `${prefs().autoDownloadCount} per show`,
help: () =>
`How many of the most recent episodes to auto-download per in-scope show.\nType: number (any positive integer)\nDefault: 2\nCurrent: ${prefs().autoDownloadCount}\nj/k to /+1 · Enter to type a value.`,
cycle: (dir) => {
const next = Math.max(1, prefs().autoDownloadCount + dir);
app.updatePreferences({ autoDownloadCount: next });
feedStore.runAutoDownload();
},
renderEditor: () => (
<NumberInputEditor
label="Auto Download Count"
value={() => prefs().autoDownloadCount}
commit={(n) => {
app.updatePreferences({ autoDownloadCount: n });
feedStore.runAutoDownload();
}}
/>
),
},
{
id: "autoDownloadScope",
label: "Auto Download Scope",
kind: "select",
display: () => scopeLabel(prefs().autoDownloadScope),
help: () =>
`Which shows auto-download applies to.\nAll: every subscribed show.\nNone: nothing.\nWhitelist: only the shows you add (in My Shows press ${"w"} on the focused show; or open the Whitelist item below).\nType: select\nDefault: all\nCurrent: ${scopeLabel(prefs().autoDownloadScope)}\nCycle with j/k; Enter to apply.`,
cycle: (dir) => {
const idx = SCOPE_LABELS.findIndex(
(s) => s.value === prefs().autoDownloadScope,
);
const next =
SCOPE_LABELS[(idx + dir + SCOPE_LABELS.length) % SCOPE_LABELS.length]
.value;
app.updatePreferences({ autoDownloadScope: next });
feedStore.runAutoDownload();
},
},
{
id: "autoJumpToPlayer",
@@ -130,4 +220,257 @@ export function usePreferencesItems(): SettingItem[] {
},
},
];
// Whitelist management only appears while scope is set to "whitelist".
if (prefs().autoDownloadScope === "whitelist") {
items.push({
id: "autoDownloadWhitelist",
label: "Auto Download Whitelist",
kind: "editor",
display: () => `${prefs().autoDownloadWhitelist.length} shows`,
help: () =>
`Shows included in auto-download (scope: whitelist).\nSearch your subscribed shows; suggestions toggle in/out with Space.\nType: editor\nCurrent: ${prefs().autoDownloadWhitelist.length} shows`,
renderEditor: () => <WhitelistEditor />,
});
}
return items;
}
// ── Number editor ────────────────────────────────────────────────────────────
// Lets the user type any positive integer (Enter commits; Esc defocuses and
// j/k ±1 cycling takes over — SettingsPage's depth-2 step handler).
function NumberInputEditor(props: {
label: string;
value: () => number;
commit: (n: number) => void;
}) {
const { theme } = useTheme();
const ref = useInputFocusNav();
const [draft, setDraft] = createSignal(String(props.value()));
const [error, setError] = createSignal<string | null>(null);
const submit = () => {
const n = Number(draft().trim());
if (!Number.isInteger(n) || n < 1) {
setError("Enter a whole number ≥ 1");
return;
}
props.commit(n);
setError(null);
};
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>{props.label}</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Episodes per show:</text>
<input
ref={ref}
value={draft()}
onInput={(v) => {
setDraft(v);
setError(null);
}}
onSubmit={submit}
focused
width={8}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
</Show>
<text fg={theme.muted ?? theme.textMuted}>
Type a number, Enter to apply · Esc to browse (j/k ±1) · h back
</text>
</box>
);
}
// ── Whitelist editor ─────────────────────────────────────────────────────────
// Search field over subscribed shows + a navigable suggestion list. Space
// (toggle-select) toggles the focused show in/out of the whitelist; Enter
// does the same. While the input is focused, keys type; Esc (handled in the
// Shell) defocuses so j/k move the list.
//
// Transient UI state lives at module level so preference updates (which
// rebuild the item list) never reset the search or yank focus back into the
// input mid-browse.
//
// The nav.action listener is registered ONCE at module level, not per
// component instance: toggling a show updates preferences, which remounts
// the editor (SettingsPage re-resolves the item's renderEditor), and
// re-registering the listener via onMount/onCleanup during a bus emit
// mutates the handler set mid-iteration — the event bus then re-delivers to
// the fresh listener forever. A single stable listener guarded by an active
// flag sidesteps that entirely.
const [wlQuery, setWlQuery] = createSignal("");
const [wlCursor, setWlCursor] = createSignal(0);
const [wlTyping, setWlTyping] = createSignal(true);
let wlEditorActive = false;
// Indirection for refocusing the search input from the module-level nav.action
// listener (which cannot call useNavigation — that needs the provider).
let wlFocusInput: (() => void) | null = null;
function wlSuggestions(): Feed[] {
const q = wlQuery().trim().toLowerCase();
const all = useFeedStore().getFilteredFeeds();
if (!q) return all;
return all.filter((f) =>
(f.customName || f.podcast.title).toLowerCase().includes(q),
);
}
/** Keep the cursor inside the (possibly shrinking) suggestion list. */
function wlCursorClamped(): number {
return Math.min(wlCursor(), Math.max(wlSuggestions().length - 1, 0));
}
function wlToggle(feedId: string): void {
const app = useAppStore();
const cur = app.state().preferences.autoDownloadWhitelist ?? [];
const next = cur.includes(feedId)
? cur.filter((id) => id !== feedId)
: [...cur, feedId];
app.updatePreferences({ autoDownloadWhitelist: next });
useFeedStore().runAutoDownload();
}
const wlOnAction = (data: {
action: KeybindActionName;
tab: TABS;
pane: PaneId;
mode: NavMode;
}) => {
// Fire at most once per dispatch: the editor is only ever open inside the
// Settings tab's depth-2 pane, so scope on tab + pane and gate on the
// mount flag (which flips during remounts without re-registering).
if (!wlEditorActive) return;
if (data.tab !== TABS.SETTINGS) return;
if (data.pane !== DEPTH_CENTER_PANE) return;
const list = wlSuggestions();
if (list.length === 0) return;
switch (data.action) {
case "move-down":
setWlCursor((c) => Math.min(c + 1, list.length - 1));
break;
case "move-up":
setWlCursor((c) => Math.max(c - 1, 0));
break;
case "toggle-select":
case "open":
wlToggle(list[wlCursorClamped()].id);
break;
case "search":
// `s` while browsing re-enters typing mode (mirrors SearchPage).
wlFocusInput?.();
break;
}
};
on("nav.action", wlOnAction);
function WhitelistEditor() {
const { theme } = useTheme();
const nav = useNavigation();
const feedStore = useFeedStore();
const app = useAppStore();
const whitelist = () => app.state().preferences.autoDownloadWhitelist ?? [];
const inList = (feedId: string) => whitelist().includes(feedId);
onMount(() => {
wlEditorActive = true;
// Restore the last typing/browsing mode across the remounts that
// preference updates trigger. nav.inputFocused() drives the input's
// focused prop (deterministic Esc-to-blur, same as SearchPage), so
// keep the store in sync with the persisted module mode.
nav.setInputFocused(wlTyping());
wlFocusInput = () => nav.setInputFocused(true);
onCleanup(() => {
wlEditorActive = false;
nav.setInputFocused(false);
wlFocusInput = null;
});
});
const focusNavRef = useInputFocusNav();
const inputRef = (el: InputRenderable | null | undefined) => {
focusNavRef(el);
if (el) {
// Sync the persisted mode with real focus changes so remounts
// (e.g. after a toggle) restore the right state.
el.on(RenderableEvents.FOCUSED, () => setWlTyping(true));
el.on(RenderableEvents.BLURRED, () => setWlTyping(false));
}
};
return (
<box flexDirection="column" padding={1} gap={1}>
<text fg={theme.text}>
<strong>Auto Download Whitelist</strong>
</text>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={theme.textMuted}>Search:</text>
<input
ref={inputRef}
value={wlQuery()}
onInput={setWlQuery}
focused={nav.inputFocused()}
placeholder="Type to filter shows…"
width={30}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<Show when={wlSuggestions().length === 0}>
<text fg={theme.muted ?? theme.textMuted}>
No subscribed shows match.
</text>
</Show>
<For each={wlSuggestions()}>
{(feed, index) => {
// While the input is focused (typing), no row shows the
// accent highlight or `` — only the input is "in focus".
const focused = () =>
!nav.inputFocused() && index() === wlCursorClamped();
const ref = useScrollIntoView(focused);
const marker = useSelectionMarker();
const bg = () => (focused() ? theme.primary : undefined);
const fg = () => (focused() ? theme.surface : theme.text);
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={() => {
nav.setActivePane(DEPTH_CENTER_PANE);
setWlCursor(index());
// Click toggles membership directly (works even
// while typing, where Space is input text).
wlToggle(feed.id);
}}
>
<text fg={fg()}>{focused() ? marker() : " "}</text>
<text fg={fg()}>{inList(feed.id) ? "●" : "○"}</text>
<text fg={fg()}>
{feed.customName || feed.podcast.title}
</text>
</box>
);
}}
</For>
<text fg={theme.muted ?? theme.textMuted}>
Type to search · Esc to browse · j/k move · Space toggles · s to
type · h back
</text>
</box>
);
}

View File

@@ -27,6 +27,7 @@ import {
type PaneId,
} from "@/context/NavigationContext";
import { on, off } from "@/utils/event-bus";
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
import type { KeybindActionName } from "@/context/KeybindContext";
import type { SettingItem, SettingsSectionDef } from "./types";
import { usePreferencesItems } from "./PreferencesPanel";
@@ -37,6 +38,7 @@ import { useDownloadItems } from "./DownloadManager";
import { PaneRow } from "@/components/PaneRow";
import { TabListPane } from "@/components/TabPanel";
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
export const SettingsPaneCount = 1;
@@ -45,29 +47,38 @@ const SECTIONS: SettingsSectionDef[] = [
id: 0,
label: "Sync",
description: "Import/export subscriptions and sync status.",
icon: NF_ICONS.sync,
},
{
id: 1,
label: "Sources",
description: "Podcast search/RSS sources — add, enable, remove.",
icon: NF_ICONS.sources,
},
{
id: 2,
label: "Preferences",
description: "Theme, font, playback speed, explicit/auto-download.",
icon: NF_ICONS.preferences,
},
{
id: 3,
label: "Visualizer",
description: "Audio visualizer: bars, sensitivity, cutoffs.",
icon: NF_ICONS.visualizer,
},
{
id: 4,
label: "Downloads",
description: "Manage downloaded episodes — delete by show or individually.",
icon: NF_ICONS.downloads,
},
];
// Static: detection never changes mid-session. Module-level because the Row
// component below (a sibling module function) needs it too.
const nerd = supportsNerdFonts();
/** Resolve the items for a section id at render time. */
function sectionItems(sectionId: number): SettingItem[] {
switch (sectionId) {
@@ -286,6 +297,7 @@ export function SettingsPage() {
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={false}
/>
@@ -315,6 +327,7 @@ export function SettingsPage() {
{(section, index) => (
<Row
label={section.label}
icon={section.icon}
focused={index() === focusedSectionIdx()}
active={isActive()}
onMouseDown={() => {
@@ -407,6 +420,7 @@ function Row(props: {
focused: boolean;
active: boolean;
hint?: string;
icon?: string;
onMouseDown?: () => void;
}) {
const { theme } = useTheme();
@@ -423,17 +437,18 @@ function Row(props: {
? theme.selectedListItemText ?? theme.text
: theme.text;
const ref = useScrollIntoView(() => props.focused);
const marker = useSelectionMarker();
return (
<box
ref={ref}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={bg()}
onMouseDown={props.onMouseDown}
>
<text fg={fg()}>{props.focused ? "" : " "}</text>
<text fg={fg()}>{props.focused ? marker() : " "}</text>
{props.icon && nerd && <text fg={fg()}>{props.icon}</text>}
<text fg={fg()}>{props.label}</text>
<Show when={props.value}>
<box flexGrow={1} />

View File

@@ -103,6 +103,8 @@ function AddSourceForm() {
onInput={setName}
placeholder="My Custom Feed"
width={25}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<box flexDirection="row" gap={1}>
@@ -116,6 +118,8 @@ function AddSourceForm() {
}}
placeholder="https://example.com/feed.rss"
width={35}
textColor={theme.text}
focusedTextColor={theme.accent}
/>
</box>
<box

View File

@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
id: number;
label: string;
description: string;
/** Nerd Font glyph for the section row (rendered only when supported). */
icon: string;
items?: () => SettingItem[];
}

View File

@@ -30,12 +30,16 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoDownloadCount: 2,
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};

View File

@@ -20,17 +20,17 @@ export interface DiscoverCategory {
}
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" },
{ id: "all", name: "All", icon: "\uF0CA" },
{ id: "technology", name: "Technology", icon: "\uF2DB" },
{ id: "science", name: "Science", icon: "\uF0C3" },
{ id: "comedy", name: "Comedy", icon: "\uF118" },
{ id: "news", name: "News", icon: "\uF1EA" },
{ id: "business", name: "Business", icon: "\uF0B1" },
{ id: "health", name: "Health", icon: "\uF21E" },
{ id: "education", name: "Education", icon: "\uF19D" },
{ id: "sports", name: "Sports", icon: "\uF1E3" },
{ id: "true-crime", name: "True Crime", icon: "\uF00E" },
{ id: "arts", name: "Arts", icon: "\uF1FC" },
];
// ── Remote featured-shows manifest ───────────────────────────────────────────

View File

@@ -12,6 +12,7 @@ import type { DownloadedEpisode } from "../types/episode";
import type { Episode } from "../types/episode";
import { downloadEpisode } from "../utils/episode-downloader";
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
import { useFeedStore } from "./feed";
const DOWNLOADS_FILE = "downloads.json";
const MAX_CONCURRENT = 2;
@@ -201,6 +202,27 @@ function createDownloadStore() {
speed: 0,
error: null,
});
// Write the podcast cover beside the audio so mpv's
// --cover-art-auto=exact picks it up for Now Playing art.
const coverUrl = useFeedStore()
.feeds()
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
if (coverUrl && result.filePath) {
const dot = result.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = result.filePath.slice(0, dot) + ".jpg";
fetch(coverUrl)
.then(async (r) => {
if (!r.ok) return;
await Bun.write(
coverPath,
new Uint8Array(await r.arrayBuffer()),
);
})
.catch(() => {});
}
}
} else {
updateDownload(item.episodeId, {
status: DownloadStatus.FAILED,
@@ -306,6 +328,11 @@ function createDownloadStore() {
try {
const { unlink } = await import("fs/promises");
await unlink(dl.filePath);
const dot = dl.filePath.lastIndexOf(".");
if (dot > 0) {
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
await unlink(coverPath);
}
} catch {
// File may already be gone
}

View File

@@ -18,6 +18,7 @@ import {
saveSourcesToFile,
} from "../utils/feeds-persistence";
import { useDownloadStore } from "./download";
import { useAppStore } from "./app";
import { DownloadStatus } from "../types/episode";
/** Max episodes to load per page/chunk */
@@ -209,29 +210,41 @@ function createFeedStore() {
saveFeeds(updated);
return updated;
});
// Global auto-download: newly subscribed shows join the next pass.
runAutoDownload();
return newFeed;
};
/** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
/** Download the N most recent episodes of every in-scope show, per the
* global auto-download preferences (master toggle + scope + whitelist +
* count). Skips episodes already downloaded, queued, or in flight;
* retries failed ones. Idempotent — safe to run after any settings
* change, feed refresh, or subscribe. */
const runAutoDownload = (): void => {
const app = useAppStore();
const prefs = app.state().preferences;
if (!prefs.autoDownload || prefs.autoDownloadScope === "none") return;
const whitelist = prefs.autoDownloadWhitelist ?? [];
const count = Math.max(1, prefs.autoDownloadCount ?? 2);
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
for (const feed of feeds()) {
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
prefs.autoDownloadScope === "whitelist" &&
!whitelist.includes(feed.id)
) {
dlStore.startDownload(ep, feedId);
continue;
}
const sorted = [...feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
for (const ep of sorted.slice(0, count)) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feed.id);
}
}
}
};
@@ -240,7 +253,6 @@ function createFeedStore() {
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
@@ -254,13 +266,9 @@ function createFeedStore() {
return updated;
});
// Auto-download new episodes if enabled for this feed
if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
}
}
// Global auto-download: ensure the N most recent episodes of in-scope
// shows are available offline after every refresh (idempotent).
runAutoDownload();
};
/** Refresh all feeds */
@@ -280,7 +288,15 @@ function createFeedStore() {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
const loadedSources = await loadSourcesFromFile<PodcastSource>();
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
// The default "rss" placeholder source fabricated fake search results
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
// too. User-added custom feeds keep their own ids and are untouched.
const migratedSources =
loadedSources?.filter((source) => source.id !== "rss") ?? [];
if (migratedSources.length > 0) {
setSources(migratedSources);
saveSources(migratedSources);
}
await refreshAllFeeds();
})();
@@ -359,7 +375,7 @@ function createFeedStore() {
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false;
if (sourceId === "itunes") return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
@@ -477,13 +493,9 @@ function createFeedStore() {
}
};
/** Set auto-download settings for a feed */
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
/** Run the global auto-download pass (see runAutoDownload above). */
const runAutoDownloadNow = (): void => {
runAutoDownload();
};
return {
@@ -520,7 +532,7 @@ function createFeedStore() {
removeSource,
toggleSource,
updateSource,
setAutoDownload,
runAutoDownload: runAutoDownloadNow,
};
}

View File

@@ -33,10 +33,6 @@ export interface Feed {
isPinned: boolean
/** Feed color for UI */
color?: string
/** Whether auto-download is enabled for this feed */
autoDownload?: boolean
/** Number of newest episodes to auto-download (0 = all new) */
autoDownloadCount?: number
}
/** Feed item for display in lists */

View File

@@ -81,15 +81,26 @@ export type AppSettings = {
downloadPath: string;
/** Render the app background transparent (let the terminal's own bg show). */
transparentBackground: boolean;
/** Show the `` cursor marker on the focused row of every list (default: off). */
showSelectionMarker: boolean;
visualizer: VisualizerSettings;
};
/** How the Feed list loads older episodes (default: manual "[Fetch More]"). */
export type FetchMoreMode = "manual" | "auto";
/** Which shows the auto-download setting applies to (default: all). */
export type AutoDownloadScope = "all" | "none" | "whitelist";
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
/** Most recent episodes to auto-download per in-scope show (default: 2). */
autoDownloadCount: number;
/** Shows auto-download covers: all / none / whitelist (default: all). */
autoDownloadScope: AutoDownloadScope;
/** Feed ids in the auto-download whitelist (used when scope is "whitelist"). */
autoDownloadWhitelist: string[];
/** Jump to the Player view automatically when playback starts (default: true) */
autoJumpToPlayer: boolean;
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */

View File

@@ -105,12 +105,4 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
language: "en_us",
allowExplicit: true,
},
{
id: "rss",
name: "RSS Feed",
type: SourceType.RSS,
baseUrl: "",
enabled: true,
description: "Add podcasts via RSS feed URL",
},
]

View File

@@ -274,7 +274,7 @@ function CommandDialog(props: {
{/* Search input */}
<box marginBottom={1}>
<text fg={theme.textMuted}>{"> "}</text>
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
<text fg={theme.accent}>{filter() || "Type to search commands..."}</text>
</box>
{/* Command list */}

View File

@@ -33,12 +33,16 @@ const defaultSettings: AppSettings = {
playbackSpeed: 1,
downloadPath: "",
transparentBackground: false,
showSelectionMarker: false,
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
autoDownloadCount: 2,
autoDownloadScope: "all",
autoDownloadWhitelist: [],
autoJumpToPlayer: true,
fetchMoreMode: "manual",
};

View File

@@ -11,7 +11,8 @@
import { platform } from "os";
import { existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { dirname, join } from "path";
import type { Socket, Subprocess } from "bun";
// ── Types ────────────────────────────────────────────────────────────
@@ -46,6 +47,8 @@ export interface PlayOptions {
startPosition?: number;
volume?: number;
speed?: number;
mediaTitle?: string;
coverArtPath?: string;
}
// ── Utilities ────────────────────────────────────────────────────────
@@ -72,19 +75,35 @@ function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
}
/**
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
* makes macOS attribute its Now Playing session to PodTui — source-app icon
* and name in Control Center — instead of a blank placeholder for an
* unbundled binary. Falls back to PATH so dev runs and Linux keep working.
*/
function resolveMpvBinary(): string | null {
try {
const bundled = join(dirname(process.execPath), "mpv");
if (existsSync(bundled)) return bundled;
} catch {
/* process.execPath unusable — fall through to PATH */
}
return which("mpv");
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
export class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv";
private proc: ReturnType<typeof Bun.spawn> | null = null;
private proc: Subprocess | null = null;
private socketPath = mpvSocketPath();
private _playing = false;
private _position = 0;
private _duration = 0;
private _volume = 100;
private _speed = 1;
private pollTimer: ReturnType<typeof setInterval> | null = null;
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop();
@@ -100,7 +119,7 @@ export class MpvBackend implements AudioBackend {
}
const args = [
"mpv",
resolveMpvBinary() ?? "mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
@@ -109,6 +128,16 @@ export class MpvBackend implements AudioBackend {
`--speed=${opts?.speed ?? 1}`,
];
if (opts?.mediaTitle) {
args.push(`--force-media-title=${opts.mediaTitle}`);
}
if (opts?.coverArtPath) {
// Explicit cover file → albumart track → macOS Now Playing artwork
// (works for remote streams, not just local downloads).
args.push(`--cover-art-files=${opts.coverArtPath}`);
}
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`);
}
@@ -129,14 +158,13 @@ export class MpvBackend implements AudioBackend {
// Wait for socket to appear (mpv creates it async)
await this.waitForSocket(2000);
// Start polling position
this.startPolling();
// Position is fetched live from mpv on each getPosition() call (see
// below) — the UI polls it, so no internal poll timer is needed.
// Detect process exit
this.proc.exited
.then(() => {
this._playing = false;
this.stopPolling();
})
.catch(() => {});
}
@@ -149,79 +177,6 @@ export class MpvBackend implements AudioBackend {
}
}
private async ipc(command: unknown[]): Promise<unknown> {
try {
const socket = await Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
// Response handling is done by reading below
},
error(_socket, err) {},
close() {},
open() {},
},
});
const payload = JSON.stringify({ command }) + "\n";
socket.write(payload);
// Read response with timeout
const response = await new Promise<string>((resolve) => {
let buf = "";
const reader = setInterval(() => {
// Check if we got a response already
if (buf.includes("\n")) {
clearInterval(reader);
resolve(buf);
}
}, 10);
setTimeout(() => {
clearInterval(reader);
resolve(buf);
}, 200);
});
socket.end();
if (response) {
try {
return JSON.parse(response.split("\n")[0]);
} catch {
return null;
}
}
return null;
} catch {
return null;
}
}
/** Send a command over mpv's IPC and get the parsed response data. */
private async ipcCommand(command: unknown[]): Promise<unknown> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
});
const payload = JSON.stringify({ command }) + "\n";
conn.write(payload);
// Give mpv a moment to process, then read via a fresh connection
await new Promise((r) => setTimeout(r, 30));
conn.end();
return null;
} catch {
return null;
}
}
/** Send a fire-and-forget command (no response needed) */
private async send(command: unknown[]): Promise<void> {
try {
@@ -246,65 +201,85 @@ export class MpvBackend implements AudioBackend {
}
}
/** Get a property value from mpv via IPC */
private async getProperty(name: string): Promise<number> {
/**
* Get a property value from mpv via IPC.
*
* Resolves the parsed numeric value, or `undefined` when the read fails
* (socket error, timeout, unparseable response, or the property being
* unavailable — e.g. `time-pos` before playback starts). Failure is
* distinct from a legitimate `0` so callers can keep the last known
* value instead of snapping the position clock to zero on a transient
* error; the next poll retries.
*
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
* onto the same connection, so we line-buffer and only settle on the
* line that carries the command response (`request_id` set). The socket
* is closed once the response is handled — leaving it open leaks an fd
* per poll, while closing it before mpv processes the request drops the
* reply.
*/
private async getProperty(name: string): Promise<number | undefined> {
try {
return await new Promise<number>((resolve) => {
let result = 0;
const timeout = setTimeout(() => resolve(result), 300);
return await new Promise<number | undefined>((resolve) => {
let settled = false;
let sock: Socket | null = null;
let buf = "";
const done = (value: number | undefined) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
try {
sock?.end();
} catch {
/* ignore */
}
resolve(value);
};
const timeout = setTimeout(() => done(undefined), 300);
Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
try {
const text = Buffer.from(data).toString();
const parsed = JSON.parse(text.split("\n")[0]);
if (parsed?.data !== undefined) {
result = Number(parsed.data) || 0;
}
} catch {
/* ignore parse errors */
}
clearTimeout(timeout);
resolve(result);
},
error() {
clearTimeout(timeout);
resolve(0);
},
close() {},
open(socket) {
sock = socket;
socket.write(
JSON.stringify({ command: ["get_property", name] }) + "\n",
);
},
data(_socket, data) {
buf += Buffer.from(data).toString();
let nl = buf.indexOf("\n");
while (nl !== -1) {
const line = buf.slice(0, nl);
buf = buf.slice(nl + 1);
nl = buf.indexOf("\n");
try {
const parsed = JSON.parse(line);
// Events carry no request_id; only settle on
// the actual command response.
if (parsed?.request_id === undefined) continue;
if (parsed?.data !== undefined) {
done(Number(parsed.data) || 0);
} else {
done(undefined);
}
return;
} catch {
/* skip malformed lines */
}
}
},
error() {
done(undefined);
},
close() {
done(undefined);
},
},
}).catch(() => {
clearTimeout(timeout);
resolve(0);
});
}).catch(() => done(undefined));
});
} catch {
return 0;
}
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(async () => {
if (!this._playing || !this.proc) return;
this._position = await this.getProperty("time-pos");
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
}
}, 500);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
return undefined;
}
}
@@ -319,7 +294,6 @@ export class MpvBackend implements AudioBackend {
}
async stop(): Promise<void> {
this.stopPolling();
if (this.proc) {
try {
this.proc.kill();
@@ -359,12 +333,20 @@ export class MpvBackend implements AudioBackend {
}
async getPosition(): Promise<number> {
// Live-fetch `time-pos` so the position clock is as fresh as the
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
// failure, keep the last known value rather than returning 0.
if (this._playing && this.proc) {
const pos = await this.getProperty("time-pos");
if (pos !== undefined) this._position = pos;
}
return this._position;
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
this._duration = await this.getProperty("duration");
const dur = await this.getProperty("duration");
if (dur !== undefined && dur > 0) this._duration = dur;
}
return this._duration;
}
@@ -418,7 +400,7 @@ export interface DetectedPlayer {
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = [];
const mpvPath = which("mpv");
const mpvPath = resolveMpvBinary();
if (mpvPath) {
players.push({
name: "mpv",
@@ -452,13 +434,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (backend) return backend;
}
return which("mpv") ? new MpvBackend() : new NoopBackend();
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv":
return which("mpv") ? new MpvBackend() : null;
return resolveMpvBinary() ? new MpvBackend() : null;
case "none":
return new NoopBackend();
}

View File

@@ -4,10 +4,15 @@
* Spawns a separate ffmpeg process that decodes the same audio URL
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
* and provides them to the caller on demand.
* and serves windows *at a requested playback position* to the caller.
*
* This is independent from the actual playback backend — it's a
* read-only "tap" on the audio for FFT analysis purposes.
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
* player is maintained by pacing decode at the player's clock rate
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
* (`-readrate_initial_burst`) so the decode head leads the player
* position by a stable lead — read() samples at the exact position the
* player reports, never at the decode head.
*/
/** PCM output format constants */
@@ -15,8 +20,34 @@ const SAMPLE_RATE = 44100;
const CHANNELS = 1;
const BYTES_PER_SAMPLE = 2; // s16le
/** How many samples to buffer (~1 second) */
const RING_BUFFER_SAMPLES = SAMPLE_RATE;
/**
* How many samples to buffer (~10 seconds).
* Large enough to absorb the gap between mpv's startup latency (0.53s,
* more for network streams at speed) and the reader's decode head, plus
* short player stalls. Samples older than the ring window are never needed
* again — the renderer only samples at the current playback position.
*/
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
/**
* Decode-head lead over the player position, in seconds.
*
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
* immediately on start, then pace at realtime (`-readrate speed`) after.
* The decode head thus leads the player by ~LEAD_SECONDS from the very
* first frame. read() samples at the player's current position, which is
* always behind the head — so it finds freshly decoded samples there
* instead of clamping to stale data.
*
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
* + first-packet latency) and, since both advance at the same rate, never
* catches up — the bars lag by ε (up to several seconds on network
* streams). The burst eliminates that constant offset.
*
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
* lead audio hasn't wrapped out by the time the player reaches it.
*/
const LEAD_SECONDS = 3;
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
@@ -32,11 +63,14 @@ export interface AudioStreamReaderOptions {
*/
let globalGeneration = 0;
import type { Subprocess } from "bun";
export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null;
private proc: Subprocess | null = null;
private ringBuffer: Float64Array;
private writePos = 0;
private totalSamplesWritten = 0;
private startPosition = 0;
private _running = false;
private generation = 0;
readonly url: string;
@@ -67,8 +101,10 @@ export class AudioStreamReader {
* immediately.
*
* @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
* atempo filter so visualization stays in sync with audio.
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
* at the player's advance rate so decode tracks the
* player clock; `-readrate_initial_burst` front-loads
* a LEAD_SECONDS head start.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
@@ -81,25 +117,42 @@ export class AudioStreamReader {
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration;
this.startPosition = Math.max(0, startPosition);
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
const args = [
"ffmpeg",
"-loglevel",
"quiet",
// Read input at native frame rate so decoded PCM stays in sync with
// real-time playback. Without -re, ffmpeg greedily decodes the whole
// file as fast as possible: the ring buffer fills with audio seconds
// ahead of the player (laggy bars), then the process exits when it
// hits EOF (bars freeze ~10s in).
"-re",
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
// Pace input at the player's advance rate (speed× native). Combined
// with -readrate_initial_burst below, the decode head starts
// LEAD_SECONDS ahead of the player and advances at the same rate —
// read() samples at the player position and always finds fresh data.
"-readrate",
String(readRate),
// Front-load LEAD_SECONDS of audio immediately so the decode head
// leads the player from the very first frame. Without this, ffmpeg
// starts ε behind mpv (input-open + first-packet latency) and,
// pacing at the same rate, never catches up — bars lag by ε.
"-readrate_initial_burst",
String(LEAD_SECONDS),
];
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
// input-open when the input is a local file, killing the process
// before any PCM is produced. Only pass them for network URLs.
if (/^https?:\/\//i.test(this.url)) {
args.push(
"-reconnect",
"1",
"-reconnect_streamed",
"1",
"-reconnect_delay_max",
"5",
);
}
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition));
@@ -107,12 +160,9 @@ export class AudioStreamReader {
args.push("-i", this.url);
// Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed));
}
// No atempo filter: the renderer samples the *source* audio at the
// player's current position, so output samples map 1:1 to input time
// (stream index = (targetSeconds - startPosition) * sampleRate).
args.push(
"-ac",
String(CHANNELS),
@@ -155,31 +205,48 @@ export class AudioStreamReader {
}
/**
* Read available samples into the provided buffer.
* Returns the number of samples actually copied.
* Read the visualization window ending at `targetSeconds` of playback.
*
* The player (mpv) and this decoder are independent processes, so the
* decode head and the actual playback position drift apart (startup skew,
* stalls, speed changes). Instead of sampling the decode head, we select
* the window *at* the position the player reports, clamped to the nearest
* available samples when the target hasn't been decoded yet (decode head
* behind) or has already wrapped out of the ring (long stall).
*
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @param targetSeconds - Playback position (input seconds) to sample.
* @returns Number of samples written to `out`.
*/
read(out: Float64Array): number {
const available = Math.min(
out.length,
this.totalSamplesWritten,
this.ringBuffer.length,
read(out: Float64Array, targetSeconds: number): number {
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
const headSample = this.totalSamplesWritten - 1;
const coveredStart = Math.max(
0,
this.totalSamplesWritten - this.ringBuffer.length,
);
const targetSample = Math.max(
0,
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
);
// Window end: the target, clamped to what's been decoded so far.
const endSample = Math.min(targetSample, headSample);
// Window start: at most out.length samples back, clamped to what the
// ring still holds (target older than the ring -> serve the oldest
// available window, which is the closest to the target).
const startSample = Math.max(
coveredStart,
Math.min(endSample, endSample - out.length + 1),
);
const available = endSample - startSample + 1;
if (available <= 0) return 0;
// Read the most recent `available` samples from the ring buffer
const readStart =
(this.writePos - available + this.ringBuffer.length) %
this.ringBuffer.length;
if (readStart + available <= this.ringBuffer.length) {
out.set(this.ringBuffer.subarray(readStart, readStart + available));
} else {
const firstChunk = this.ringBuffer.length - readStart;
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length));
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk);
const ringLen = this.ringBuffer.length;
for (let i = 0; i < available; i++) {
out[i] = this.ringBuffer[(startSample + i) % ringLen];
}
return available;
@@ -255,25 +322,3 @@ export class AudioStreamReader {
}
}
}
/**
* Build an ffmpeg atempo filter chain for a given speed.
* atempo only accepts values in [0.5, 100.0], so we chain
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/
function buildAtempoChain(speed: number): string {
const parts: string[] = [];
let remaining = Math.max(0.25, Math.min(4, speed));
while (remaining > 100) {
parts.push("atempo=100.0");
remaining /= 100;
}
while (remaining < 0.5) {
parts.push("atempo=0.5");
remaining /= 0.5;
}
parts.push(`atempo=${remaining}`);
return parts.join(",");
}

102
src/utils/bar-mapping.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Pure bar-scaling helpers for the terminal waveform.
*
* barChars maps a 0..16 level to the two characters of a 2-row bar built
* from Unicode lower block elements (U+2581..U+2588). The partial block
* sits in the TOP row (its glyph bottom edge = row bottom), so a full
* block below makes a visually continuous 2-cell column — the "double the
* default height" requirement (each bar = 2 terminal rows, 16 heights).
*
* createBarScaler is a stateful fast-attack / slow-release peak follower
* that replaces cava's autosens: a loud start cannot pin every bar at
* full height (the peak follower absorbs it) and quiet content gets
* normalized up.
*/
// ── Types ────────────────────────────────────────────────────────────
export interface BarScalerOptions {
/** Peak follower decay per frame (default: 0.985) */
release?: number;
/** Power curve applied after normalization (default: 0.7) */
curve?: number;
/** Silence threshold — below this the input is treated as silent (default: 1e-6) */
epsilon?: number;
}
// ── Constants ────────────────────────────────────────────────────────
/** Number of discrete bar heights (2 rows × 8 block levels). */
export const BAR_LEVELS = 16;
/** Lower block elements, index 0 = space (silence) through full block (max). */
const LOWER = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
// ── Bar mapping ──────────────────────────────────────────────────────
/**
* Map a bar level (0..16) to the two characters that render it as a
* 2-row column: top row + bottom row.
*
* level 0 → { top: " ", bottom: " " }
* level 1..8 → { top: " ", bottom: LOWER[level] }
* level 9..16 → { top: LOWER[level - 8], bottom: "\u2588" }
*/
export function barChars(level: number): { top: string; bottom: string } {
const raw = Math.floor(level);
const lvl = Number.isFinite(raw)
? Math.max(0, Math.min(BAR_LEVELS, raw))
: 0;
if (lvl === 0) return { top: " ", bottom: " " };
if (lvl <= 8) return { top: " ", bottom: LOWER[lvl] };
return { top: LOWER[lvl - 8], bottom: "\u2588" };
}
// ── Peak-follower scaler ─────────────────────────────────────────────
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
/**
* Create a stateful bar scaler. Each call normalizes its input against a
* peak follower (instant attack, multiplicative release), then applies a
* power curve so low-energy content remains visible. Returns a new
* number[] per call.
*/
export function createBarScaler(
opts?: BarScalerOptions,
): (values: ArrayLike<number>) => number[] {
const release = opts?.release ?? 0.985;
const curve = opts?.curve ?? 0.7;
const epsilon = opts?.epsilon ?? 1e-6;
let peak = 0;
return (values: ArrayLike<number>): number[] => {
let frameMax = 0;
for (let i = 0; i < values.length; i++) {
const magnitude = Math.abs(values[i]);
if (magnitude > frameMax) frameMax = magnitude;
}
// Fast attack, slow release
peak = frameMax > peak ? frameMax : peak * release;
const gain = peak > epsilon ? 1 / peak : 0;
const output = new Array<number>(values.length);
for (let i = 0; i < values.length; i++) {
output[i] = Math.pow(clamp01(values[i] * gain), curve);
}
return output;
};
}

57
src/utils/cover-art.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Cover-art staging for the system Now Playing session.
*
* macOS shows the media session's albumart in the audio center (Control
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
* file as an albumart video track), so the podcast cover is staged to a temp
* file BEFORE playback starts and passed to mpv.
*
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
* which would silently drop every cover in shipped builds. curl is present
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
* so an 8s cap drops the art.
*/
import { tmpdir } from "os";
import { join } from "path";
import { unlinkSync, statSync } from "fs";
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
export async function fetchCoverArt(url: string): Promise<string | null> {
const path = coverTempPath();
try {
unlinkSync(path);
} catch {
/* no stale cover */
}
try {
return await Promise.race([
(async () => {
const proc = Bun.spawn([
"curl",
"-sS",
"--fail",
"-m",
"8",
"--max-filesize",
"2097152",
"-o",
path,
url,
]);
const code = await proc.exited;
if (code !== 0) return null;
try {
return statSync(path).size > 0 ? path : null;
} catch {
return null;
}
})(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
]);
} catch {
return null;
}
}

View File

@@ -76,6 +76,9 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
"toggle-hidden",
"refresh",
"unsubscribe",
"download",
"delete-download",
"whitelist-toggle",
]);
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */

View File

@@ -132,8 +132,6 @@ export type AppEvents = {
"media.toggle": {};
"media.volumeUp": {};
"media.volumeDown": {};
"media.seekForward": {};
"media.seekBackward": {};
"media.speedCycle": {};
};

View File

@@ -65,12 +65,16 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
"toggle-hidden": ["."],
refresh: ["r"],
unsubscribe: ["x"],
// downloads
download: ["d"],
"delete-download": ["D"],
"whitelist-toggle": ["w"],
// audio transport (preserved; shifted single keys, no collisions)
"audio-toggle": ["P"],
"audio-next": ["N"],
"audio-prev": ["B"],
"audio-seek-forward": ["shift-."],
"audio-seek-backward": ["shift-,"],
"audio-seek-forward": ["shift-."], // > = shift+.
"audio-seek-backward": ["shift-,"], // < = shift+,
};
/** Copy keybinds.jsonc to user config directory on first run */

115
src/utils/nerd-fonts.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* Nerd Font support detection + icon codepoints for PodTui.
*
* The app prepends Nerd Font glyphs to hard-defined list rows (tabs, Discover
* categories, Settings sections, the Feed "Fetch More" row). When the user's
* terminal font is NOT Nerd Font capable those glyphs must not render at all —
* no tofu boxes, no empty columns — so every call site gates the icon on
* `supportsNerdFonts()`.
*
* Detection is a heuristic (see `supportsNerdFonts`); the `PODTUI_NERD_FONTS`
* env override wins over everything so a wrong guess is always fixable.
* Under tmux the outer terminal decides — `TMUX` being set counts as
* capable (the multiplexer passes glyphs through), matching the same choice
* made for `screen`-prefixed TERM values. See README → Configuration → Fonts.
*
* This module is deliberately free of Solid/JSX imports so it stays
* unit-testable in isolation.
*/
// ── Detection ────────────────────────────────────────────────────────────────
// Memoized: the terminal does not change mid-session, so detect once.
let cached: boolean | null = null;
/**
* True when the terminal is (very likely) using a Nerd Font-patched font.
*
* Order:
* a. `PODTUI_NERD_FONTS` env override ("1"/"true" → true, "0"/"false" →
* false) — wins over everything.
* b. Allowlist: TERM_PROGRAM ∈ {iTerm.app, WezTerm, vscode, ghostty, rio,
* hyper, tabby, contour}, or TERM starts with {xterm-kitty, foot,
* alacritty, contour, screen} (tmux/screen passthrough — the outer
* terminal decides), or WT_SESSION set (Windows Terminal), or TMUX set.
* Case-insensitive.
* c. Everything else (Terminal.app default SF Mono, plain xterm, unknown)
* → false.
*/
export function supportsNerdFonts(): boolean {
if (cached !== null) return cached;
// a. Env override wins over everything.
const override = process.env.PODTUI_NERD_FONTS?.trim().toLowerCase();
if (override === "1" || override === "true") {
cached = true;
return cached;
}
if (override === "0" || override === "false") {
cached = false;
return cached;
}
// b. Allowlist.
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() ?? "";
const term = process.env.TERM?.toLowerCase() ?? "";
const TERM_PROGRAM_ALLOWLIST: Record<string, true> = {
"iterm.app": true,
wezterm: true,
vscode: true,
ghostty: true,
rio: true,
hyper: true,
tabby: true,
contour: true,
};
const TERM_PREFIX_ALLOWLIST = [
"xterm-kitty",
"foot",
"alacritty",
"contour",
"screen",
];
cached =
TERM_PROGRAM_ALLOWLIST[termProgram] === true ||
TERM_PREFIX_ALLOWLIST.some((prefix) => term.startsWith(prefix)) ||
!!process.env.WT_SESSION ||
!!process.env.TMUX;
// c. Everything else falls through to false.
return cached;
}
// ── Icon codepoints ──────────────────────────────────────────────────────────
// Font Awesome codepoints in the Nerd Font PUA range — stable across Nerd
// Font versions. Keyed by the semantic names the list rows use.
export const NF_ICONS: Record<string, string> = {
feed: "\uF09E",
shows: "\uF005",
discover: "\uF14E",
search: "\uF002",
player: "\uF144",
settings: "\uF013",
sync: "\uF021",
sources: "\uF143",
preferences: "\uF1DE",
visualizer: "\uF080",
downloads: "\uF019",
all: "\uF0CA",
technology: "\uF2DB",
science: "\uF0C3",
comedy: "\uF118",
news: "\uF1EA",
business: "\uF0B1",
health: "\uF21E",
education: "\uF19D",
sports: "\uF1E3",
"true-crime": "\uF00E",
arts: "\uF1FC",
more: "\uF141",
add: "\uF067",
};
/** Glyph for a named icon when Nerd Fonts are supported, else "". */
export function nfIcon(name: string): string {
return supportsNerdFonts() ? NF_ICONS[name] ?? "" : "";
}

View File

@@ -4,95 +4,12 @@ import type { PodcastSource, SearchResult } from "../types/source"
type SearcherResult = SearchResult[]
const delay = async (min = 200, max = 500) =>
new Promise((resolve) => setTimeout(resolve, min + Math.random() * max))
const hashString = (input: string): number => {
let hash = 0
for (let i = 0; i < input.length; i += 1) {
hash = (hash << 5) - hash + input.charCodeAt(i)
hash |= 0
}
return Math.abs(hash)
}
const slugify = (input: string): string =>
input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
const sourceLabel = (source: PodcastSource): string =>
source.name || source.id
const buildPodcast = (
idBase: string,
title: string,
description: string,
author: string,
categories: string[],
source: PodcastSource
): Podcast => ({
id: idBase,
title,
description,
feedUrl: `https://example.com/${slugify(title)}/feed.xml`,
author,
categories,
lastUpdated: new Date(),
isSubscribed: false,
})
const makeResults = (query: string, source: PodcastSource, seedOffset = 0): SearcherResult => {
const seed = hashString(`${source.id}:${query}`) + seedOffset
const baseTitles = [
"Daily Briefing",
"Studio Sessions",
"Signal & Noise",
"The Long Play",
"Off the Record",
]
const descriptors = [
"Deep dives into",
"A fast-paced look at",
"Smart conversations about",
"A weekly roundup of",
"Curated stories on",
]
const categories = ["Technology", "Business", "Science", "Culture", "News"]
return baseTitles.map((base, index) => {
const title = `${query} ${base}`
const desc = `${descriptors[index % descriptors.length]} ${query.toLowerCase()} from ${sourceLabel(source)}.`
const author = `${sourceLabel(source)} Network`
const cat = [categories[(seed + index) % categories.length]]
const podcast = buildPodcast(
`search-${source.id}-${seed + index}`,
title,
desc,
author,
cat,
source
)
return {
sourceId: source.id,
sourceName: source.name,
sourceType: source.type,
podcast,
score: 1 - index * 0.08,
}
})
}
const searchRSSSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(200, 450)
return makeResults(query, source, 1)
}
type ItunesResult = {
collectionId?: number
collectionName?: string
@@ -173,23 +90,28 @@ const searchAPISource = async (
}))
}
const searchCustomSource = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
await delay(300, 650)
return makeResults(query, source, 13)
}
/**
* RSS-type sources have no directory search backend: a feed URL identifies one
* show, and no API exists to search across "the RSS directory". Return no
* results rather than fabricating them.
*/
const searchRSSSource = async (): Promise<SearcherResult> => []
/**
* Custom sources are RSS feeds added by URL (SourceManager) — same
* no-backend story, so they contribute nothing to directory search.
*/
const searchCustomSource = async (): Promise<SearcherResult> => []
export const searchSourceByType = async (
query: string,
source: PodcastSource
): Promise<SearcherResult> => {
if (source.type === SourceType.RSS) {
return searchRSSSource(query, source)
return searchRSSSource()
}
if (source.type === SourceType.CUSTOM) {
return searchCustomSource(query, source)
return searchCustomSource()
}
return searchAPISource(query, source)
}

View File

@@ -0,0 +1,239 @@
/**
* AudioStreamReader sync contract tests.
*
* The visualizer's bars must track the player's position in real time even
* though the reader is an independent ffmpeg process. These tests pin the
* two mechanisms that make that true:
*
* 1. `read(out, target)` serves the FFT window *at* the requested playback
* position — not at the decode head, which drifts from the player
* (startup skew, stalls).
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
* the decode head keeps up with the position at any playback speed —
* native-rate pacing falls behind by (speed-1)s per second.
*
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
* expected samples can be computed analytically and compared exactly.
*/
import { test, expect } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
const SAMPLE_RATE = 44100;
const FREQ = 440;
const AMP = 30000;
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
function writeSineWav(path: string, seconds: number): void {
const total = Math.round(seconds * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
for (let i = 0; i < total; i++) {
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
dv.setInt16(44 + i * 2, v, true);
}
Bun.write(path, buf);
}
/** Analytic sample value at a file index, matching the writer's formula. */
function expectedAt(fileIndex: number): number {
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
}
/**
* Block until the reader's decode head has advanced past `samples` samples.
* The head advances at readrate × real time, so this bounds how long we wait.
*/
async function waitForHead(
reader: AudioStreamReader,
samples: number,
timeoutMs = 8000,
): Promise<void> {
const start = Date.now();
while (reader.samplesWritten < samples) {
if (Date.now() - start > timeoutMs) {
throw new Error("reader decode head did not advance in time");
}
await Bun.sleep(25);
}
}
const hasFfmpeg = !!Bun.which("ffmpeg");
test.skipIf(!hasFfmpeg)(
"read() serves the exact window at the requested position",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(5, 1);
// Cover targets up to ~5.6s (head must pass the read target).
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
const out = new Float64Array(512);
// Window at 5.1s: the window ENDS at the target, so out[i] is at
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
expect(reader.read(out, 5.1)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(5 * SAMPLE_RATE) +
Math.round((5.1 - 5) * SAMPLE_RATE) -
(out.length - 1) +
i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
// Window at 5.105s is the same stream shifted by exactly
// round(0.005*SR)=221 samples — pins that the target maps to a
// precise offset, not "whatever the decode head is at".
const later = new Float64Array(512);
expect(reader.read(later, 5.105)).toBe(512);
for (let i = 0; i <= 512 - 222; i++) {
expect(later[i]).toBe(out[i + 221]);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"decode keeps up with the player clock at 2x speed",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 2);
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
// With native-rate pacing it would only be at ~1.25s, and the
// window at 2.5s would clamp to the head — content mismatch.
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
const out = new Float64Array(512);
expect(reader.read(out, 2.5)).toBe(512);
for (let i = 0; i < 512; i++) {
const idx =
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"read() clamps to the nearest samples when the target is beyond the head",
async () => {
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
writeSineWav(wav, 20);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
// Target far beyond the decode head: serve the newest available
// window (real sine samples, never zeros or garbage).
const out = new Float64Array(512);
expect(reader.read(out, 999)).toBe(512);
const maxAbs = Math.max(...Array.from(out, Math.abs));
expect(maxAbs).toBeGreaterThan(10000);
for (const v of out) {
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
}
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
);
test.skipIf(!hasFfmpeg)(
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
async () => {
// Real wall-clock time is required here: this test validates ffmpeg's
// actual decode pacing (-readrate + -readrate_initial_burst) against
// the platform clock. Deterministic time control cannot reproduce the
// race where ffmpeg exits early and the bars freeze — that only
// surfaces when a real process writes to a real pipe.
//
// Simulates the actual render loop: for ~5s of wall time, advance a
// simulated player position at 1× realtime and call read() each frame.
// The decode head must stay ahead of the player position so read()
// always returns 512 samples, and ffmpeg must not exit early (which
// would freeze the bars). This test would have caught the
// backpressure-pacing failure where ffmpeg decoded all data into the
// pipe buffer instantly, exited, and the readLoop stopped.
const wav = join(
tmpdir(),
`podtui-reader-${process.pid}-${Date.now()}.wav`,
);
writeSineWav(wav, 30);
const reader = new AudioStreamReader({ url: wav });
try {
reader.start(0, 1);
const FRAME_MS = 33;
const DURATION_MS = 5000;
const out = new Float64Array(512);
let successes = 0;
let failures = 0;
let minLead = Infinity;
const start = Date.now();
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
const playerPos = (Date.now() - start) / 1000;
const count = reader.read(out, playerPos);
if (count === 512) successes++;
else failures++;
// The decode head should stay ahead of the player position.
const headPos = reader.samplesWritten / SAMPLE_RATE;
const lead = headPos - playerPos;
if (frame > 3) minLead = Math.min(minLead, lead);
await Bun.sleep(FRAME_MS);
}
// ffmpeg must still be running — it must not have exited early.
expect(reader.running).toBe(true);
// The vast majority of frames should return a full window.
// A few early failures during ffmpeg startup are acceptable.
expect(failures).toBeLessThan(5);
expect(successes).toBeGreaterThan(100);
// The decode head must maintain a positive lead over the player.
// Without -readrate_initial_burst, the head would lag behind by
// the ffmpeg startup latency and never catch up.
expect(minLead).toBeGreaterThan(0);
} finally {
reader.stop();
await Bun.$`rm -f ${wav}`.quiet();
}
},
{ timeout: 15000 },
);

97
tests/bar-mapping.test.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* bar-mapping tests — the pure waveform bar-scaling helpers.
*
* Two contracts are pinned:
*
* • barChars: the 2-row / 16-level bar rendering. The partial block
* always sits in the TOP row (glyph bottom edge = row bottom) so a
* full block below renders a visually continuous 2-cell column —
* this is the "double the default height" requirement.
*
* • createBarScaler: the peak-follower normalization that replaces
* cava's autosens. The regression this guards: bars suddenly maxing
* out when audio starts. A loud first frame must normalize to a
* single full bar (the peak), not pin every bar at full height, and
* quiet content after a loud passage must still recover (slow
* release) instead of staying dead.
*/
import { describe, test, expect } from "bun:test";
import { barChars, createBarScaler, BAR_LEVELS } from "../src/utils/bar-mapping";
describe("barChars", () => {
test("level 0 is two spaces (silence)", () => {
expect(barChars(0)).toEqual({ top: " ", bottom: " " });
});
test("levels 1..8 fill the bottom row only, partial block on top row stays empty", () => {
expect(barChars(1)).toEqual({ top: " ", bottom: "\u2581" });
expect(barChars(4)).toEqual({ top: " ", bottom: "\u2584" });
expect(barChars(8)).toEqual({ top: " ", bottom: "\u2588" });
});
test("levels 9..16 fill the bottom row and put the partial in the top row", () => {
expect(barChars(9)).toEqual({ top: "\u2581", bottom: "\u2588" });
expect(barChars(12)).toEqual({ top: "\u2584", bottom: "\u2588" });
expect(barChars(16)).toEqual({ top: "\u2588", bottom: "\u2588" });
});
test("BAR_LEVELS is 16 (double the single-row 8 levels)", () => {
expect(BAR_LEVELS).toBe(16);
});
test("clamps out-of-range and NaN levels", () => {
expect(barChars(20)).toEqual(barChars(16));
expect(barChars(-3)).toEqual(barChars(0));
expect(barChars(Number.NaN)).toEqual(barChars(0));
});
});
describe("createBarScaler", () => {
test("a loud first frame normalizes to one full bar, not all bars", () => {
const scale = createBarScaler();
const out = scale([0.9, 0.5, 0.1]);
expect(out[0]).toBeCloseTo(1, 5); // the peak maps to full height
expect(out[1]).toBeCloseTo(Math.pow(0.5 / 0.9, 0.7), 5);
expect(out[2]).toBeCloseTo(Math.pow(0.1 / 0.9, 0.7), 5);
});
test("quiet frame after a loud passage recovers via slow release", () => {
const scale = createBarScaler();
scale([0.9]);
// peak decays multiplicatively (release 0.985), so 0.05 gets
// normalized up well past its raw value instead of rendering dead.
const out = scale([0.05]);
const expectedPeak = 0.9 * 0.985;
expect(out[0]).toBeCloseTo(Math.pow(0.05 / expectedPeak, 0.7), 5);
});
test("silence maps to zeros and never inflates the peak", () => {
const scale = createBarScaler();
scale([0.8, 0.4]);
const out = scale([0, 0, 0]);
expect(out).toEqual([0, 0, 0]);
// peak keeps decaying toward silence
expect(scale([0])[0]).toBe(0);
});
test("negative values clamp to zero (no negative bars)", () => {
const scale = createBarScaler();
const out = scale([-0.5]);
expect(out[0]).toBe(0);
});
test("empty input returns an empty array", () => {
const scale = createBarScaler();
expect(scale([])).toEqual([]);
});
test("returns a fresh array each call (no aliasing of cava's buffer)", () => {
const scale = createBarScaler();
const a = scale([0.5]);
const b = scale([0.5]);
expect(a).not.toBe(b);
a[0] = 0;
expect(b[0]).not.toBe(0);
});
});

View File

@@ -0,0 +1,181 @@
/**
* ProgressBar click-to-seek test — pins the mouse→seek coordinate mapping.
*
* @opentui MouseEvent.x/y are terminal-absolute (the SGR parser returns
* `col - 1` and the event propagates up the renderable tree unchanged),
* NOT element-relative. The bar must subtract its own absolute left edge
* (read from the renderable ref) or every click lands shifted by the panes
* to its left — the parent/Up pane ≈ 20% of the terminal width. The bar is
* rendered here exactly as PlayerPage renders it (2-pane PaneRow, padded
* column), so the terminal geometry matches production.
*/
import { describe, test, expect, afterAll, mock } from "bun:test";
import { testRender } from "@opentui/solid";
import { ThemeProvider } from "../src/context/ThemeContext";
import { PaneRow } from "../src/components/PaneRow";
import { ProgressBar } from "../src/pages/Player/ProgressBar";
// ── Audio stub ─────────────────────────────────────────────────────────
const seeks: number[] = [];
const fakeAudio = {
duration: () => 100,
position: () => 0,
seek: async (seconds: number) => {
seeks.push(seconds);
},
};
mock.module("../src/hooks/useAudio", () => ({
useAudio: () => fakeAudio,
}));
// ── Harness ───────────────────────────────────────────────────────────
type Span = { text: string };
type Frame = { cols: number; lines: { spans: Span[] }[] };
type TestSetup = {
renderOnce: () => Promise<void>;
captureSpans: () => unknown;
mockMouse: { click: (x: number, y: number) => Promise<void> };
renderer: { destroy: () => Promise<void> };
};
interface BarGeometry {
setup: TestSetup;
/** Terminal column of the first rendered ░ (the bar's played/remaining run). */
runStartX: number;
/** Number of ░ glyphs drawn (the bar's content width). */
contentWidth: number;
/** Terminal row of the bar's content line. */
barY: number;
destroy: () => Promise<void>;
}
async function renderBar(): Promise<BarGeometry> {
const setup = (await testRender(
() => (
<ThemeProvider mode="dark">
<PaneRow
parent={null}
current={() => (
<box flexDirection="column" gap={1} padding={1}>
<ProgressBar />
</box>
)}
currentLabel="Player"
panes={2}
/>
</ThemeProvider>
),
{ width: 100, height: 10, useThread: false },
)) as unknown as TestSetup;
let runStartX = -1;
let contentWidth = -1;
let barY = -1;
for (let i = 0; i < 40; i++) {
await setup.renderOnce();
const frame = setup.captureSpans() as unknown as Frame;
const lines = frame.lines.map((l) => l.spans.map((s) => s.text).join(""));
const line = lines.find((l) => l.includes("░"));
if (line) {
barY = lines.indexOf(line);
runStartX = line.indexOf("░");
contentWidth = line.split("░").length - 1;
break;
}
const { promise, resolve } = Promise.withResolvers<void>();
setTimeout(resolve, 100);
await promise;
}
if (barY < 0) throw new Error("ProgressBar did not render before timeout");
return {
setup,
runStartX,
contentWidth,
barY,
destroy: async () => {
setup.renderer.destroy();
},
};
}
const cleanups: (() => void | Promise<void>)[] = [];
afterAll(async () => {
for (const c of cleanups) {
try {
await c();
} catch {
// renderer already torn down — ignore
}
}
});
// ── Click-to-seek ──────────────────────────────────────────────────────
describe("ProgressBar click-to-seek", () => {
test("the bar sits past the parent pane (test premise)", async () => {
const bar = await renderBar();
cleanups.push(bar.destroy);
// Parent pane keeps its 20% slot: the bar's run must start well
// right of column 0, otherwise the test would not reproduce the
// coordinate-offset bug.
expect(bar.runStartX).toBeGreaterThan(15);
});
test("clicking the first content column seeks near the start, not the parent pane offset", async () => {
const bar = await renderBar();
cleanups.push(bar.destroy);
seeks.length = 0;
// Pre-fix, the handler read the absolute mouse x as bar-local:
// clicking the bar's left edge sought to ~20%+ of the duration
// (the Up pane's width). It must now seek to the very start.
await bar.setup.mockMouse.click(bar.runStartX, bar.barY);
expect(seeks).toHaveLength(1);
expect(seeks[0]).toBe(0);
});
test("clicking the bar's right edge seeks to the end", async () => {
const bar = await renderBar();
cleanups.push(bar.destroy);
seeks.length = 0;
// The column right of the last drawn char is the box's right border:
// local x there equals the content width, so the seek must be 100%.
await bar.setup.mockMouse.click(
bar.runStartX + bar.contentWidth,
bar.barY,
);
expect(seeks).toHaveLength(1);
expect(seeks[0]).toBeCloseTo(100, 0);
});
test("clicking the bar's middle seeks to half the duration", async () => {
const bar = await renderBar();
cleanups.push(bar.destroy);
seeks.length = 0;
const mid = bar.runStartX + Math.floor(bar.contentWidth / 2);
await bar.setup.mockMouse.click(mid, bar.barY);
expect(seeks).toHaveLength(1);
expect(seeks[0]).toBeCloseTo(50, 0);
});
test("clicks map linearly across the whole bar", async () => {
const bar = await renderBar();
cleanups.push(bar.destroy);
seeks.length = 0;
// One click at each drawn column; each must seek to that column's
// exact share of the duration (within one second of rounding).
for (let offset = 0; offset < bar.contentWidth; offset++) {
await bar.setup.mockMouse.click(bar.runStartX + offset, bar.barY);
const expected = (offset / bar.contentWidth) * 100;
expect(seeks[seeks.length - 1]).toBeCloseTo(expected, 0);
}
});
});

View File

@@ -0,0 +1,40 @@
/**
* Search source dispatch regression test.
*
* RSS-type and custom sources have no directory search backend: a feed URL
* identifies one show, and no API exists to search "the RSS directory". They
* must return no results. Earlier the dispatcher fabricated fake podcasts
* ("<query> Daily Briefing" by "<Source> Network", with dead
* https://example.com/... feed URLs) from the query, which polluted every
* search. This test pins the empty-result contract.
*/
import { test, expect } from "bun:test";
import { searchSourceByType } from "../src/utils/source-searcher";
import { SourceType } from "../src/types/source";
import type { PodcastSource } from "../src/types/source";
const rssSource: PodcastSource = {
id: "rss",
name: "RSS Feed",
type: SourceType.RSS,
baseUrl: "",
enabled: true,
};
const customSource: PodcastSource = {
id: "my-feed",
name: "My Feed",
type: SourceType.CUSTOM,
baseUrl: "https://example.com/feed.rss",
enabled: true,
};
test("RSS sources return no directory search results", async () => {
const results = await searchSourceByType("blocked and reported", rssSource);
expect(results).toEqual([]);
});
test("custom sources return no directory search results", async () => {
const results = await searchSourceByType("anything", customSource);
expect(results).toEqual([]);
});