Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2e9e5c16c | |||
| 41c0002090 | |||
| c0252fc9b8 | |||
| 0c3506beb5 | |||
| ef9fc13aaa | |||
| 0b0637b9dc | |||
| e73e608b9f | |||
| ebed49237c | |||
| dc2b22eaa5 | |||
| 2bb612ee07 | |||
| dc855ab8a0 | |||
| f976bdc2b7 | |||
| 1cf3361e59 | |||
| ada441300a | |||
| 6134dea044 | |||
| 93d5925dfd | |||
| de6d0ccbf6 | |||
| cda29bcb95 | |||
| 3775a9801d | |||
| a9589e7686 | |||
| c52fa14e42 | |||
| 116d095ad5 | |||
| b280af484c | |||
| 5dce21c038 | |||
| d2c46631ef | |||
| 67032460ff | |||
| 0f3ffcf934 | |||
| c813949d48 | |||
| eb220386ce | |||
| 19eae4fd5a | |||
| e70469b1ec | |||
| 30b7ff57d3 | |||
| fd689aba04 | |||
| 8eaca82ce9 |
8
.github/workflows/release.yml
vendored
@@ -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
@@ -34,3 +34,4 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
.DS_Store
|
||||
.harness/
|
||||
.ralpi
|
||||
notes.md
|
||||
|
||||
22
README.md
@@ -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 and per-show "Fetch More" rows). 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
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 465 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 759 B |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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 |
1
assets/App Icon/App Icon.icon/Assets/Terminal Cursor.svg
Normal 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 |
51
assets/App Icon/App Icon.icon/icon.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
assets/App Icon/App Icon.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
1
assets/App Icon/App Icon.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/App Icon/App Icon_2x.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
assets/App Icon/App Icon_4x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
assets/App Icon/App Icon_6x.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
assets/App Icon/AppIcon.icns
Normal file
126
build.ts
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||
*
|
||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
||||
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
||||
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
||||
* the ratio is exact regardless of content width — a column's content can
|
||||
* never stretch its slot.
|
||||
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||
* column's content can never stretch its slot.
|
||||
*
|
||||
* Column semantics (per the yazi depth model):
|
||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
||||
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||
* KEEPS its 20% slot when blank (never collapses to width 0).
|
||||
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||
* header row: the CURRENT column's title renders top-left in the
|
||||
* parent's slot (the panes above current/preview were removed).
|
||||
@@ -184,7 +184,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||
{/* ── parent (1/5) — previous-depth list; title row top-left ───────── */}
|
||||
{/* ── parent (20%) — previous-depth list; title row top-left ────────── */}
|
||||
<Pane
|
||||
grow={PANE_RATIO.parent}
|
||||
label={currentLabel}
|
||||
@@ -200,7 +200,7 @@ export function PaneRow(props: PaneRowProps) {
|
||||
border={["left", "right"]}
|
||||
scrollFocused={() => focused()}
|
||||
/>
|
||||
{/* ── preview (2/5) — hovered-item detail; no border, no header ────── */}
|
||||
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||
<Show when={panes() === 3}>
|
||||
<Pane
|
||||
grow={PANE_RATIO.preview}
|
||||
|
||||
@@ -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,68 @@ 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 when its text overflows; when it fits (or the bar is too
|
||||
// narrow to show anything) it renders statically. Each pass scrolls at
|
||||
// SCROLL_STEP_MS per char, then holds at the start for SCROLL_HOLD_MS
|
||||
// before scrolling again.
|
||||
const dims = useTerminalDimensions();
|
||||
const GAP = 3;
|
||||
const SCROLL_STEP_MS = 150;
|
||||
const SCROLL_HOLD_MS = 10_000;
|
||||
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;
|
||||
// Hold at the start position for SCROLL_HOLD_MS, scroll one pass,
|
||||
// then hold again before the next pass.
|
||||
let holdId: ReturnType<typeof setTimeout> | null = null;
|
||||
let scrollId: ReturnType<typeof setInterval> | null = null;
|
||||
const startHold = () => {
|
||||
setScrollOffset(0);
|
||||
holdId = setTimeout(() => {
|
||||
scrollId = setInterval(() => {
|
||||
const next = scrollOffset() + 1;
|
||||
if (next >= cycle) {
|
||||
clearInterval(scrollId!);
|
||||
startHold();
|
||||
} else {
|
||||
setScrollOffset(next);
|
||||
}
|
||||
}, SCROLL_STEP_MS);
|
||||
}, SCROLL_HOLD_MS);
|
||||
};
|
||||
startHold();
|
||||
onCleanup(() => {
|
||||
if (holdId) clearTimeout(holdId);
|
||||
if (scrollId) clearInterval(scrollId);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -290,12 +359,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 (`<` → `<`),
|
||||
* 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>
|
||||
@@ -376,6 +447,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
||||
["enter", "open"],
|
||||
["r", "refresh"],
|
||||
["s", "search"],
|
||||
[p("search-scope-toggle"), "shows/episodes"],
|
||||
["f", "filter"],
|
||||
[",", "sort"],
|
||||
[".", "hidden"],
|
||||
|
||||
@@ -19,86 +19,105 @@ 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",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[TABS.SETTINGS]: "Settings",
|
||||
[TABS.FEED]: "Feed",
|
||||
[TABS.MYSHOWS]: "My Shows",
|
||||
[TABS.DISCOVER]: "Discover",
|
||||
[TABS.SEARCH]: "Search",
|
||||
[TABS.PLAYER]: "Player",
|
||||
[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",
|
||||
(v): v is TABS => typeof v === "number",
|
||||
) as TABS[];
|
||||
|
||||
export function TabListPane(props: { muted?: boolean }) {
|
||||
const { theme } = useTheme();
|
||||
const nav = useNavigation();
|
||||
// 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();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
const cursor = () => nav.tabCursor();
|
||||
const activeTab = () => nav.activeTab();
|
||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||
const active = () => !props.muted;
|
||||
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.surface
|
||||
: t === cursor()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
// Same focus-bg / focus-fg contract every other pane uses.
|
||||
const focusBg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.primary
|
||||
: t === cursor()
|
||||
? theme.border
|
||||
: undefined;
|
||||
const focusFg = (t: TABS) =>
|
||||
t === cursor() && active()
|
||||
? theme.surface
|
||||
: t === cursor()
|
||||
? theme.selectedListItemText ?? theme.text
|
||||
: theme.text;
|
||||
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(isCursor);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
onMouseDown={() => {
|
||||
// Click = hover + open, the yazi "open" of the row
|
||||
// (switches to the tab and enters its content), the same
|
||||
// as l/Enter. Restores mouse support the tab-strip
|
||||
// refactor dropped.
|
||||
nav.setTabCursor(tab);
|
||||
nav.activateTabCursor();
|
||||
}}
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
return (
|
||||
<For each={TAB_ORDER}>
|
||||
{(tab) => {
|
||||
const isCursor = () => cursor() === tab;
|
||||
const isActive = () => activeTab() === tab;
|
||||
// The active tab is only accented in the Up/parent position — when this
|
||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||
const labelFg = () =>
|
||||
isCursor()
|
||||
? focusFg(tab)
|
||||
: isActive() && !active()
|
||||
? theme.accent
|
||||
: theme.text;
|
||||
const ref = useScrollIntoView(isCursor);
|
||||
return (
|
||||
<box
|
||||
ref={ref}
|
||||
width="100%"
|
||||
height={1}
|
||||
flexDirection="row"
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(tab)}
|
||||
onMouseDown={() => {
|
||||
// Click = hover + open, the yazi "open" of the row
|
||||
// (switches to the tab and enters its content), the same
|
||||
// as l/Enter. Restores mouse support the tab-strip
|
||||
// refactor dropped.
|
||||
nav.setTabCursor(tab);
|
||||
nav.activateTabCursor();
|
||||
}}
|
||||
>
|
||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(tab)} paddingRight={1}>
|
||||
{TAB_ICON[tab]}
|
||||
</text>
|
||||
)}
|
||||
<text fg={labelFg()} paddingLeft={1}>
|
||||
{TAB_LABEL[tab]}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,19 +59,27 @@
|
||||
"help": ["~", "f1"],
|
||||
|
||||
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||
"search": ["s"],
|
||||
"filter": ["f"],
|
||||
"search": ["s"],
|
||||
// tab toggles the Search page between show and episode scope (re-runs the
|
||||
// current query when viewing results)
|
||||
"search-scope-toggle": ["tab"],
|
||||
"filter": ["f"],
|
||||
"sort": [","],
|
||||
"toggle-hidden": ["."],
|
||||
"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+,)
|
||||
}
|
||||
|
||||
@@ -63,11 +63,15 @@ export type KeybindActionName =
|
||||
| "quit"
|
||||
| "help"
|
||||
| "search"
|
||||
| "search-scope-toggle"
|
||||
| "filter"
|
||||
| "sort"
|
||||
| "toggle-hidden"
|
||||
| "refresh"
|
||||
| "unsubscribe"
|
||||
| "download"
|
||||
| "delete-download"
|
||||
| "whitelist-toggle"
|
||||
| "audio-toggle"
|
||||
| "audio-next"
|
||||
| "audio-prev"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* parent | current | preview
|
||||
*
|
||||
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) live in
|
||||
* Layout ratios (20% : 50% : 30% — PANE_RATIO 2:5:3) live in
|
||||
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||
* nav model — which column is focused and where its list cursor lives. The
|
||||
* parent/preview columns are always derived, never focused.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
src/hooks/useSelectionMarker.ts
Normal 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 ? "❯" : " ";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Feed } from "./types/feed"
|
||||
import type { Episode } from "./types/episode"
|
||||
|
||||
const VERSION = "0.3.1";
|
||||
const VERSION = "0.4.0";
|
||||
|
||||
interface CliArgs {
|
||||
version: boolean;
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={() => {
|
||||
@@ -251,31 +267,55 @@ function FeedPage() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
{index() === fi() ? "❯" : " "}
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{index() === fi() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), fi(), isActive())}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), fi(), isActive())}
|
||||
>
|
||||
{item.episode.episodeNumber
|
||||
? `#${item.episode.episodeNumber} `
|
||||
: ""}
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === fi() ? theme.surface : theme.info}>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<text fg={index() === fi() ? theme.surface : muted()}>
|
||||
{/* podcast name on its own row — readable at a glance; the
|
||||
50% current pane fits it in full for typical names, and
|
||||
truncate keeps the row one line tall either way */}
|
||||
<box paddingLeft={2}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
||||
>
|
||||
{item.feed.customName || item.feed.podcast.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(item.episode.pubDate)}
|
||||
</text>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(item.episode.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(item.episode.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(item.episode.id)}>
|
||||
<text fg={downloadColor(item.episode.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
||||
{downloadLabel(item.episode.id)}
|
||||
</text>
|
||||
</Show>
|
||||
@@ -289,7 +329,6 @@ function FeedPage() {
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
||||
onMouseDown={() => {
|
||||
@@ -298,8 +337,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 +422,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>
|
||||
|
||||
@@ -6,14 +6,18 @@
|
||||
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||
* preview — detail of the hovered item in the current column.
|
||||
*
|
||||
* Depth 1 ends with a "[Fetch More]" row (same preference-driven behavior
|
||||
* as the Feed tab) that loads the next batch of episodes for that show.
|
||||
*
|
||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||
* 0). j/k move only within the current column.
|
||||
*/
|
||||
|
||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { createMemo, createEffect, 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";
|
||||
@@ -27,6 +31,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";
|
||||
@@ -34,17 +39,22 @@ 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() {
|
||||
// Static: detection never changes mid-session.
|
||||
const nerd = supportsNerdFonts();
|
||||
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;
|
||||
@@ -67,17 +77,40 @@ export function MyShowsPage() {
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||
);
|
||||
});
|
||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||
// A "[Fetch More]" row at the bottom of a drilled show's episode list
|
||||
// advances that show's loaded window by 50 episodes — the per-show
|
||||
// counterpart to the Feed page's row (which loads every feed). manual
|
||||
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
||||
// automatically (see the effect below).
|
||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
||||
const showFetchMore = () =>
|
||||
depth() >= 1 &&
|
||||
!!drilledShowId() &&
|
||||
feedStore.hasMoreEpisodes(drilledShowId());
|
||||
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
|
||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||
const focusedRow = () =>
|
||||
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
||||
const focusedOnMore = () =>
|
||||
showFetchMore() && focusedRow() === episodes().length;
|
||||
// -1 while the Fetch More row is focused so no episode row renders the
|
||||
// cursor/highlight (the button is the focused row, not the last episode).
|
||||
const focusedEpIdx = () =>
|
||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||
focusedOnMore()
|
||||
? -1
|
||||
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||
const focusedEpisode = () =>
|
||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
||||
|
||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
||||
const curLen = () => (depth() === 0 ? shows().length : rowCount());
|
||||
|
||||
const ensureFocus = () => {
|
||||
if (shows().length > 0 && focus(0) >= shows().length)
|
||||
nav.setDepthFocus(shows().length - 1, 0);
|
||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
||||
nav.setDepthFocus(episodes().length - 1, 1);
|
||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||
nav.setDepthFocus(rowCount() - 1, 1);
|
||||
};
|
||||
onMount(ensureFocus);
|
||||
|
||||
@@ -88,6 +121,17 @@ export function MyShowsPage() {
|
||||
});
|
||||
});
|
||||
|
||||
// Auto mode: reaching the bottom of a drilled show's list loads its next
|
||||
// batch. Guarded by isLoadingMore so concurrent loads never stack.
|
||||
createEffect(() => {
|
||||
if (depth() < 1) return;
|
||||
if (fetchMoreMode() !== "auto") return;
|
||||
if (!showFetchMore()) return;
|
||||
if (feedStore.isLoadingMore()) return;
|
||||
if (focusedRow() < rowCount() - 1) return;
|
||||
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||
const formatDuration = (s: number) => {
|
||||
@@ -139,6 +183,10 @@ export function MyShowsPage() {
|
||||
return;
|
||||
}
|
||||
if (depth() >= 1) {
|
||||
if (focusedOnMore()) {
|
||||
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const ep = focusedEpisode();
|
||||
if (ep) playEpisode(ep);
|
||||
}
|
||||
@@ -161,6 +209,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 +300,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 +334,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 +352,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 +360,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 +398,6 @@ export function MyShowsPage() {
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||
onMouseDown={() => {
|
||||
@@ -316,26 +406,41 @@ export function MyShowsPage() {
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
{index() === lf() ? "❯" : " "}
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{index() === lf() ? marker() : " "}
|
||||
</text>
|
||||
<text fg={focusFg(index(), lf(), isActive())}>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={focusFg(index(), lf(), isActive())}
|
||||
>
|
||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
||||
{ep.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg={index() === lf() ? theme.surface : theme.info}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : theme.info}
|
||||
>
|
||||
{formatDate(ep.pubDate)}
|
||||
</text>
|
||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
fg={index() === lf() ? theme.surface : muted()}
|
||||
>
|
||||
{formatDuration(ep.duration)}
|
||||
</text>
|
||||
<Show when={nav.isSelected(ep.id)}>
|
||||
<text fg={theme.warning}>●</text>
|
||||
<text flexShrink={0} fg={theme.warning}>
|
||||
●
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={downloadLabel(ep.id)}>
|
||||
<text fg={downloadColor(ep.id)}>
|
||||
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
||||
{downloadLabel(ep.id)}
|
||||
</text>
|
||||
</Show>
|
||||
@@ -344,9 +449,38 @@ export function MyShowsPage() {
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<LoadingIndicator label="Loading more…" />
|
||||
<Show when={showFetchMore()}>
|
||||
<box
|
||||
ref={moreRef}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(
|
||||
episodes().length,
|
||||
focusedRow(),
|
||||
isActive(),
|
||||
)}
|
||||
onMouseDown={() => {
|
||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||
nav.setDepthFocus(episodes().length, 1);
|
||||
}}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{focusedOnMore() ? marker() : " "}
|
||||
</text>
|
||||
{nerd && (
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
{NF_ICONS.more}
|
||||
</text>
|
||||
)}
|
||||
<Show
|
||||
when={!feedStore.isLoadingMore()}
|
||||
fallback={<LoadingIndicator label="Fetching…" />}
|
||||
>
|
||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
||||
[Fetch More]
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
@@ -381,20 +515,47 @@ 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>
|
||||
) : (
|
||||
// depth ≥1 preview: hovered episode
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||
<>
|
||||
<Show when={focusedOnMore()}>
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
<strong>[Fetch More]</strong>
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
{feedStore.isLoadingMore()
|
||||
? "Loading the next batch of episodes…"
|
||||
: fetchMoreMode() === "auto"
|
||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||
: "Load the next batch of older episodes for this show (Enter)."}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: load more · h back</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
</Show>
|
||||
<Show when={!focusedOnMore()}>
|
||||
<Show
|
||||
when={focusedEpisode()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg={muted()}>No episode focused</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(ep) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.textPrimary ?? theme.text}>
|
||||
@@ -421,11 +582,27 @@ 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>
|
||||
</Show>
|
||||
);
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
|
||||
@@ -14,70 +14,81 @@ type PlaybackControlsProps = {
|
||||
onSpeedChange: (value: number) => void;
|
||||
};
|
||||
|
||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||
mpv: "mpv",
|
||||
none: "none",
|
||||
};
|
||||
|
||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexWrap="wrap"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
border
|
||||
padding={1}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onPrev}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary}>[Prev]</text>
|
||||
{/* transport buttons — wrap as a unit, centered on their own line */}
|
||||
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onPrev}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">[Prev]</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onToggle}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onNext}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary} wrapMode="none">[Next]</text>
|
||||
</box>
|
||||
</box>
|
||||
{/* status group — always follows the buttons; wrap point is here */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onToggle}
|
||||
borderColor={theme.border}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
marginLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={props.onNext}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<text fg={theme.primary}>[Next]</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Vol</text>
|
||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||
<text fg={theme.textMuted}>↑↓</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
<text fg={theme.textMuted}>s</text>
|
||||
</box>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg={theme.textMuted}>via</text>
|
||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
||||
<text fg={theme.textMuted}>Speed</text>
|
||||
<text fg={theme.text}>{props.speed}x</text>
|
||||
<text fg={theme.textMuted}>s</text>
|
||||
</box>
|
||||
)}
|
||||
{props.backendName === "none" && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio player found</text>
|
||||
</box>
|
||||
)}
|
||||
{props.hasAudioUrl === false && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio URL</text>
|
||||
</box>
|
||||
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||
{(props.backendName === "none" || props.hasAudioUrl === false) && (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
alignItems="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
{props.backendName === "none" && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio player found</text>
|
||||
</box>
|
||||
)}
|
||||
{props.hasAudioUrl === false && (
|
||||
<box marginLeft={2}>
|
||||
<text fg={theme.warning}>No audio URL</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
|
||||
@@ -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;
|
||||
@@ -109,9 +112,13 @@ export function PlayerPage() {
|
||||
/>
|
||||
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
||||
</text>
|
||||
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||
* transform HTML-escapes static string children (`<` → `<`),
|
||||
* which opentui renders verbatim; content bypasses that. */}
|
||||
<text
|
||||
fg={muted()}
|
||||
content={"P play/pause N next B prev < > seek h back"}
|
||||
/>
|
||||
</box>
|
||||
);
|
||||
|
||||
|
||||
68
src/pages/Player/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.0–1.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>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* query (muted, read-only); preview shows the detail of
|
||||
* the focused result.
|
||||
*
|
||||
* Search scope: `tab` (search-scope-toggle) flips between shows and episodes
|
||||
* (clickable pills on the query depth too); toggling while viewing results
|
||||
* re-runs the current query in the new scope.
|
||||
*
|
||||
* Typed input owns its keys while `nav.inputFocused()` is true (the Shell
|
||||
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||
@@ -26,6 +30,7 @@ import {
|
||||
} from "solid-js";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import { format } from "date-fns";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import {
|
||||
@@ -37,21 +42,25 @@ import {
|
||||
} from "@/context/NavigationContext";
|
||||
import { on, off } from "@/utils/event-bus";
|
||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||
import type { SearchResult } from "@/types/source";
|
||||
import type { SearchResult, SearchScope } from "@/types/source";
|
||||
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";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
|
||||
export const SearchPaneCount = 1;
|
||||
|
||||
function SearchPage() {
|
||||
const searchStore = useSearchStore();
|
||||
const feedStore = useFeedStore();
|
||||
const toast = useToast();
|
||||
const [inputValue, setInputValue] = createSignal("");
|
||||
const { theme } = useTheme();
|
||||
const muted = () => theme.muted || theme.text;
|
||||
const nav = useNavigation();
|
||||
const marker = useSelectionMarker();
|
||||
|
||||
const stack = nav.depthStack;
|
||||
const depth = nav.currentDepth;
|
||||
@@ -65,21 +74,32 @@ function SearchPage() {
|
||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
||||
//
|
||||
// Typing is the default only on the query depth (0); the results depth
|
||||
// (1) is always list-navigation. Drive `inputFocused` straight off
|
||||
// `depth()` rather than seeding it `true` on mount and patching on change:
|
||||
// the depth stack persists across tab switches, so re-mounting this page
|
||||
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
||||
// must NOT leave `inputFocused` stuck on — otherwise the Shell swallows
|
||||
// j/k (yielding to a non-existent input) and only the scrollbox's native
|
||||
// scroll responds.
|
||||
// The input's REAL focus is the source of truth for the flag:
|
||||
// useInputFocusNav (the same hook the Settings forms use) flips
|
||||
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
|
||||
// and the renderable in lockstep. That matters when the user clicks OFF the
|
||||
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
|
||||
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
|
||||
// event drops the flag, so the Shell router immediately resumes j/k/h
|
||||
// instead of swallowing keys with no input to receive them — no more
|
||||
// stuck "typing" state where Esc/j/k/s all do nothing.
|
||||
//
|
||||
// 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 still SEEDS the flag on transitions, since the query
|
||||
// depth defaults to typing: re-entering depth 0 (h back from results, or a
|
||||
// fresh mount) focuses the input; mounting at depth 1 (returning to the
|
||||
// tab after a search) stays list-navigation — a stuck-on flag there would
|
||||
// have the Shell yield j/k to a non-existent input. The depth STACK signal
|
||||
// is also written by focus moves (setDepthFocus), so gate the seed 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 focusNavRef = useInputFocusNav();
|
||||
const isQueryDepth = createMemo(() => depth() === 0);
|
||||
createEffect(() => {
|
||||
nav.setInputFocused(depth() === 0);
|
||||
nav.setInputFocused(isQueryDepth());
|
||||
});
|
||||
|
||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||
@@ -105,7 +125,10 @@ function SearchPage() {
|
||||
// Register a visual-mode resolver for the results list (depth 1).
|
||||
onMount(() => {
|
||||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
||||
nav.registerResolver(key, (i) => results()[i]?.podcast.id);
|
||||
nav.registerResolver(key, (i) => {
|
||||
const r = results()[i];
|
||||
return r?.kind === "episode" ? r.episode.id : r?.podcast.id;
|
||||
});
|
||||
});
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -130,10 +153,36 @@ function SearchPage() {
|
||||
runSearch(query);
|
||||
};
|
||||
|
||||
const handleSubscribe = (result: SearchResult) => {
|
||||
// Actually add the feed to the feed store, then mark the result subscribed
|
||||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
||||
searchStore.markSubscribed(result.podcast.id);
|
||||
/** Set show/episode scope; when viewing results, re-run the current query
|
||||
* so the list switches immediately (the toggle is otherwise invisible on
|
||||
* a list of results). */
|
||||
const applyScope = (next: SearchScope) => {
|
||||
searchStore.setScope(next);
|
||||
if (depth() >= 1) {
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
}
|
||||
};
|
||||
const toggleScope = () =>
|
||||
applyScope(searchStore.scope() === "podcast" ? "episode" : "podcast");
|
||||
|
||||
const handleSubscribe = async (result: SearchResult) => {
|
||||
// Actually add the feed to the feed store, then mark the result
|
||||
// subscribed. addFeed returns null when a feedless directory stub
|
||||
// (delisted show) can't be resolved — tell the user why.
|
||||
const feed = await feedStore
|
||||
.addFeed(result.podcast, result.sourceId)
|
||||
.catch(() => null);
|
||||
if (!feed && !result.podcast.feedUrl) {
|
||||
toast.show({
|
||||
title: "Can't subscribe",
|
||||
message:
|
||||
"No RSS feed is listed for this show and the feed couldn't be resolved. Try adding it by feed URL.",
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||
};
|
||||
|
||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||
@@ -150,13 +199,17 @@ function SearchPage() {
|
||||
"toggle-select": () => {
|
||||
if (depth() === 1) {
|
||||
const r = focusedResult();
|
||||
if (r) nav.toggleSelected(r.podcast.id);
|
||||
if (r)
|
||||
nav.toggleSelected(
|
||||
r.kind === "episode" ? r.episode.id : r.podcast.id,
|
||||
);
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||
if (depth() === 0) nav.setInputFocused(true);
|
||||
},
|
||||
"search-scope-toggle": () => toggleScope(),
|
||||
refresh: () => {
|
||||
const q = submittedQuery() || inputValue().trim();
|
||||
if (q) searchStore.search(q).catch(() => {});
|
||||
@@ -213,15 +266,28 @@ 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={theme.textSecondary}>
|
||||
Scope · {searchStore.scope() === "episode" ? "episodes" : "shows"}
|
||||
</text>
|
||||
<text fg={muted()}>h: back to query</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
);
|
||||
|
||||
// ── current pane ────────────────────────────────────────────────────────────
|
||||
@@ -233,14 +299,78 @@ function SearchPage() {
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={muted()}>Query:</text>
|
||||
<input
|
||||
ref={focusNavRef}
|
||||
value={inputValue()}
|
||||
onInput={setInputValue}
|
||||
onSubmit={() => handleSubmit()}
|
||||
placeholder="Enter podcast name..."
|
||||
onMouseDown={(evt) => {
|
||||
// Clicking the input must focus it (typing mode).
|
||||
// preventDefault stops opentui's click auto-focus from
|
||||
// grabbing the pane scrollbox instead; setting the flag
|
||||
// drives the `focused` prop → renderable focus → the
|
||||
// useInputFocusNav FOCUSED handler.
|
||||
evt.preventDefault();
|
||||
nav.setInputFocused(true);
|
||||
}}
|
||||
onKeyDown={(evt) => {
|
||||
// While the input owns keys the Shell router never sees
|
||||
// Tab, so the scope toggle must be handled here (the
|
||||
// pills and the tab keybind cover the defocused cases).
|
||||
if (evt.name === "tab") {
|
||||
evt.preventDefault();
|
||||
toggleScope();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
searchStore.scope() === "episode"
|
||||
? "Enter episode, guest, topic..."
|
||||
: "Enter podcast name..."
|
||||
}
|
||||
focused={inputActive()}
|
||||
width={28}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={theme.textSecondary}>Scope:</text>
|
||||
<box
|
||||
backgroundColor={
|
||||
searchStore.scope() === "podcast" ? theme.primary : undefined
|
||||
}
|
||||
onMouseDown={() => applyScope("podcast")}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
searchStore.scope() === "podcast"
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
Shows{" "}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={
|
||||
searchStore.scope() === "episode" ? theme.primary : undefined
|
||||
}
|
||||
onMouseDown={() => applyScope("episode")}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
searchStore.scope() === "episode"
|
||||
? theme.surface
|
||||
: muted()
|
||||
}
|
||||
>
|
||||
{" "}
|
||||
Episodes{" "}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={muted()}>tab to toggle</text>
|
||||
</box>
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<LoadingIndicator label="Searching…" />
|
||||
</Show>
|
||||
@@ -263,23 +393,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>
|
||||
);
|
||||
}}
|
||||
@@ -289,7 +443,7 @@ function SearchPage() {
|
||||
<text fg={muted()}>
|
||||
{inputActive()
|
||||
? "Enter to search · Esc to defocus"
|
||||
: "j/k recents · s to type · h back"}
|
||||
: "j/k recents · s to type · tab scope · h back"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
@@ -305,7 +459,9 @@ function SearchPage() {
|
||||
<text fg={muted()}>
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
: searchStore.scope() === "episode"
|
||||
? "Enter a search term to find episodes"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
@@ -323,7 +479,6 @@ function SearchPage() {
|
||||
ref={ref}
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||
onMouseDown={() => {
|
||||
@@ -333,10 +488,12 @@ 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}
|
||||
{result.kind === "episode"
|
||||
? result.episode.title
|
||||
: result.podcast.title}
|
||||
</text>
|
||||
<Show when={result.podcast.isSubscribed}>
|
||||
<text
|
||||
@@ -346,14 +503,24 @@ function SearchPage() {
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={result.podcast.author}>
|
||||
{result.kind === "episode" ? (
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {result.podcast.author}
|
||||
{result.podcast.title} ·{" "}
|
||||
{formatDate(result.episode.pubDate)}
|
||||
</text>
|
||||
</Show>
|
||||
) : (
|
||||
<Show when={result.podcast.author}>
|
||||
<text
|
||||
fg={index() === fi() ? theme.surface : muted()}
|
||||
paddingLeft={2}
|
||||
>
|
||||
by {result.podcast.author}
|
||||
</text>
|
||||
</Show>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
@@ -371,6 +538,10 @@ function SearchPage() {
|
||||
<strong>Search</strong>
|
||||
</text>
|
||||
<text fg={muted()}>Type a query, press Enter to search.</text>
|
||||
<text fg={muted()}>
|
||||
Tab toggles Shows ↔ Episodes (episode search finds guests
|
||||
and topics).
|
||||
</text>
|
||||
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||
<box height={1} />
|
||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||
@@ -387,52 +558,102 @@ function SearchPage() {
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(result) => (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{result().podcast.title}</strong>
|
||||
</text>
|
||||
<Show when={result().podcast.author}>
|
||||
<text fg={muted()}>by {result().podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{result().podcast.description!.slice(0, 400)}
|
||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
{(result) => {
|
||||
const r = result();
|
||||
if (r.kind === "episode") {
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{r.episode.title}</strong>
|
||||
</text>
|
||||
<text fg={theme.textSecondary}>{r.podcast.title}</text>
|
||||
<Show when={r.podcast.author}>
|
||||
<text fg={muted()}>by {r.podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={r.episode.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{r.episode.description!.slice(0, 400)}
|
||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Published: {formatDate(r.episode.pubDate)}
|
||||
</text>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={r.sourceName}>
|
||||
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!r.podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>
|
||||
enter: subscribe to show · h: back to query
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
||||
</text>
|
||||
<Show when={result().sourceName}>
|
||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!result().podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={result().podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
</box>
|
||||
)}
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="column" gap={1} padding={1}>
|
||||
<text fg={theme.text}>
|
||||
<strong>{r.podcast.title}</strong>
|
||||
</text>
|
||||
<Show when={r.podcast.author}>
|
||||
<text fg={muted()}>by {r.podcast.author}</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.description}>
|
||||
<text fg={theme.textSecondary}>
|
||||
{r.podcast.description!.slice(0, 400)}
|
||||
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={muted()}>
|
||||
Feed:{" "}
|
||||
{r.podcast.feedUrl ||
|
||||
"not listed by source — resolves on subscribe"}
|
||||
</text>
|
||||
<text fg={muted()}>
|
||||
Updated: {formatDate(r.podcast.lastUpdated)}
|
||||
</text>
|
||||
<Show when={r.sourceName}>
|
||||
<text fg={muted()}>Source: {r.sourceName}</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<Show when={!r.podcast.isSubscribed}>
|
||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||
</Show>
|
||||
<Show when={r.podcast.isSubscribed}>
|
||||
<text fg={theme.success}>Already subscribed</text>
|
||||
</Show>
|
||||
<box height={1} />
|
||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
||||
</box>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
);
|
||||
|
||||
const currentLabel = () =>
|
||||
depth() === 0
|
||||
? `Search · ${recents().length} recent`
|
||||
: `Results · ${results().length}`;
|
||||
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
|
||||
|
||||
return (
|
||||
<PaneRow
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
@@ -121,7 +211,7 @@ export function usePreferencesItems(): SettingItem[] {
|
||||
kind: "select",
|
||||
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||
help: () =>
|
||||
`How the Feed list loads older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||
cycle: (dir) => {
|
||||
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -11,16 +11,24 @@
|
||||
* right-pane key conflicts).
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js";
|
||||
import { createSignal, For, Show, onMount } from "solid-js";
|
||||
import { Renderable } from "@opentui/core";
|
||||
import { useFeedStore } from "@/stores/feed";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||
import { useDialog } from "@/ui/dialog";
|
||||
import { useToast } from "@/ui/toast";
|
||||
import {
|
||||
resolveSourceCredentials,
|
||||
savePodcastIndexCredentials,
|
||||
} from "@/utils/source-credentials";
|
||||
import { SourceType } from "@/types/source";
|
||||
import type { PodcastSource } from "@/types/source";
|
||||
import type { SettingItem } from "./types";
|
||||
|
||||
export function useSourceItems(): SettingItem[] {
|
||||
const feedStore = useFeedStore();
|
||||
const dialog = useDialog();
|
||||
|
||||
const typeBadge = (s: PodcastSource) =>
|
||||
s.type === SourceType.API
|
||||
@@ -48,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
|
||||
kind: "toggle",
|
||||
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||
help: () =>
|
||||
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => feedStore.toggleSource(s.id),
|
||||
s.id === "podcastindex"
|
||||
? `Source: ${s.name} (open podcast directory)\nEnabled: ${s.enabled}\nSpace to ${s.enabled ? "disable" : "enable"}: enabling asks for API keys.\nKeys are masked in the UI and stored in the macOS keychain\n(encrypted at rest), falling back to config.json when the\nkeychain is unavailable; they are kept when disabled.`
|
||||
: `Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
||||
toggle: () => {
|
||||
// Enabling Podcast Index requires credentials: ask first
|
||||
// (prefilled with the stored key, masked) instead of flipping
|
||||
// the source into a key-less "on" state. Disabling never
|
||||
// clears the stored credentials.
|
||||
if (s.id === "podcastindex" && !s.enabled) {
|
||||
dialog.push(() => <PodcastIndexCredentialsDialog />);
|
||||
return;
|
||||
}
|
||||
feedStore.toggleSource(s.id);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +123,9 @@ function AddSourceForm() {
|
||||
onInput={setName}
|
||||
placeholder="My Custom Feed"
|
||||
width={25}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -116,6 +139,9 @@ function AddSourceForm() {
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
width={35}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
@@ -145,3 +171,152 @@ function AddSourceForm() {
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mask a stored credential for prefill: first 3 chars then "...". */
|
||||
const maskCredential = (value: string): string => `${value.slice(0, 3)}...`;
|
||||
|
||||
/** Credentials popup shown when enabling the Podcast Index source. Prefilled
|
||||
* (masked) with stored credentials so re-enabling just needs Enter; leaving
|
||||
* a masked field untouched keeps the stored value. Credentials are saved to
|
||||
* the macOS keychain (encrypted at rest) with a plaintext config.json
|
||||
* fallback when the keychain is unavailable. */
|
||||
function PodcastIndexCredentialsDialog() {
|
||||
const feedStore = useFeedStore();
|
||||
const { theme } = useTheme();
|
||||
const dialog = useDialog();
|
||||
const toast = useToast();
|
||||
const source = feedStore.sources().find((s) => s.id === "podcastindex");
|
||||
const [key, setKey] = createSignal("");
|
||||
const [secret, setSecret] = createSignal("");
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [saving, setSaving] = createSignal(false);
|
||||
// Yield navigation keybinds to the Shell router while an input is focused.
|
||||
const keyRef = useInputFocusNav();
|
||||
const secretRef = useInputFocusNav();
|
||||
let keyEl: Renderable | null | undefined;
|
||||
let secretEl: Renderable | null | undefined;
|
||||
|
||||
onMount(() => {
|
||||
// Prefill stored credentials (masked) when re-enabling after a
|
||||
// disable — toggling off never clears them. Masked either way, so a
|
||||
// plaintext-stored key never appears in full in the UI.
|
||||
if (source) {
|
||||
resolveSourceCredentials(source)
|
||||
.then((stored) => {
|
||||
if (stored?.apiKey) setKey(maskCredential(stored.apiKey));
|
||||
if (stored?.apiSecret) setSecret(maskCredential(stored.apiSecret));
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
setTimeout(() => keyEl?.focus(), 1);
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (saving()) return;
|
||||
const stored = source
|
||||
? await resolveSourceCredentials(source).catch(() => null)
|
||||
: null;
|
||||
const keyValue = key().trim();
|
||||
const secretValue = secret().trim();
|
||||
// A field still showing its masked prefill means "keep what's stored".
|
||||
const apiKey =
|
||||
stored?.apiKey && keyValue === maskCredential(stored.apiKey)
|
||||
? stored.apiKey
|
||||
: keyValue;
|
||||
const apiSecret =
|
||||
stored?.apiSecret && secretValue === maskCredential(stored.apiSecret)
|
||||
? stored.apiSecret
|
||||
: secretValue;
|
||||
if (!apiKey || !apiSecret) {
|
||||
setError(
|
||||
"Both API key and secret are required (free at podcastindex.org)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const ok = await savePodcastIndexCredentials(apiKey, apiSecret).catch(
|
||||
() => false,
|
||||
);
|
||||
setSaving(false);
|
||||
if (!ok) {
|
||||
// Keychain unavailable (non-macOS, locked, sandboxed): plaintext
|
||||
// fallback on the source so the fallback search still works.
|
||||
feedStore.updateSource("podcastindex", {
|
||||
hasCredentials: true,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey,
|
||||
apiSecret,
|
||||
enabled: true,
|
||||
});
|
||||
toast.show({
|
||||
title: "Credentials stored in config.json",
|
||||
message: "macOS keychain unavailable — API keys saved unencrypted.",
|
||||
variant: "warning",
|
||||
});
|
||||
dialog.pop();
|
||||
return;
|
||||
}
|
||||
feedStore.updateSource("podcastindex", {
|
||||
hasCredentials: true,
|
||||
credentialStorage: "keychain",
|
||||
enabled: true,
|
||||
});
|
||||
dialog.pop();
|
||||
};
|
||||
|
||||
return (
|
||||
<box
|
||||
border
|
||||
title="Podcast Index API Keys"
|
||||
padding={1}
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
Free key + secret from https://podcastindex.org/. Used as a
|
||||
fallback when other sources return fewer than 3 results.
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>API Key:</text>
|
||||
<input
|
||||
ref={(el: Renderable | null | undefined) => {
|
||||
keyRef(el);
|
||||
keyEl = el;
|
||||
}}
|
||||
value={key()}
|
||||
onInput={setKey}
|
||||
onSubmit={() => secretEl?.focus()}
|
||||
placeholder="e.g. UXKCGDSYGUUEVQJSYDZH"
|
||||
width={30}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.text}>API Secret:</text>
|
||||
<input
|
||||
ref={(el: Renderable | null | undefined) => {
|
||||
secretRef(el);
|
||||
secretEl = el;
|
||||
}}
|
||||
value={secret()}
|
||||
onInput={setSecret}
|
||||
onSubmit={() => save()}
|
||||
placeholder="e.g. yzJe2eE7XV-3eY576dyRZ6wXyAbndh6LUrCZ8KN|"
|
||||
width={40}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.accent}
|
||||
cursorColor={theme.accent}
|
||||
/>
|
||||
</box>
|
||||
<Show when={error()}>{(e) => <text fg={theme.error}>{e()}</text>}</Show>
|
||||
<Show when={saving()}>
|
||||
<text fg={theme.textMuted}>Storing credentials...</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>
|
||||
[Enter] save · [Esc] cancel — keys stay stored when disabled.
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Episode } from "../types/episode";
|
||||
import type { PodcastSource } from "../types/source";
|
||||
import { DEFAULT_SOURCES } from "../types/source";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||
import {
|
||||
loadFeedsFromFile,
|
||||
saveFeedsToFile,
|
||||
@@ -18,6 +20,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 */
|
||||
@@ -42,6 +45,61 @@ function saveSources(sources: PodcastSource[]): void {
|
||||
saveSourcesToFile(sources);
|
||||
}
|
||||
|
||||
/** Move plaintext apiKey/apiSecret (pre-keychain persistence) into the macOS
|
||||
* keychain, marking the source hasCredentials and stripping the plaintext.
|
||||
* When the keychain is unavailable the plaintext stays (marked as the
|
||||
* plaintext storage backend) so the source keeps working.
|
||||
* Returns the same array when nothing needed migrating. */
|
||||
async function migratePlaintextCredentials(
|
||||
sources: PodcastSource[],
|
||||
): Promise<PodcastSource[]> {
|
||||
let changed = false;
|
||||
const migrated: PodcastSource[] = [];
|
||||
for (const source of sources) {
|
||||
if (
|
||||
source.id === "podcastindex" &&
|
||||
source.apiKey &&
|
||||
source.apiSecret &&
|
||||
!source.hasCredentials
|
||||
) {
|
||||
const ok = await savePodcastIndexCredentials(
|
||||
source.apiKey,
|
||||
source.apiSecret,
|
||||
);
|
||||
if (ok) {
|
||||
migrated.push({
|
||||
...source,
|
||||
apiKey: undefined,
|
||||
apiSecret: undefined,
|
||||
hasCredentials: true,
|
||||
credentialStorage: "keychain",
|
||||
});
|
||||
} else {
|
||||
migrated.push({
|
||||
...source,
|
||||
hasCredentials: true,
|
||||
credentialStorage: "plaintext",
|
||||
});
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
migrated.push(source);
|
||||
}
|
||||
return changed ? migrated : sources;
|
||||
}
|
||||
|
||||
/** True when two episode lists hold the same episodes (id-set equality,
|
||||
* order-insensitive). Refreshes compare fetched content against this so an
|
||||
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
|
||||
* "updated" sort — instead of reordering the list on every background
|
||||
* refresh. */
|
||||
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const ids = new Set(a.map((e) => e.id));
|
||||
return b.every((e) => ids.has(e.id));
|
||||
}
|
||||
|
||||
/** Create feed store */
|
||||
function createFeedStore() {
|
||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||
@@ -184,6 +242,17 @@ function createFeedStore() {
|
||||
sourceId: string,
|
||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||
): Promise<Feed | null> => {
|
||||
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
|
||||
// feed URL; resolve the real feed from its directory page before
|
||||
// subscribing. Refuse when it can't be resolved rather than adding a
|
||||
// broken feed.
|
||||
if (!podcast.feedUrl) {
|
||||
if (!podcast.directoryUrl) return null;
|
||||
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
|
||||
if (!resolved) return null;
|
||||
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
|
||||
}
|
||||
|
||||
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||
@@ -209,68 +278,114 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
||||
* only when the content actually changed (see sameEpisodes). Returns the
|
||||
* ORIGINAL array reference when nothing changed so callers skip
|
||||
* persistence entirely — a refresh that fetched identical episodes must
|
||||
* not re-sort the "updated" view. */
|
||||
const applyRefreshedEpisodes = (
|
||||
prev: Feed[],
|
||||
feedId: string,
|
||||
episodes: Episode[],
|
||||
): Feed[] => {
|
||||
let changed = false;
|
||||
const updated = prev.map((f) => {
|
||||
if (f.id !== feedId) return f;
|
||||
if (sameEpisodes(f.episodes, episodes)) return f;
|
||||
changed = true;
|
||||
return { ...f, episodes, lastUpdated: new Date() };
|
||||
});
|
||||
return changed ? updated : prev;
|
||||
};
|
||||
|
||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||
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,
|
||||
feedId,
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
const updated = prev.map((f) =>
|
||||
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
|
||||
);
|
||||
saveFeeds(updated);
|
||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
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 */
|
||||
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
|
||||
* atomic update. Per-feed incremental setFeeds re-sorted the list once
|
||||
* per completion (each refresh bumped lastUpdated and the "updated" sort
|
||||
* re-ran), which showed up as the list order flapping until the batch
|
||||
* finished. */
|
||||
const refreshAllFeeds = async () => {
|
||||
setIsLoadingFeeds(true);
|
||||
try {
|
||||
const currentFeeds = feeds();
|
||||
for (const feed of currentFeeds) {
|
||||
await refreshFeed(feed.id);
|
||||
}
|
||||
const results = await Promise.all(
|
||||
currentFeeds.map(async (feed) => [
|
||||
feed.id,
|
||||
await fetchEpisodes(
|
||||
feed.podcast.feedUrl,
|
||||
MAX_EPISODES_REFRESH,
|
||||
feed.id,
|
||||
),
|
||||
] as const),
|
||||
);
|
||||
setFeeds((prev) => {
|
||||
let updated = prev;
|
||||
for (const [feedId, episodes] of results) {
|
||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
||||
}
|
||||
if (updated !== prev) saveFeeds(updated);
|
||||
return updated;
|
||||
});
|
||||
// Global auto-download: one idempotent pass after the batch.
|
||||
runAutoDownload();
|
||||
} finally {
|
||||
setIsLoadingFeeds(false);
|
||||
}
|
||||
@@ -280,7 +395,30 @@ 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") ?? [];
|
||||
// Default sources fill gaps in persisted configs (so new defaults like
|
||||
// the Podcast Index fallback reach existing installs), while a
|
||||
// persisted source with the same id always wins over its default —
|
||||
// user edits (keys, enabled, country) are never clobbered.
|
||||
const mergedSources = [
|
||||
...migratedSources,
|
||||
...DEFAULT_SOURCES.filter(
|
||||
(defaultSource) =>
|
||||
!migratedSources.some((s) => s.id === defaultSource.id),
|
||||
),
|
||||
];
|
||||
if (mergedSources.length > 0) {
|
||||
// One-time credential migration: sources persisted with plaintext
|
||||
// apiKey/apiSecret (pre-keychain builds) move into the macOS
|
||||
// keychain and are stripped from config.json.
|
||||
const secured = await migratePlaintextCredentials(mergedSources);
|
||||
setSources(secured);
|
||||
if (secured !== mergedSources) saveSources(secured);
|
||||
}
|
||||
await refreshAllFeeds();
|
||||
})();
|
||||
|
||||
@@ -359,7 +497,7 @@ function createFeedStore() {
|
||||
/** Remove a source */
|
||||
const removeSource = (sourceId: string) => {
|
||||
// Don't remove default sources
|
||||
if (sourceId === "itunes" || sourceId === "rss") return false;
|
||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
||||
|
||||
setSources((prev) => {
|
||||
const updated = prev.filter((s) => s.id !== sourceId);
|
||||
@@ -477,13 +615,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 +654,7 @@ function createFeedStore() {
|
||||
removeSource,
|
||||
toggleSource,
|
||||
updateSource,
|
||||
setAutoDownload,
|
||||
runAutoDownload: runAutoDownloadNow,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||
import { useFeedStore } from "./feed";
|
||||
import type { SearchResult } from "../types/source";
|
||||
import type { SearchResult, SearchScope } from "../types/source";
|
||||
|
||||
const STORAGE_KEY = "podtui_search_history";
|
||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
export interface SearchState {
|
||||
@@ -41,6 +42,27 @@ function saveHistory(history: string[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||
function loadScope(): SearchScope {
|
||||
if (typeof localStorage === "undefined") return "podcast";
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
|
||||
return stored === "episode" ? "episode" : "podcast";
|
||||
} catch {
|
||||
return "podcast";
|
||||
}
|
||||
}
|
||||
|
||||
/** Save search scope to localStorage */
|
||||
function saveScope(scope: SearchScope): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
/** Create search store */
|
||||
export function createSearchStore() {
|
||||
const feedStore = useFeedStore();
|
||||
@@ -50,6 +72,13 @@ export function createSearchStore() {
|
||||
const [error, setError] = createSignal<string | null>(null);
|
||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||
|
||||
/** Set the search scope (shows vs episodes) and persist it. */
|
||||
const setScope = (next: SearchScope) => {
|
||||
setScopeState(next);
|
||||
saveScope(next);
|
||||
};
|
||||
|
||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||
const feeds = feedStore.feeds();
|
||||
@@ -110,9 +139,14 @@ export function createSearchStore() {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
});
|
||||
const searchResults =
|
||||
scope() === "episode"
|
||||
? await searchEpisodes(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
})
|
||||
: await searchPodcasts(q, sourceIds, sources, {
|
||||
cacheTtl: CACHE_TTL,
|
||||
});
|
||||
|
||||
setResults(applySubscribedStatus(searchResults));
|
||||
} catch (e) {
|
||||
@@ -187,6 +221,7 @@ export function createSearchStore() {
|
||||
error,
|
||||
history,
|
||||
selectedSources,
|
||||
scope,
|
||||
|
||||
// Actions
|
||||
search,
|
||||
@@ -195,6 +230,7 @@ export function createSearchStore() {
|
||||
clearHistory,
|
||||
removeFromHistory,
|
||||
setSelectedSources,
|
||||
setScope,
|
||||
markSubscribed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -12,8 +12,12 @@ export interface Podcast {
|
||||
description: string
|
||||
/** Cover image URL */
|
||||
coverUrl?: string
|
||||
/** RSS feed URL */
|
||||
/** RSS feed URL. Empty when the directory lists the show without a feed
|
||||
* (e.g. shows delisted from Apple Podcasts); see directoryUrl. */
|
||||
feedUrl: string
|
||||
/** Directory listing page (e.g. Apple Podcasts) for shows whose feed URL
|
||||
* the directory omits — used to resolve the real feed at subscribe time. */
|
||||
directoryUrl?: string
|
||||
/** Author/creator name */
|
||||
author?: string
|
||||
/** Podcast categories */
|
||||
|
||||
@@ -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]"). */
|
||||
/** How the Feed and per-show episode lists load 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). */
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
* Podcast source type definitions for PodTUI
|
||||
*/
|
||||
|
||||
import type { Episode } from "./episode"
|
||||
import type { Podcast } from "./podcast"
|
||||
|
||||
/** Source type enumeration */
|
||||
export enum SourceType {
|
||||
/** RSS feed URL */
|
||||
@@ -22,8 +25,21 @@ export interface PodcastSource {
|
||||
type: SourceType
|
||||
/** Base URL for the source */
|
||||
baseUrl: string
|
||||
/** API key (if required) */
|
||||
/** API key — live only when the keychain is unavailable and the source
|
||||
* uses the plaintext fallback (credentialStorage "plaintext"). Legacy
|
||||
* plaintext keys are migrated to the OS keychain on load and stripped. */
|
||||
apiKey?: string
|
||||
/** API secret (e.g. Podcast Index signature auth) — same lifecycle as
|
||||
* apiKey: held in the OS keychain by default, live on the source only
|
||||
* under the plaintext fallback. */
|
||||
apiSecret?: string
|
||||
/** True when this source's credentials are stored. A source is usable once
|
||||
* enabled. */
|
||||
hasCredentials?: boolean
|
||||
/** Where this source's credentials live: the OS keychain (encrypted at
|
||||
* rest) by default, or config.json as a plaintext fallback when the
|
||||
* keychain is unavailable (e.g. non-macOS). */
|
||||
credentialStorage?: "keychain" | "plaintext"
|
||||
/** Whether source is enabled */
|
||||
enabled: boolean
|
||||
/** Source icon/logo URL */
|
||||
@@ -78,20 +94,39 @@ export enum SearchSortField {
|
||||
POPULARITY = "popularity",
|
||||
}
|
||||
|
||||
/** Search result */
|
||||
export interface SearchResult {
|
||||
/** What a directory search targets: shows or individual episodes. */
|
||||
export type SearchScope = "podcast" | "episode"
|
||||
|
||||
/** Fields shared by every search result. */
|
||||
export interface SearchResultBase {
|
||||
/** Source that returned this result */
|
||||
sourceId: string
|
||||
/** Source display name */
|
||||
sourceName?: string
|
||||
/** Source type */
|
||||
sourceType?: SourceType
|
||||
/** Podcast data */
|
||||
podcast: import("./podcast").Podcast
|
||||
/** Relevance score (0-1) */
|
||||
score?: number
|
||||
}
|
||||
|
||||
/** A show found by directory search. */
|
||||
export interface PodcastSearchResult extends SearchResultBase {
|
||||
kind: "podcast"
|
||||
/** Podcast data */
|
||||
podcast: Podcast
|
||||
}
|
||||
|
||||
/** A single episode found by directory search. `podcast` is its parent show
|
||||
* — used for display context and for subscribing to the show. */
|
||||
export interface EpisodeSearchResult extends SearchResultBase {
|
||||
kind: "episode"
|
||||
podcast: Podcast
|
||||
episode: Episode
|
||||
}
|
||||
|
||||
/** Search result */
|
||||
export type SearchResult = PodcastSearchResult | EpisodeSearchResult
|
||||
|
||||
/** Default podcast sources */
|
||||
export const DEFAULT_SOURCES: PodcastSource[] = [
|
||||
{
|
||||
@@ -106,11 +141,14 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
|
||||
allowExplicit: true,
|
||||
},
|
||||
{
|
||||
id: "rss",
|
||||
name: "RSS Feed",
|
||||
type: SourceType.RSS,
|
||||
baseUrl: "",
|
||||
enabled: true,
|
||||
description: "Add podcasts via RSS feed URL",
|
||||
id: "podcastindex",
|
||||
name: "Podcast Index",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||
enabled: false,
|
||||
description:
|
||||
"Open podcast directory. Fallback when other sources return few results; requires a free API key + secret from podcastindex.org.",
|
||||
language: "en",
|
||||
allowExplicit: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.5–3s,
|
||||
* 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.5–100.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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -71,11 +71,15 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
||||
"open",
|
||||
"open-interactive",
|
||||
"search",
|
||||
"search-scope-toggle",
|
||||
"filter",
|
||||
"sort",
|
||||
"toggle-hidden",
|
||||
"refresh",
|
||||
"unsubscribe",
|
||||
"download",
|
||||
"delete-download",
|
||||
"whitelist-toggle",
|
||||
]);
|
||||
|
||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||
|
||||
@@ -132,8 +132,6 @@ export type AppEvents = {
|
||||
"media.toggle": {};
|
||||
"media.volumeUp": {};
|
||||
"media.volumeDown": {};
|
||||
"media.seekForward": {};
|
||||
"media.seekBackward": {};
|
||||
"media.speedCycle": {};
|
||||
};
|
||||
|
||||
|
||||
61
src/utils/itunes-feed-resolver.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* iTunes feed resolution for shows delisted from Apple Podcasts.
|
||||
*
|
||||
* The iTunes Search API returns `feedUrl: null` for shows that left Apple
|
||||
* Podcasts (e.g. The Daily Wire's shows in 2021) — the directory keeps a
|
||||
* metadata-only stub. The show's public Apple Podcasts page still embeds the
|
||||
* real feed URL in its JSON state (`showOffer.feedUrl`), so subscribing can
|
||||
* resolve it from there.
|
||||
*/
|
||||
|
||||
/** `"feedUrl":"https://..."` as embedded in the Apple page's JSON state. */
|
||||
const FEED_URL_RE = /"feedUrl"\s*:\s*"(https?:\/\/[^"]+)"/
|
||||
|
||||
/**
|
||||
* Extract the show's feed URL from an Apple Podcasts page's HTML.
|
||||
*
|
||||
* The page embeds `showOffer` blocks for the show AND for related shows, each
|
||||
* with its own feedUrl, and Apple serves multiple JSON variants — the main
|
||||
* show's showOffer may sit adjacent to its adamId or thousands of chars later.
|
||||
* Anchor on the collection id from `directoryUrl` (`"adamId":"<id>"`) and take
|
||||
* the FIRST feedUrl after it (the main show's content precedes related shows'
|
||||
* in the document). Falls back to the first feedUrl in the document only when
|
||||
* the id isn't present in the URL. Returns null when no trustworthy match
|
||||
* exists (page restructured, no feed) — callers must not guess.
|
||||
*/
|
||||
export const extractFeedUrlFromPage = (
|
||||
html: string,
|
||||
directoryUrl: string,
|
||||
): string | null => {
|
||||
const idMatch = /[?/]id(\d+)/.exec(directoryUrl)
|
||||
if (!idMatch) {
|
||||
const fallback = FEED_URL_RE.exec(html)
|
||||
return fallback ? fallback[1] : null
|
||||
}
|
||||
|
||||
const adamIdx = html.search(new RegExp(`"adamId"\\s*:\\s*"${idMatch[1]}"`))
|
||||
if (adamIdx < 0) return null
|
||||
|
||||
const fromAdam = new RegExp(FEED_URL_RE.source, "g")
|
||||
fromAdam.lastIndex = adamIdx
|
||||
const match = fromAdam.exec(html)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a delisted show's RSS feed from its Apple Podcasts page.
|
||||
* Returns null on network failure or when the page has no resolvable feed.
|
||||
*/
|
||||
export const resolveItunesFeedUrl = async (
|
||||
directoryUrl: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const response = await fetch(directoryUrl, {
|
||||
headers: { "User-Agent": "PodTUI/1.0" },
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return extractFeedUrlFromPage(await response.text(), directoryUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -60,17 +60,22 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
||||
help: ["~", "f1"],
|
||||
// list ops
|
||||
search: ["s"],
|
||||
"search-scope-toggle": ["tab"],
|
||||
filter: ["f"],
|
||||
sort: [","],
|
||||
"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 */
|
||||
|
||||
@@ -57,13 +57,13 @@ export function rootFrameFor(
|
||||
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||
//
|
||||
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
|
||||
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
|
||||
// give `current` the combined 4/5.
|
||||
// Current ratios: parent : current : preview = 2 : 5 : 3, i.e. 20% / 50% / 30%
|
||||
// of the row width (2 : 5 : 3 of 10). 2-pane tabs drop the preview slot and
|
||||
// give `current` the combined 8/10 (80%).
|
||||
export const PANE_RATIO = {
|
||||
parent: 1,
|
||||
current: 2,
|
||||
preview: 2,
|
||||
parent: 2,
|
||||
current: 5,
|
||||
preview: 3,
|
||||
} as const;
|
||||
|
||||
// Number of *focusable* content panes per tab. The three visible columns
|
||||
|
||||
115
src/utils/nerd-fonts.ts
Normal 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] ?? "" : "";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { searchSourceByType } from "./source-searcher";
|
||||
import { searchSourceByType, searchEpisodesByType } from "./source-searcher";
|
||||
import { parseRSSFeed } from "../api/rss-parser";
|
||||
import { SourceType } from "../types/source";
|
||||
import type { PodcastSource, SearchResult } from "../types/source";
|
||||
@@ -17,6 +17,12 @@ const rateLimitState = new Map<string, number[]>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60000;
|
||||
const RATE_LIMIT_MAX_CALLS = 20;
|
||||
|
||||
/** Minimum results a primary search must return before the Podcast Index
|
||||
* fallback runs — the open directory is only consulted when Apple's came up
|
||||
* thin, exactly the case where it adds shows Apple lacks. */
|
||||
const FALLBACK_MIN_RESULTS = 3;
|
||||
const FALLBACK_SOURCE_ID = "podcastindex";
|
||||
|
||||
const throttleSource = async (sourceId: string) => {
|
||||
const now = Date.now();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
||||
@@ -36,9 +42,9 @@ const throttleSource = async (sourceId: string) => {
|
||||
rateLimitState.set(sourceId, updated);
|
||||
};
|
||||
|
||||
const buildCacheKey = (query: string, sourceIds: string[]) => {
|
||||
const buildCacheKey = (query: string, sourceIds: string[], prefix: string) => {
|
||||
const keySources = [...sourceIds].sort().join(",");
|
||||
return `${query.toLowerCase()}::${keySources}`;
|
||||
return `${prefix}:${query.toLowerCase()}::${keySources}`;
|
||||
};
|
||||
|
||||
const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
@@ -47,8 +53,12 @@ const isCacheValid = (entry: SearchCacheEntry, ttl: number) =>
|
||||
const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
||||
const map = new Map<string, SearchResult>();
|
||||
for (const result of results) {
|
||||
// Episodes dedupe on the episode id; shows on feedUrl/id/title. The two
|
||||
// scopes never mix within one result set, so keys can't collide.
|
||||
const key =
|
||||
result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||
result.kind === "episode"
|
||||
? `episode:${result.episode.id}`
|
||||
: result.podcast.feedUrl || result.podcast.id || result.podcast.title;
|
||||
const existing = map.get(key);
|
||||
if (!existing || (result.score ?? 0) > (existing.score ?? 0)) {
|
||||
map.set(key, result);
|
||||
@@ -87,6 +97,7 @@ export const searchByFeedUrl = async (
|
||||
sourceId: "direct-rss",
|
||||
sourceName: "RSS Feed",
|
||||
sourceType: SourceType.RSS,
|
||||
kind: "podcast",
|
||||
// parseRSSFeed marks feeds subscribed; a search result should start
|
||||
// unsubscribed so the store can flag it correctly if already added.
|
||||
podcast: { ...podcast, isSubscribed: false },
|
||||
@@ -98,11 +109,20 @@ export const searchByFeedUrl = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const searchPodcasts = async (
|
||||
type SourceSearcher = (
|
||||
query: string,
|
||||
source: PodcastSource,
|
||||
) => Promise<SearchResult[]>;
|
||||
|
||||
const searchSources = async (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
searcher: SourceSearcher,
|
||||
cachePrefix: string,
|
||||
options: SearchOptions = {},
|
||||
/** Optional source id consulted as a low-result fallback (show scope only). */
|
||||
fallbackSourceId?: string,
|
||||
): Promise<SearchResult[]> => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return [];
|
||||
@@ -124,6 +144,7 @@ export const searchPodcasts = async (
|
||||
const cacheKey = buildCacheKey(
|
||||
trimmed,
|
||||
activeSources.map((s) => s.id),
|
||||
cachePrefix,
|
||||
);
|
||||
const cached = searchCache.get(cacheKey);
|
||||
if (cached && isCacheValid(cached, cacheTtl)) {
|
||||
@@ -137,7 +158,7 @@ export const searchPodcasts = async (
|
||||
activeSources.map(async (source) => {
|
||||
try {
|
||||
await throttleSource(source.id);
|
||||
const sourceResults = await searchSourceByType(trimmed, source);
|
||||
const sourceResults = await searcher(trimmed, source);
|
||||
results.push(...sourceResults);
|
||||
} catch (error) {
|
||||
errors.push(error as Error);
|
||||
@@ -146,7 +167,32 @@ export const searchPodcasts = async (
|
||||
);
|
||||
|
||||
const deduped = dedupeResults(results);
|
||||
const sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
let sorted = deduped.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
|
||||
// Low-result fallback: when the primary sources came back thin, consult
|
||||
// the fallback source — but only when it's enabled AND keyed (a key-less
|
||||
// default must never send requests) and it didn't already run as a primary
|
||||
// source above. A fallback failure never sinks the primary results.
|
||||
if (sorted.length < FALLBACK_MIN_RESULTS && fallbackSourceId) {
|
||||
const fallback = sources.find(
|
||||
(s) =>
|
||||
s.id === fallbackSourceId &&
|
||||
s.enabled &&
|
||||
s.hasCredentials === true &&
|
||||
!activeSources.includes(s),
|
||||
);
|
||||
if (fallback) {
|
||||
try {
|
||||
await throttleSource(fallback.id);
|
||||
const fallbackResults = await searcher(trimmed, fallback);
|
||||
sorted = dedupeResults([...sorted, ...fallbackResults]).sort(
|
||||
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sorted.length === 0 && errors.length > 0) {
|
||||
throw new Error("Search failed for all sources");
|
||||
@@ -156,4 +202,39 @@ export const searchPodcasts = async (
|
||||
return sorted;
|
||||
};
|
||||
|
||||
export const searchPodcasts = (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {},
|
||||
): Promise<SearchResult[]> =>
|
||||
searchSources(
|
||||
query,
|
||||
sourceIds,
|
||||
sources,
|
||||
searchSourceByType,
|
||||
"show",
|
||||
options,
|
||||
FALLBACK_SOURCE_ID,
|
||||
);
|
||||
|
||||
/** Episode-scope search: find individual episodes (e.g. a guest appearing
|
||||
* across shows). Shares the source guard, rate limiting, and cache with
|
||||
* searchPodcasts; the cache key is scoped separately so the two result
|
||||
* kinds never collide for the same query. */
|
||||
export const searchEpisodes = (
|
||||
query: string,
|
||||
sourceIds: string[],
|
||||
sources: PodcastSource[],
|
||||
options: SearchOptions = {},
|
||||
): Promise<SearchResult[]> =>
|
||||
searchSources(
|
||||
query,
|
||||
sourceIds,
|
||||
sources,
|
||||
searchEpisodesByType,
|
||||
"episode",
|
||||
options,
|
||||
);
|
||||
|
||||
|
||||
|
||||
100
src/utils/source-credentials.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Credential storage for keyed podcast sources.
|
||||
*
|
||||
* Preferred storage is the macOS keychain (encrypted at rest by the OS),
|
||||
* written through the `security` CLI — no native dependencies. When the
|
||||
* keychain is unavailable (non-macOS, locked, sandboxed) credentials fall
|
||||
* back to plaintext on the source itself (config.json) so the source still
|
||||
* works; `credentialStorage` on the source records which backend was used.
|
||||
*
|
||||
* Credentials are never presented in full — the UI always masks them (first
|
||||
* 3 chars + "..."). The keychain password is passed as an argv value to
|
||||
* `add-generic-password` (standard practice for CLI-driven keychain writes;
|
||||
* the item lands in the login keychain immediately).
|
||||
*/
|
||||
|
||||
import type { PodcastSource } from "../types/source"
|
||||
|
||||
const KEYCHAIN_SERVICE = "podtui"
|
||||
const KEYCHAIN_ACCOUNT = "podcastindex"
|
||||
|
||||
export type Credentials = {
|
||||
apiKey: string
|
||||
apiSecret: string
|
||||
}
|
||||
|
||||
/** Run a `security` subcommand; resolves with exit status + stdout. */
|
||||
async function runSecurity(
|
||||
args: string[],
|
||||
): Promise<{ ok: boolean; stdout: string }> {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ["security", ...args],
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const [stdout] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
])
|
||||
const exitCode = await proc.exited
|
||||
return { ok: exitCode === 0, stdout }
|
||||
} catch {
|
||||
return { ok: false, stdout: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Store Podcast Index credentials in the macOS keychain. True on success. */
|
||||
export async function savePodcastIndexCredentials(
|
||||
apiKey: string,
|
||||
apiSecret: string,
|
||||
): Promise<boolean> {
|
||||
const payload = JSON.stringify({ apiKey, apiSecret })
|
||||
const { ok } = await runSecurity([
|
||||
"add-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
payload,
|
||||
"-U",
|
||||
])
|
||||
return ok
|
||||
}
|
||||
|
||||
/** Read Podcast Index credentials from the macOS keychain. Null when absent
|
||||
* or unreadable (non-macOS, item deleted, keychain locked). */
|
||||
export async function loadPodcastIndexCredentials(): Promise<Credentials | null> {
|
||||
const { ok, stdout } = await runSecurity([
|
||||
"find-generic-password",
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-w",
|
||||
])
|
||||
if (!ok) return null
|
||||
try {
|
||||
const parsed = JSON.parse(stdout.trim()) as Credentials
|
||||
if (!parsed.apiKey || !parsed.apiSecret) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a source's stored credentials: its plaintext fields when saved
|
||||
* with the plaintext fallback, else the macOS keychain. Null when the
|
||||
* source has no usable credentials. */
|
||||
export async function resolveSourceCredentials(
|
||||
source: PodcastSource,
|
||||
): Promise<Credentials | null> {
|
||||
if (source.credentialStorage === "plaintext") {
|
||||
return source.apiKey && source.apiSecret
|
||||
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
|
||||
: null
|
||||
}
|
||||
return loadPodcastIndexCredentials()
|
||||
}
|
||||
@@ -1,107 +1,30 @@
|
||||
import type { Podcast } from "../types/podcast"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { SourceType } from "../types/source"
|
||||
import type { PodcastSource, SearchResult } from "../types/source"
|
||||
import { detectContentType, ContentType } from "../utils/rss-content-detector"
|
||||
import { htmlToText } from "../utils/html-to-text"
|
||||
import { resolveSourceCredentials } from "../utils/source-credentials"
|
||||
|
||||
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
|
||||
artistName?: string
|
||||
feedUrl?: string
|
||||
/** Null for shows delisted from Apple Podcasts (directory stub records). */
|
||||
feedUrl?: string | null
|
||||
artworkUrl100?: string
|
||||
artworkUrl600?: string
|
||||
primaryGenreName?: string
|
||||
releaseDate?: string
|
||||
collectionViewUrl?: string
|
||||
}
|
||||
|
||||
type ItunesResponse = {
|
||||
@@ -109,6 +32,26 @@ type ItunesResponse = {
|
||||
results: ItunesResult[]
|
||||
}
|
||||
|
||||
type ItunesEpisodeResult = {
|
||||
trackId?: number
|
||||
trackName?: string
|
||||
collectionId?: number
|
||||
collectionName?: string
|
||||
artistName?: string
|
||||
description?: string
|
||||
/** Null for episodes of delisted shows (directory stub records). */
|
||||
feedUrl?: string | null
|
||||
episodeUrl?: string
|
||||
/** Duration in milliseconds. */
|
||||
trackTimeMillis?: number
|
||||
releaseDate?: string
|
||||
artworkUrl100?: string
|
||||
artworkUrl600?: string
|
||||
primaryGenreName?: string
|
||||
trackViewUrl?: string
|
||||
collectionViewUrl?: string
|
||||
}
|
||||
|
||||
const buildItunesUrl = (query: string, source: PodcastSource) => {
|
||||
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
|
||||
const url = new URL(baseUrl)
|
||||
@@ -124,8 +67,167 @@ const buildItunesUrl = (query: string, source: PodcastSource) => {
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
|
||||
if (!result.collectionName || !result.feedUrl) return null
|
||||
/** Same as buildItunesUrl but targets episodes instead of shows — this is how
|
||||
* guest/name searches find specific episodes (the term matches episode titles
|
||||
* and show notes). */
|
||||
const buildItunesEpisodeUrl = (query: string, source: PodcastSource) => {
|
||||
const baseUrl = source.baseUrl?.trim() || "https://itunes.apple.com/search"
|
||||
const url = new URL(baseUrl)
|
||||
const params = url.searchParams
|
||||
|
||||
params.set("term", query.trim())
|
||||
params.set("media", "podcast")
|
||||
params.set("entity", "podcastEpisode")
|
||||
params.set("country", source.country ?? "US")
|
||||
params.set("lang", source.language ?? "en_us")
|
||||
params.set("explicit", source.allowExplicit === false ? "No" : "Yes")
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
// ── Podcast Index (fallback directory) ─────────────────────────────────────
|
||||
// Open, community-run directory that includes shows Apple never lists or has
|
||||
// delisted. Requires a user-supplied key + secret (podcastindex.org) and is
|
||||
// used only as a fallback when primary sources return few results (see
|
||||
// search.ts). Feed-first: results carry the feed URL directly, so there is no
|
||||
// delisted-show stub resolution step like iTunes has.
|
||||
|
||||
type PodcastIndexResult = {
|
||||
id?: number
|
||||
title?: string
|
||||
/** Current feed URL. */
|
||||
url?: string
|
||||
/** Show website. */
|
||||
link?: string
|
||||
description?: string
|
||||
author?: string
|
||||
image?: string
|
||||
artwork?: string
|
||||
/** Unix epoch seconds of the feed's last update. */
|
||||
lastUpdateTime?: number
|
||||
/** Apple directory id when known (nullable — not all shows are on Apple). */
|
||||
itunesId?: number | null
|
||||
language?: string
|
||||
explicit?: boolean
|
||||
/** True when the feed is unreachable — drop these. */
|
||||
dead?: boolean
|
||||
episodeCount?: number
|
||||
/** Category id -> name. */
|
||||
categories?: Record<string, string>
|
||||
newestItemPubdate?: number
|
||||
}
|
||||
|
||||
type PodcastIndexResponse = {
|
||||
status?: string | boolean
|
||||
feeds?: PodcastIndexResult[]
|
||||
}
|
||||
|
||||
const sha1Hex = async (input: string): Promise<string> => {
|
||||
const data = new TextEncoder().encode(input)
|
||||
const digest = await crypto.subtle.digest("SHA-1", data)
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
/** Podcast Index auth is header-based: X-Auth-Key + X-Auth-Date (unix epoch
|
||||
* seconds) + Authorization = sha1(key + secret + epoch). No query params.
|
||||
* Credentials resolve from the source's storage backend: the OS keychain
|
||||
* (encrypted at rest) by default, or the source's plaintext fields when the
|
||||
* keychain was unavailable at save time. */
|
||||
const buildPodcastIndexHeaders = async (
|
||||
source: PodcastSource,
|
||||
): Promise<Record<string, string>> => {
|
||||
const credentials = await resolveSourceCredentials(source)
|
||||
const key = credentials?.apiKey
|
||||
const secret = credentials?.apiSecret
|
||||
if (!key || !secret) {
|
||||
throw new Error(
|
||||
`${source.name} credentials are missing — enable the source in Settings → Sources to enter them`,
|
||||
)
|
||||
}
|
||||
const epoch = Math.floor(Date.now() / 1000).toString()
|
||||
const signature = await sha1Hex(key + secret + epoch)
|
||||
return {
|
||||
"User-Agent": "PodTUI/1.0",
|
||||
"X-Auth-Key": key,
|
||||
"X-Auth-Date": epoch,
|
||||
Authorization: signature,
|
||||
}
|
||||
}
|
||||
|
||||
const buildPodcastIndexUrl = (query: string, source: PodcastSource) => {
|
||||
const url = new URL(source.baseUrl)
|
||||
url.searchParams.set("q", query.trim())
|
||||
url.searchParams.set("max", "25")
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const mapPodcastIndexResult = (
|
||||
result: PodcastIndexResult,
|
||||
source: PodcastSource,
|
||||
): Podcast | null => {
|
||||
if (!result.title || !result.url) return null
|
||||
|
||||
const id = result.id
|
||||
? `podcastindex-${result.id}`
|
||||
: `podcastindex-${slugify(result.title)}`
|
||||
|
||||
const descriptionParts = [result.title]
|
||||
if (result.author) descriptionParts.push(`by ${result.author}`)
|
||||
if (result.episodeCount !== undefined)
|
||||
descriptionParts.push(`${result.episodeCount} episodes`)
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.title,
|
||||
description: descriptionParts.join(" • "),
|
||||
feedUrl: result.url,
|
||||
author: result.author,
|
||||
categories: result.categories
|
||||
? Object.values(result.categories)
|
||||
: undefined,
|
||||
coverUrl: result.image || result.artwork,
|
||||
language: result.language,
|
||||
websiteUrl: result.link,
|
||||
lastUpdated: result.lastUpdateTime
|
||||
? new Date(result.lastUpdateTime * 1000)
|
||||
: new Date(),
|
||||
isSubscribed: false,
|
||||
}
|
||||
}
|
||||
|
||||
const searchPodcastIndexSource = async (
|
||||
query: string,
|
||||
source: PodcastSource,
|
||||
): Promise<SearcherResult> => {
|
||||
const headers = await buildPodcastIndexHeaders(source)
|
||||
const response = await fetch(buildPodcastIndexUrl(query, source), {
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${source.name} search failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PodcastIndexResponse
|
||||
const results = (data.feeds ?? [])
|
||||
.filter((item) => !item.dead)
|
||||
.map((item) => mapPodcastIndexResult(item, source))
|
||||
.filter((item): item is Podcast => Boolean(item))
|
||||
|
||||
return results.map((podcast, index) => ({
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
kind: "podcast" as const,
|
||||
podcast,
|
||||
score: 1 - index * 0.02,
|
||||
}))
|
||||
}
|
||||
|
||||
export const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast | null => {
|
||||
if (!result.collectionName) return null
|
||||
|
||||
const id = result.collectionId
|
||||
? `itunes-${result.collectionId}`
|
||||
@@ -135,11 +237,18 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
||||
if (result.artistName) descriptionParts.push(`by ${result.artistName}`)
|
||||
if (result.primaryGenreName) descriptionParts.push(result.primaryGenreName)
|
||||
|
||||
// Shows delisted from Apple Podcasts (e.g. The Daily Wire's shows) come back
|
||||
// as metadata-only stub records with feedUrl null. Keep the stub so the show
|
||||
// stays findable; the real feed is resolved from the directory page at
|
||||
// subscribe time (see itunes-feed-resolver).
|
||||
const feedUrl = result.feedUrl ?? ""
|
||||
|
||||
return {
|
||||
id,
|
||||
title: result.collectionName,
|
||||
description: descriptionParts.join(" • "),
|
||||
feedUrl: result.feedUrl,
|
||||
feedUrl,
|
||||
directoryUrl: feedUrl ? undefined : result.collectionViewUrl,
|
||||
author: result.artistName,
|
||||
categories: result.primaryGenreName ? [result.primaryGenreName] : undefined,
|
||||
coverUrl: result.artworkUrl600 || result.artworkUrl100,
|
||||
@@ -148,7 +257,57 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
||||
}
|
||||
}
|
||||
|
||||
const searchAPISource = async (
|
||||
/**
|
||||
* Clean an iTunes description: detect HTML vs plain text and convert HTML to
|
||||
* readable plain text (mirrors rss-parser's cleanField). iTunes show notes
|
||||
* are often raw HTML.
|
||||
*/
|
||||
const cleanDescription = (raw: string): string => {
|
||||
if (!raw) return ""
|
||||
const decoded = raw
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
if (detectContentType(decoded) === ContentType.HTML) {
|
||||
return htmlToText(decoded)
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an iTunes episode record to a search result: the episode itself plus its
|
||||
* parent show (as a Podcast) so subscribing works exactly like a show result.
|
||||
* Returns null when the record is missing a track or collection name.
|
||||
*/
|
||||
export const mapItunesEpisodeResult = (
|
||||
result: ItunesEpisodeResult,
|
||||
source: PodcastSource,
|
||||
): { podcast: Podcast; episode: Episode } | null => {
|
||||
if (!result.trackName || !result.collectionName) return null
|
||||
|
||||
const podcast = mapItunesResult(result, source)
|
||||
if (!podcast) return null
|
||||
|
||||
const episode: Episode = {
|
||||
id: result.trackId
|
||||
? `itunes-ep-${result.trackId}`
|
||||
: `itunes-ep-${slugify(result.trackName)}`,
|
||||
podcastId: podcast.id,
|
||||
title: result.trackName,
|
||||
description: cleanDescription(result.description ?? ""),
|
||||
audioUrl: result.episodeUrl ?? "",
|
||||
duration: result.trackTimeMillis
|
||||
? Math.round(result.trackTimeMillis / 1000)
|
||||
: 0,
|
||||
pubDate: result.releaseDate ? new Date(result.releaseDate) : new Date(),
|
||||
}
|
||||
|
||||
return { podcast, episode }
|
||||
}
|
||||
|
||||
const searchItunesSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
@@ -156,7 +315,7 @@ const searchAPISource = async (
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`iTunes search failed: ${response.status}`)
|
||||
throw new Error(`${source.name} search failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ItunesResponse
|
||||
@@ -168,28 +327,106 @@ const searchAPISource = async (
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
kind: "podcast" as const,
|
||||
podcast,
|
||||
score: 1 - index * 0.02,
|
||||
}))
|
||||
}
|
||||
|
||||
const searchCustomSource = async (
|
||||
/** Dispatch API-source search by source id: iTunes is the primary directory,
|
||||
* Podcast Index the user-configured fallback (also usable directly). */
|
||||
const searchAPISource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
await delay(300, 650)
|
||||
return makeResults(query, source, 13)
|
||||
switch (source.id) {
|
||||
case "podcastindex":
|
||||
return searchPodcastIndexSource(query, source)
|
||||
default:
|
||||
return searchItunesSource(query, source)
|
||||
}
|
||||
}
|
||||
|
||||
const searchItunesEpisodeSource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
const url = buildItunesEpisodeUrl(query, source)
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${source.name} episode search failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { results: ItunesEpisodeResult[] }
|
||||
const results = data.results
|
||||
.map((item) => mapItunesEpisodeResult(item, source))
|
||||
.filter(
|
||||
(item): item is { podcast: Podcast; episode: Episode } => Boolean(item),
|
||||
)
|
||||
|
||||
return results.map(({ podcast, episode }, index) => ({
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
sourceType: source.type,
|
||||
kind: "episode" as const,
|
||||
podcast,
|
||||
episode,
|
||||
score: 1 - index * 0.02,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Episode-scope API dispatch: only iTunes supports episode-by-term text
|
||||
* search; Podcast Index has no such endpoint (its episode search is
|
||||
* by-person only), so it contributes nothing to episode scope. */
|
||||
const searchEpisodeAPISource = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
if (source.id === "podcastindex") return []
|
||||
return searchItunesEpisodeSource(query, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode-scope dispatch: same backend rules as searchSourceByType — only
|
||||
* API sources (iTunes) can search episodes; RSS/custom sources have no
|
||||
* directory backend.
|
||||
*/
|
||||
export const searchEpisodesByType = async (
|
||||
query: string,
|
||||
source: PodcastSource
|
||||
): Promise<SearcherResult> => {
|
||||
if (source.type === SourceType.RSS) {
|
||||
return searchRSSSource()
|
||||
}
|
||||
if (source.type === SourceType.CUSTOM) {
|
||||
return searchCustomSource()
|
||||
}
|
||||
return searchEpisodeAPISource(query, source)
|
||||
}
|
||||
|
||||
239
tests/audio-stream-reader.test.ts
Normal 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
@@ -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);
|
||||
});
|
||||
});
|
||||
143
tests/feed-pagination.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Per-feed pagination test — the store contract behind the "[Fetch More]"
|
||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||
* page's row.
|
||||
*
|
||||
* addFeed caches the FULL parsed feed while exposing only the first
|
||||
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
|
||||
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
|
||||
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
|
||||
* pins:
|
||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||
* 3. loadMoreEpisodes past the end is a no-op (the feed is untouched).
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||
// feed-refresh.test.ts / feedless-subscribe.test.ts. Track the feeds we add
|
||||
// and remove them in afterAll so whichever file runs next sees a pristine
|
||||
// store (execution order between files is not guaranteed).
|
||||
const addedFeedIds: string[] = [];
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Paged Show</title>
|
||||
<description>Pagination test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makePodcast = (feedUrl: string): Podcast => ({
|
||||
id: feedUrl,
|
||||
title: "Paged Show",
|
||||
description: "Pagination test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Leave the shared singleton as we found it (see addedFeedIds note).
|
||||
const store = useFeedStore();
|
||||
for (const id of addedFeedIds) store.removeFeed(id);
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
||||
const store = useFeedStore();
|
||||
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
|
||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||
title: `Ep ${60 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
// Subscribe window (MAX_EPISODES_SUBSCRIBE = 20) with more cached.
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
|
||||
// One load-more covers the remaining 40 (20 + 50 >= 60).
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(60);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
|
||||
// Exhausted: loadMoreEpisodes is a no-op — the feed object is untouched.
|
||||
const before = store.getFeed(id)!;
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)).toBe(before);
|
||||
});
|
||||
|
||||
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
||||
const store = useFeedStore();
|
||||
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
||||
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
||||
title: `Ep ${120 - i}`,
|
||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
||||
}));
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
const id = feed!.id;
|
||||
addedFeedIds.push(id);
|
||||
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(70);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||
|
||||
await store.loadMoreEpisodes(id);
|
||||
expect(store.getFeed(id)!.episodes.length).toBe(120);
|
||||
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||
});
|
||||
155
tests/feed-refresh.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Feed refresh order-stability regression test.
|
||||
*
|
||||
* My Shows / Feed sort by `lastUpdated` ("updated") by default, and every
|
||||
* refresh bumped it unconditionally — so a startup refresh-all re-sorted the
|
||||
* list once per feed as each fetch landed (order flapping until the batch
|
||||
* finished). These tests pin the contract:
|
||||
*
|
||||
* 1. A refresh that fetches identical episodes does NOT bump lastUpdated —
|
||||
* the feed object is untouched, so the list cannot reorder.
|
||||
* 2. A refresh that fetches genuinely new episodes DOES bump lastUpdated.
|
||||
* 3. refreshAllFeeds applies one atomic update: unchanged feeds keep their
|
||||
* order and timestamps after a full refresh.
|
||||
*
|
||||
* The clock is mocked (fake timers) so the "did lastUpdated advance?" checks
|
||||
* are deterministic — no real sleeps that would race under load.
|
||||
*/
|
||||
|
||||
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-refresh-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
interface ServedEpisode {
|
||||
title: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let servedEpisodes: ServedEpisode[] = [];
|
||||
let feedAId = "";
|
||||
|
||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||
const items = episodes
|
||||
.map(
|
||||
(ep, i) => `<item>
|
||||
<title>${ep.title}</title>
|
||||
<pubDate>${ep.date}</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
)
|
||||
.join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Test Show</title>
|
||||
<description>Regression test feed</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
const makePodcast = (feedUrl: string): Podcast => ({
|
||||
id: feedUrl,
|
||||
title: "Test Show",
|
||||
description: "Regression test feed",
|
||||
author: "tester",
|
||||
feedUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: true,
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".xml")) {
|
||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("refresh with identical episodes does not bump lastUpdated", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes = [
|
||||
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||
];
|
||||
const feedUrl = `http://127.0.0.1:${server!.port}/show-a.xml`;
|
||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||
expect(feed).not.toBeNull();
|
||||
feedAId = feed!.id;
|
||||
|
||||
const before = store.getFeed(feedAId)!;
|
||||
const beforeUpdated = before.lastUpdated.getTime();
|
||||
|
||||
// Advance the (mocked) clock, then refresh with identical content.
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedAId);
|
||||
|
||||
const after = store.getFeed(feedAId)!;
|
||||
expect(after).toBe(before); // same object: no update applied at all
|
||||
expect(after.lastUpdated.getTime()).toBe(beforeUpdated);
|
||||
expect(after.episodes.length).toBe(3);
|
||||
});
|
||||
|
||||
test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
||||
const store = useFeedStore();
|
||||
servedEpisodes.push({ title: "Ep 0 (new)", date: "2026-08-04T00:00:00Z" });
|
||||
|
||||
const before = store.getFeed(feedAId)!.lastUpdated.getTime();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshFeed(feedAId);
|
||||
|
||||
const after = store.getFeed(feedAId)!;
|
||||
expect(after.lastUpdated.getTime()).toBeGreaterThan(before);
|
||||
expect(after.episodes.length).toBe(4);
|
||||
});
|
||||
|
||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||
const store = useFeedStore();
|
||||
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||
// no-op too.
|
||||
const feedBUrl = `http://127.0.0.1:${server!.port}/show-b.xml`;
|
||||
const feedB = await store.addFeed(makePodcast(feedBUrl), "test-source");
|
||||
expect(feedB).not.toBeNull();
|
||||
const feedBId = feedB!.id;
|
||||
|
||||
const orderBefore = store.getFilteredFeeds().map((f) => f.id);
|
||||
const tsBefore: Record<string, number> = {};
|
||||
for (const id of [feedAId, feedBId]) {
|
||||
tsBefore[id] = store.getFeed(id)!.lastUpdated.getTime();
|
||||
}
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
await store.refreshAllFeeds();
|
||||
|
||||
expect(store.getFilteredFeeds().map((f) => f.id)).toEqual(orderBefore);
|
||||
for (const id of [feedAId, feedBId]) {
|
||||
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||
}
|
||||
});
|
||||
207
tests/feed-row-wrap.test.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Episode-row height regression — Feed rows must stay exactly 3 lines tall:
|
||||
* title, podcast name, meta (date + duration + markers). Every line carries
|
||||
* `wrapMode="none"` + `truncate` on flexible text and `flexShrink={0}` on
|
||||
* fixed-width cells so Yoga can never shrink a text below its content width
|
||||
* and wrap it — a wrapped row grows to 4+ lines and every entry below
|
||||
* shifts its starting position while scrolling (the original bug).
|
||||
*
|
||||
* Rendered at 70 columns so the 35-col current pane is narrow enough to
|
||||
* force truncation on the long title; at the default 100-col/50-col pane
|
||||
* the same rows show everything in full.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, afterAll } from "bun:test";
|
||||
import type { JSX } from "solid-js";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||
import { PaneRow } from "../src/components/PaneRow";
|
||||
|
||||
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||
|
||||
function frameText(spans: Frame): string[] {
|
||||
return spans.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||
}
|
||||
|
||||
// The exact row shape FeedPage renders: title, podcast name, meta. Static
|
||||
// text, no store hooks — pure layout probe.
|
||||
const FixedRow = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
#674 - Scott Payne
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text wrapMode="none" truncate>
|
||||
This Past Weekend w/ Theo Von
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text flexShrink={0}>Aug 10, 2026</text>
|
||||
<text flexShrink={0}>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
// A long title proves the truncate guard: without it the title wraps to
|
||||
// multiple lines and the row grows past 3 lines.
|
||||
const LongTitleFixed = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0}>❯</text>
|
||||
<text wrapMode="none" truncate>
|
||||
Out of Whiskey and Reaching for the Rotgut (Members Only #338)
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text wrapMode="none" truncate>
|
||||
This Past Weekend w/ Theo Von
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text flexShrink={0}>Aug 10, 2026</text>
|
||||
<text flexShrink={0}>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
// Pre-fix shape: no wrapMode/truncate/flexShrink props — the long title
|
||||
// wraps at the shrunken width and the row grows.
|
||||
const NaiveRow = () => (
|
||||
<box flexDirection="column" gap={0} paddingLeft={1} paddingRight={1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>❯</text>
|
||||
<text>Out of Whiskey and Reaching for the Rotgut (Members Only #338)</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text>This Past Weekend w/ Theo Von</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text>Aug 10, 2026</text>
|
||||
<text>3h 50m</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
|
||||
async function renderCurrent(
|
||||
row: () => JSX.Element,
|
||||
width = 70,
|
||||
): Promise<{ lines: string[]; destroy: () => Promise<void> }> {
|
||||
const setup = await testRender(
|
||||
() => (
|
||||
<ThemeProvider mode="dark">
|
||||
<PaneRow
|
||||
parent={null}
|
||||
current={row}
|
||||
preview={null}
|
||||
currentLabel="List"
|
||||
/>
|
||||
</ThemeProvider>
|
||||
),
|
||||
{ width, height: 10, useThread: false },
|
||||
);
|
||||
// ThemeProvider mounts children only once the theme resolves; poll for
|
||||
// the header row so the captured frame is a mounted PaneRow.
|
||||
let lines: string[] | null = null;
|
||||
for (let i = 0; i < 40 && !lines; i++) {
|
||||
await setup.renderOnce();
|
||||
const frame = setup.captureSpans() as unknown as Frame;
|
||||
const ls = frameText(frame);
|
||||
if (ls.some((l) => l.includes("List"))) lines = ls;
|
||||
else await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (!lines) throw new Error("PaneRow did not render before timeout");
|
||||
return {
|
||||
lines,
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("episode row height in the current pane (70-wide → 35-col pane)", () => {
|
||||
test("fixed row: title, podcast name and meta each render on exactly one line", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <FixedRow />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// Podcast name is fully visible on its own line — the usability the
|
||||
// dedicated row exists for.
|
||||
expect(lines.filter((l) => l.includes("This Past Weekend w/ Theo Von"))).toHaveLength(1);
|
||||
// Title and meta each on a single frame line (a wrapped date would
|
||||
// split "Aug 10, 2026" across lines).
|
||||
expect(lines.filter((l) => l.includes("Scott Payne"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("Aug 10, 2026"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("3h 50m"))).toHaveLength(1);
|
||||
// The row occupies exactly the 3 content lines below the header.
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Scott Payne") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("long title stays on one line (truncated), keeping the row at 3 lines", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <LongTitleFixed />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// Truncated: the title head appears on exactly one line and the full
|
||||
// title never appears on any line (middle-ellipsis clips it).
|
||||
expect(lines.filter((l) => l.includes("Out of Whiske"))).toHaveLength(1);
|
||||
expect(
|
||||
lines.some((l) =>
|
||||
l.includes(
|
||||
"Out of Whiskey and Reaching for the Rotgut (Members Only #338)",
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
// Podcast name and meta still each on one line — row total 3.
|
||||
expect(lines.filter((l) => l.includes("This Past Weekend"))).toHaveLength(1);
|
||||
expect(lines.filter((l) => l.includes("Aug 10,"))).toHaveLength(1);
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("naive row (pre-fix props) wraps the long title — the regression the test guards", async () => {
|
||||
const { lines, destroy } = await renderCurrent(() => <NaiveRow />);
|
||||
cleanups.push(destroy);
|
||||
|
||||
// The title's wrapped fragments span 3 frame lines instead of 1.
|
||||
const titleFragments = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("for the Rotgut") ||
|
||||
l.includes("#338)"),
|
||||
);
|
||||
expect(titleFragments).toHaveLength(3);
|
||||
// Row total: 3 title lines + podcast + meta = 5 content lines.
|
||||
const content = lines.filter(
|
||||
(l) =>
|
||||
l.includes("Out of Whiske") ||
|
||||
l.includes("for the Rotgut") ||
|
||||
l.includes("#338)") ||
|
||||
l.includes("This Past Weekend") ||
|
||||
l.includes("Aug 10,"),
|
||||
);
|
||||
expect(content).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
117
tests/feedless-subscribe.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* End-to-end subscribe test for feedless directory stubs.
|
||||
*
|
||||
* A delisted show (feedUrl "" + directoryUrl) must resolve its real feed from
|
||||
* the directory page inside addFeed — so subscribing just works. When the
|
||||
* page can't be resolved, addFeed must refuse (return null) instead of adding
|
||||
* a broken feed. Served over a real local HTTP server, mirroring how the
|
||||
* app's other store tests exercise the network path.
|
||||
*/
|
||||
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||
// (their module-level init reads it).
|
||||
const configHome = mkdtempSync(join(tmpdir(), "podtui-feedless-"));
|
||||
process.env.XDG_CONFIG_HOME = configHome;
|
||||
|
||||
import { useFeedStore } from "../src/stores/feed";
|
||||
import type { Podcast } from "../src/types/podcast";
|
||||
|
||||
const FEED_ID = "12345";
|
||||
const EPISODE_TITLES = ["Ep 2", "Ep 1"];
|
||||
|
||||
function pageHtml(feedUrl: string): string {
|
||||
return `<html><body><script>
|
||||
{"pageData":{"showOffer":{"title":"Delisted Show","adamId":"${FEED_ID}","feedUrl":"${feedUrl}","showType":"episodic"}}}
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
function feedXml(origin: string): string {
|
||||
const items = EPISODE_TITLES.map(
|
||||
(title, i) => `<item>
|
||||
<title>${title}</title>
|
||||
<pubDate>2026-08-0${2 - i}T00:00:00Z</pubDate>
|
||||
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||
</item>`,
|
||||
).join("\n");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"><channel>
|
||||
<title>Delisted Show</title>
|
||||
<description>Feedless stub test</description>
|
||||
${items}
|
||||
</channel></rss>`;
|
||||
}
|
||||
|
||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||
let feedUrl = "";
|
||||
let pageUrl = "";
|
||||
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (url.pathname.endsWith(".rss")) {
|
||||
return new Response(feedXml(url.origin), {
|
||||
headers: { "Content-Type": "application/rss+xml" },
|
||||
});
|
||||
}
|
||||
if (url.pathname.startsWith("/show")) {
|
||||
return new Response(pageHtml(feedUrl), {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
},
|
||||
});
|
||||
feedUrl = `http://127.0.0.1:${server!.port}/feed.rss`;
|
||||
// The resolver anchors on `/id<digits>` in the directory URL.
|
||||
pageUrl = `http://127.0.0.1:${server!.port}/show/id${FEED_ID}`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.stop(true);
|
||||
rmSync(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const makeStub = (feedUrl: string, directoryUrl?: string): Podcast => ({
|
||||
id: "itunes-12345",
|
||||
title: "Delisted Show",
|
||||
description: "Show that left the directory",
|
||||
author: "Some Network",
|
||||
feedUrl,
|
||||
directoryUrl,
|
||||
lastUpdated: new Date(),
|
||||
isSubscribed: false,
|
||||
});
|
||||
|
||||
test("addFeed resolves a feedless stub's feed from its directory page", async () => {
|
||||
const store = useFeedStore();
|
||||
const feed = await store.addFeed(makeStub("", pageUrl), "itunes");
|
||||
expect(feed).not.toBeNull();
|
||||
expect(feed!.podcast.feedUrl).toBe(feedUrl);
|
||||
// Resolution metadata is dropped from the persisted feed record.
|
||||
expect(feed!.podcast.directoryUrl).toBeUndefined();
|
||||
expect(feed!.episodes.map((e) => e.title)).toEqual(EPISODE_TITLES);
|
||||
// Remove the feed: bun test shares the store singleton across files, and a
|
||||
// leftover feed (whose server dies in afterAll) would reorder other files'
|
||||
// refresh assertions.
|
||||
store.removeFeed(feed!.id);
|
||||
});
|
||||
|
||||
test("addFeed refuses a stub whose directory page cannot be resolved", async () => {
|
||||
const store = useFeedStore();
|
||||
const unreachable = makeStub("", "http://127.0.0.1:1/nope/id999");
|
||||
const feed = await store.addFeed(unreachable, "itunes");
|
||||
expect(feed).toBeNull();
|
||||
});
|
||||
|
||||
test("addFeed refuses a stub with no directory page at all", async () => {
|
||||
const store = useFeedStore();
|
||||
const bare = makeStub("", undefined);
|
||||
const feed = await store.addFeed(bare, "itunes");
|
||||
expect(feed).toBeNull();
|
||||
});
|
||||
70
tests/itunes-feed-resolver.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Feed-resolution extraction tests.
|
||||
*
|
||||
* The iTunes Search API returns feedUrl null for shows delisted from Apple
|
||||
* Podcasts. Their public Apple page still embeds the real feed URL in JSON
|
||||
* state — alongside feedUrls of RELATED shows — so extraction must anchor on
|
||||
* the show's adamId rather than grabbing the first feedUrl in the document.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { extractFeedUrlFromPage } from "../src/utils/itunes-feed-resolver";
|
||||
|
||||
const MAIN_FEED = "https://rss.pdrl.fm/b32227/feeds.megaphone.fm/BVDWV5370667266";
|
||||
const OTHER_FEED = "https://feeds.megaphone.fm/BVDWV7762869899";
|
||||
|
||||
/** Synthetic Apple page: related shows first, main showOffer after. */
|
||||
const pageWithNoise = `{
|
||||
"shows":[{"showOffer":{"title":"The Matt Walsh Show","adamId":"2950206264","feedUrl":"${OTHER_FEED}","showType":"episodic"}}],
|
||||
"pageData":{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}","showType":"episodic"}}
|
||||
}`;
|
||||
|
||||
test("extracts the show's feed anchored on its adamId, ignoring related shows", () => {
|
||||
const feed = extractFeedUrlFromPage(
|
||||
pageWithNoise,
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
|
||||
);
|
||||
expect(feed).toBe(MAIN_FEED);
|
||||
});
|
||||
|
||||
test("handles the ?uo=4 suffix Apple appends to directory URLs", () => {
|
||||
const feed = extractFeedUrlFromPage(
|
||||
pageWithNoise,
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260?uo=4",
|
||||
);
|
||||
expect(feed).toBe(MAIN_FEED);
|
||||
});
|
||||
|
||||
test("returns null when the page has no feedUrl for the requested id", () => {
|
||||
const feed = extractFeedUrlFromPage(
|
||||
pageWithNoise,
|
||||
"https://podcasts.apple.com/us/podcast/some-other-show/id9999999999",
|
||||
);
|
||||
expect(feed).toBeNull();
|
||||
});
|
||||
|
||||
test("finds the feed when the showOffer sits far after the adamId reference", () => {
|
||||
// Apple serves page variants where thousands of chars separate the first
|
||||
// adamId reference from the showOffer block carrying the feedUrl.
|
||||
const variant = `{"adamId":"1047335260","$kind":"ShowPageIntent"}${"x".repeat(6000)}{"showOffer":{"title":"The Ben Shapiro Show","adamId":"1047335260","feedUrl":"${MAIN_FEED}"}}`;
|
||||
const feed = extractFeedUrlFromPage(
|
||||
variant,
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
|
||||
);
|
||||
expect(feed).toBe(MAIN_FEED);
|
||||
});
|
||||
|
||||
test("falls back to the first feedUrl when the URL carries no id", () => {
|
||||
const feed = extractFeedUrlFromPage(
|
||||
pageWithNoise,
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show",
|
||||
);
|
||||
expect(feed).toBe(OTHER_FEED);
|
||||
});
|
||||
|
||||
test("returns null when the page contains no feedUrl at all", () => {
|
||||
const feed = extractFeedUrlFromPage(
|
||||
"<html><body>not found</body></html>",
|
||||
"https://podcasts.apple.com/us/podcast/the-ben-shapiro-show/id1047335260",
|
||||
);
|
||||
expect(feed).toBeNull();
|
||||
});
|
||||
354
tests/podcastindex-fallback.test.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Podcast Index fallback tests.
|
||||
*
|
||||
* The Podcast Index source ships disabled and key-less. It must only be
|
||||
* consulted as a low-result fallback (fewer than 3 primary results), only
|
||||
* when enabled AND its credentials are stored (hasCredentials), and never
|
||||
* twice when also selected as a primary source. Credentials prefer the OS
|
||||
* keychain (encrypted at rest); when the keychain is unavailable they fall
|
||||
* back to plaintext on the source (config.json). Auth follows the documented
|
||||
* scheme: X-Auth-Key, X-Auth-Date (unix epoch), Authorization =
|
||||
* sha1(key + secret + date).
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { searchPodcasts } from "../src/utils/search";
|
||||
import {
|
||||
searchSourceByType,
|
||||
searchEpisodesByType,
|
||||
mapPodcastIndexResult,
|
||||
} from "../src/utils/source-searcher";
|
||||
import { SourceType } from "../src/types/source";
|
||||
import type { PodcastSource } from "../src/types/source";
|
||||
|
||||
// The searcher pulls credentials from the credential-storage module;
|
||||
// mock.module is hoisted above the imports, so this stub lands before
|
||||
// source-searcher loads. resolveSourceCredentials mirrors the real resolver
|
||||
// (plaintext branch vs keychain branch) against the mutable keychainState.
|
||||
const keychainState: {
|
||||
credentials: { apiKey: string; apiSecret: string } | null;
|
||||
} = {
|
||||
credentials: { apiKey: "TESTKEY123", apiSecret: "TESTSECRET456" },
|
||||
};
|
||||
|
||||
mock.module("../src/utils/source-credentials", () => ({
|
||||
savePodcastIndexCredentials: async () => true,
|
||||
loadPodcastIndexCredentials: async () => keychainState.credentials,
|
||||
resolveSourceCredentials: async (source: PodcastSource) =>
|
||||
source.credentialStorage === "plaintext"
|
||||
? source.apiKey && source.apiSecret
|
||||
? { apiKey: source.apiKey, apiSecret: source.apiSecret }
|
||||
: null
|
||||
: keychainState.credentials,
|
||||
}));
|
||||
|
||||
const sha1 = (input: string): string =>
|
||||
Bun.CryptoHasher.hash("sha1", input, "hex") as string;
|
||||
|
||||
const itunesSource: PodcastSource = {
|
||||
id: "itunes",
|
||||
name: "Apple Podcasts",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://itunes.apple.com/search",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const keyedPodcastIndex: PodcastSource = {
|
||||
id: "podcastindex",
|
||||
name: "Podcast Index",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||
enabled: true,
|
||||
hasCredentials: true,
|
||||
};
|
||||
|
||||
const PI_URL = "https://api.podcastindex.org/api/1.0/search/byterm";
|
||||
const ITUNES_URL = "https://itunes.apple.com/search";
|
||||
|
||||
const jsonResponse = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const itunesResults = (count: number) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
collectionId: i + 1,
|
||||
collectionName: `Show ${i + 1}`,
|
||||
feedUrl: `https://example.com/feed${i + 1}.xml`,
|
||||
}));
|
||||
|
||||
/** Route iTunes vs Podcast Index fetches; records call URLs. */
|
||||
const routeFetch = (
|
||||
calls: string[],
|
||||
opts: { itunesCount?: number; piFeeds?: unknown[]; piStatus?: number } = {},
|
||||
) =>
|
||||
mock(async (url: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
calls.push(u);
|
||||
if (u.startsWith(ITUNES_URL)) {
|
||||
const count = opts.itunesCount ?? 0;
|
||||
return jsonResponse({ resultCount: count, results: itunesResults(count) });
|
||||
}
|
||||
if (u.startsWith(PI_URL)) {
|
||||
if (opts.piStatus && opts.piStatus >= 400) {
|
||||
return new Response("nope", { status: opts.piStatus });
|
||||
}
|
||||
const feeds = opts.piFeeds ?? [];
|
||||
return jsonResponse({ status: "true", feeds, count: feeds.length });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${u}`);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ── result mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
test("Podcast Index results map to podcasts with the feed URL direct", () => {
|
||||
const mapped = mapPodcastIndexResult(
|
||||
{
|
||||
id: 42,
|
||||
title: "Fallback Show",
|
||||
url: "https://example.com/feed2.xml",
|
||||
author: "Open Directory",
|
||||
image: "https://example.com/art.jpg",
|
||||
language: "en",
|
||||
episodeCount: 10,
|
||||
lastUpdateTime: 1600000000,
|
||||
categories: { "104": "Technology", "105": "News" },
|
||||
},
|
||||
keyedPodcastIndex,
|
||||
);
|
||||
expect(mapped).not.toBeNull();
|
||||
expect(mapped!.id).toBe("podcastindex-42");
|
||||
expect(mapped!.title).toBe("Fallback Show");
|
||||
// PI is feed-first: the feed URL is present, no delisted-show stub step.
|
||||
expect(mapped!.feedUrl).toBe("https://example.com/feed2.xml");
|
||||
expect(mapped!.directoryUrl).toBeUndefined();
|
||||
expect(mapped!.categories).toEqual(["Technology", "News"]);
|
||||
expect(mapped!.lastUpdated.toISOString()).toBe("2020-09-13T12:26:40.000Z");
|
||||
expect(mapped!.isSubscribed).toBe(false);
|
||||
});
|
||||
|
||||
test("Podcast Index results without a title or feed URL stay dropped", () => {
|
||||
expect(mapPodcastIndexResult({ id: 1 }, keyedPodcastIndex)).toBeNull();
|
||||
expect(
|
||||
mapPodcastIndexResult({ title: "No Feed" }, keyedPodcastIndex),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ── auth scheme ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("Podcast Index auth uses sha1(key+secret+date) from keychain credentials", async () => {
|
||||
let captured: RequestInit | undefined;
|
||||
globalThis.fetch = mock(
|
||||
async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).startsWith(PI_URL)) {
|
||||
captured = init;
|
||||
return jsonResponse({
|
||||
status: "true",
|
||||
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ resultCount: 0, results: [] });
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
await searchSourceByType("hello", keyedPodcastIndex);
|
||||
|
||||
const headers = captured?.headers as Record<string, string>;
|
||||
expect(headers["X-Auth-Key"]).toBe("TESTKEY123");
|
||||
expect(headers["User-Agent"]).toBe("PodTUI/1.0");
|
||||
expect(headers["X-Auth-Date"]).toMatch(/^\d{10}$/);
|
||||
expect(headers["Authorization"]).toBe(
|
||||
sha1(`TESTKEY123TESTSECRET456${headers["X-Auth-Date"]}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("plaintext-stored credentials drive the search without the keychain", async () => {
|
||||
keychainState.credentials = null;
|
||||
let captured: RequestInit | undefined;
|
||||
globalThis.fetch = mock(
|
||||
async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(url).startsWith(PI_URL)) {
|
||||
captured = init;
|
||||
return jsonResponse({
|
||||
status: "true",
|
||||
feeds: [{ id: 1, title: "X", url: "https://example.com/x.xml" }],
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return jsonResponse({ resultCount: 0, results: [] });
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const plaintext: PodcastSource = {
|
||||
...keyedPodcastIndex,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
};
|
||||
try {
|
||||
await searchSourceByType("hello", plaintext);
|
||||
} finally {
|
||||
keychainState.credentials = {
|
||||
apiKey: "TESTKEY123",
|
||||
apiSecret: "TESTSECRET456",
|
||||
};
|
||||
}
|
||||
|
||||
const headers = captured?.headers as Record<string, string>;
|
||||
expect(headers["X-Auth-Key"]).toBe("PLAINTEXTKEY");
|
||||
expect(headers["Authorization"]).toBe(
|
||||
sha1(`PLAINTEXTKEYPLAINTEXTSECRET${headers["X-Auth-Date"]}`),
|
||||
);
|
||||
});
|
||||
|
||||
test("missing keychain credentials fail with a setup message, not a request", async () => {
|
||||
keychainState.credentials = null;
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 0 });
|
||||
try {
|
||||
await expect(searchSourceByType("hello", keyedPodcastIndex)).rejects.toThrow(
|
||||
/credentials are missing/,
|
||||
);
|
||||
} finally {
|
||||
keychainState.credentials = {
|
||||
apiKey: "TESTKEY123",
|
||||
apiSecret: "TESTSECRET456",
|
||||
};
|
||||
}
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test("Podcast Index has no episode-scope search backend", async () => {
|
||||
const results = await searchEpisodesByType("hello", keyedPodcastIndex);
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
// ── fallback behavior ────────────────────────────────────────────────────────
|
||||
|
||||
test("thin primary results trigger the keyed Podcast Index fallback", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 1,
|
||||
piFeeds: [
|
||||
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
|
||||
],
|
||||
});
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-thin",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(true);
|
||||
const pi = results.find((r) => r.sourceId === "podcastindex");
|
||||
expect(pi?.sourceName).toBe("Podcast Index");
|
||||
expect(pi?.podcast.title).toBe("Fallback Show");
|
||||
expect(results.length).toBe(2);
|
||||
});
|
||||
|
||||
test("fallback is skipped when primary results meet the threshold", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 5 });
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-full",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
expect(results.length).toBe(5);
|
||||
});
|
||||
|
||||
test("a disabled Podcast Index source is never consulted", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
|
||||
const disabled = { ...keyedPodcastIndex, enabled: false };
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-disabled",
|
||||
["itunes"],
|
||||
[itunesSource, disabled],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
});
|
||||
|
||||
test("a credential-less Podcast Index source never sends requests", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1 });
|
||||
const keyless = { ...keyedPodcastIndex, hasCredentials: false };
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-keyless",
|
||||
["itunes"],
|
||||
[itunesSource, keyless],
|
||||
);
|
||||
|
||||
expect(calls.some((u) => u.startsWith(PI_URL))).toBe(false);
|
||||
});
|
||||
|
||||
test("Podcast Index selected as a primary source is fetched once, not twice", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 1,
|
||||
piFeeds: [
|
||||
{ id: 9, title: "Fallback Show", url: "https://example.com/fallback.xml" },
|
||||
],
|
||||
});
|
||||
|
||||
await searchPodcasts(
|
||||
"unique-query-both",
|
||||
["itunes", "podcastindex"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(calls.filter((u) => u.startsWith(PI_URL)).length).toBe(1);
|
||||
});
|
||||
|
||||
test("a failing fallback leaves the primary results intact", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, { itunesCount: 1, piStatus: 500 });
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-pi-fail",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].sourceId).toBe("itunes");
|
||||
});
|
||||
|
||||
test("dead Podcast Index feeds are filtered out", async () => {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = routeFetch(calls, {
|
||||
itunesCount: 0,
|
||||
piFeeds: [
|
||||
{ id: 1, title: "Live Show", url: "https://example.com/live.xml" },
|
||||
{
|
||||
id: 2,
|
||||
title: "Dead Show",
|
||||
url: "https://example.com/dead.xml",
|
||||
dead: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const results = await searchPodcasts(
|
||||
"unique-query-dead",
|
||||
["itunes"],
|
||||
[itunesSource, keyedPodcastIndex],
|
||||
);
|
||||
|
||||
const pi = results.filter((r) => r.sourceId === "podcastindex");
|
||||
expect(pi.length).toBe(1);
|
||||
expect(pi[0].podcast.title).toBe("Live Show");
|
||||
});
|
||||
181
tests/progress-bar-seek.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
267
tests/search-focus.test.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* search-focus.test.tsx — the search query input's focus flag must follow its
|
||||
* REAL focus, not a flag that outlives it.
|
||||
*
|
||||
* Regression: clicking off the query input (opentui's mouse dispatch
|
||||
* auto-focuses the clicked target's nearest focusable ancestor, blurring the
|
||||
* input) used to leave `nav.inputFocused()` stuck true while the renderable
|
||||
* was unfocused. The Shell keyboard router then yielded every key to a
|
||||
* non-existent input: Esc wouldn't defocus, `s` wouldn't refocus, j/k/h did
|
||||
* nothing. The input now drives the flag through useInputFocusNav
|
||||
* (FOCUSED/BLURRED), so click-off drops the flag and keyboard control resumes;
|
||||
* clicking the input focuses it.
|
||||
*
|
||||
* Mounts the real app (sandboxed, silent audio) via the same provider tree as
|
||||
* scripts/tui-harness.tsx and drives it with the test renderer's mock keys +
|
||||
* mouse. Layout is deterministic at 100x30: tab list 20 cols, current pane
|
||||
* x=20..69 (content padded 1), so the query input row is y=1, x=29..57 and the
|
||||
* pane interior is blank at (60,5).
|
||||
*
|
||||
* KNOWN FLAKE (full-suite CPU contention): both tests occasionally fail inside
|
||||
* openSearch — the tab-digit press or the post-Enter input focus doesn't land
|
||||
* within the retry budget (20 × press+render+40ms, then a 5s waitFor). Passes
|
||||
* reliably in isolation (`bun test tests/search-focus.test.tsx`, ~2s).
|
||||
* Observed 2026-08-11 on an M3 Pro: two consecutive red full-suite runs
|
||||
* (`timed out waiting for: input focused on tab entry` / `Expected: 4,
|
||||
* Received: 1`), then two consecutive green — timing under load, not state.
|
||||
* If a full-suite run reports these two, re-run the file alone to confirm
|
||||
* before treating it as a regression.
|
||||
*/
|
||||
|
||||
import { test, expect, afterAll, mock } from "bun:test";
|
||||
import { testRender } from "@opentui/solid";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { AudioControls } from "../src/hooks/useAudio";
|
||||
|
||||
// Other test files that `mock.module("../src/hooks/useAudio")` can share this
|
||||
// file's worker (bun reuses workers; the mock leaks into the module registry
|
||||
// here). That stub only has duration/position/seek, so the Shell's render
|
||||
// throws on audio.currentEpisode() and no key router ever attaches — presses
|
||||
// go nowhere. Register a complete no-op stub FIRST so the app mounts
|
||||
// regardless of what leaked in before us.
|
||||
const stubAudio: AudioControls = {
|
||||
isPlaying: () => false,
|
||||
position: () => 0,
|
||||
duration: () => 0,
|
||||
volume: () => 1,
|
||||
speed: () => 1,
|
||||
backendName: () => "none",
|
||||
error: () => null,
|
||||
currentEpisode: () => null,
|
||||
availablePlayers: () => [],
|
||||
play: async () => {},
|
||||
pause: async () => {},
|
||||
resume: async () => {},
|
||||
togglePlayback: async () => {},
|
||||
stop: async () => {},
|
||||
seek: async () => {},
|
||||
seekRelative: async () => {},
|
||||
setVolume: async () => {},
|
||||
setSpeed: async () => {},
|
||||
switchBackend: async () => {},
|
||||
prev: async () => {},
|
||||
next: async () => {},
|
||||
};
|
||||
mock.module("../src/hooks/useAudio", () => ({
|
||||
useAudio: () => stubAudio,
|
||||
}));
|
||||
|
||||
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
|
||||
// env vars at import time. Static imports are hoisted above this code, so the
|
||||
// app modules must be loaded dynamically (mirrors scripts/tui-harness.tsx).
|
||||
const SANDBOX = join(process.cwd(), ".harness", "test-focus");
|
||||
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
|
||||
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
|
||||
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
|
||||
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
|
||||
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||
|
||||
const { App } = await import("../src/App");
|
||||
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||
const toast = await import("../src/ui/toast");
|
||||
const { KeybindProvider, useKeybinds } = await import(
|
||||
"../src/context/KeybindContext"
|
||||
);
|
||||
const { NavigationProvider, useNavigation } = await import(
|
||||
"../src/context/NavigationContext"
|
||||
);
|
||||
const { DialogProvider } = await import("../src/ui/dialog");
|
||||
const { CommandProvider } = await import("../src/ui/command");
|
||||
const { TABS } = await import("../src/utils/navigation");
|
||||
|
||||
// Click coordinates (0-based) in the 100x30 layout — see file header.
|
||||
const INPUT_CLICK = { x: 35, y: 1 };
|
||||
const PILL_EPISODES = { x: 44, y: 3 };
|
||||
const BLANK_PANE = { x: 60, y: 5 };
|
||||
|
||||
type Mounted = {
|
||||
renderer: any;
|
||||
renderOnce: () => Promise<void>;
|
||||
mockInput: any;
|
||||
mockMouse: any;
|
||||
nav: () => {
|
||||
inputFocused: () => boolean;
|
||||
currentDepth: () => number;
|
||||
activeTab: () => number;
|
||||
};
|
||||
keybindsReady: () => boolean;
|
||||
};
|
||||
|
||||
/** Mount the real app and return drivers + nav/keybind probes (contexts are
|
||||
* only readable inside the provider tree). */
|
||||
async function mountApp(): Promise<Mounted> {
|
||||
let navRef: any = null;
|
||||
let keybindsRef: any = null;
|
||||
const StateProbe = () => {
|
||||
navRef = useNavigation();
|
||||
keybindsRef = useKeybinds();
|
||||
return null;
|
||||
};
|
||||
const HarnessRoot = () => (
|
||||
<toast.ToastProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
<KeybindProvider>
|
||||
<NavigationProvider>
|
||||
<StateProbe />
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<App />
|
||||
<toast.Toast />
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</NavigationProvider>
|
||||
</KeybindProvider>
|
||||
</ThemeProvider>
|
||||
</toast.ToastProvider>
|
||||
);
|
||||
const setup = await testRender(() => <HarnessRoot />, {
|
||||
width: 100,
|
||||
height: 30,
|
||||
useThread: false,
|
||||
});
|
||||
(setup.renderer as unknown as { disableStdoutInterception?: () => void }).disableStdoutInterception?.();
|
||||
// Initial mount settle; keybind readiness is awaited separately in
|
||||
// settleReady before any keypress.
|
||||
await setup.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
return {
|
||||
renderer: setup.renderer,
|
||||
renderOnce: setup.renderOnce,
|
||||
mockInput: setup.mockInput,
|
||||
mockMouse: setup.mockMouse,
|
||||
nav: () => navRef,
|
||||
keybindsReady: () => keybindsRef?.ready ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Render + settle until the keybind router is ready (it loads keybinds from
|
||||
* disk asynchronously on mount); the first keypress would otherwise be lost
|
||||
* under full-suite CPU contention. */
|
||||
async function settleReady(m: Mounted): Promise<void> {
|
||||
for (let i = 0; i < 80; i++) {
|
||||
await m.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
if (m.keybindsReady()) return;
|
||||
}
|
||||
throw new Error("keybinds never became ready");
|
||||
}
|
||||
|
||||
/** Navigate to the Search tab's query depth (input auto-focuses on entry).
|
||||
* The tab-digit press is retried until it lands: the first press can fire
|
||||
* before the Shell's key router attaches (keybinds load asynchronously), so
|
||||
* re-pressing self-heals instead of flaking under suite contention. */
|
||||
async function openSearch(m: Mounted): Promise<void> {
|
||||
await settleReady(m);
|
||||
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) {
|
||||
m.mockInput.pressKey("4");
|
||||
await m.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
}
|
||||
expect(m.nav().activeTab()).toBe(TABS.SEARCH);
|
||||
m.mockInput.pressEnter();
|
||||
await waitFor(m, () => m.nav().inputFocused(), "input focused on tab entry");
|
||||
}
|
||||
|
||||
/** Render + sleep until `cond` holds or the timeout elapses. Fixed sleeps after
|
||||
* input/mouse actions flake under full-suite CPU contention, so state
|
||||
* transitions are polled instead. */
|
||||
async function waitFor(
|
||||
m: Mounted,
|
||||
cond: () => boolean,
|
||||
what: string,
|
||||
timeoutMs = 5000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (cond()) return;
|
||||
await m.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
throw new Error(`timed out waiting for: ${what}`);
|
||||
}
|
||||
|
||||
/** The renderable the renderer currently considers focused (if any). */
|
||||
function focusedName(m: Mounted): string | null {
|
||||
const r = m.renderer.currentFocusedRenderable;
|
||||
return r ? `${r.constructor?.name} id=${r.id}` : null;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(SANDBOX, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("clicking off the query input drops inputFocused; s and Esc restore keyboard control", async () => {
|
||||
const m = await mountApp();
|
||||
try {
|
||||
await openSearch(m);
|
||||
expect(m.nav().inputFocused()).toBe(true);
|
||||
|
||||
// Click off the input (scope pill). Real focus moves to the pane's
|
||||
// scrollbox, blurring the input — the flag must follow.
|
||||
await m.mockMouse.click(PILL_EPISODES.x, PILL_EPISODES.y);
|
||||
await waitFor(m, () => !m.nav().inputFocused(), "flag off after pill click");
|
||||
expect(focusedName(m)).not.toMatch(/^Input/);
|
||||
|
||||
// `s` (search action) must refocus the input for typing.
|
||||
m.mockInput.pressKey("s");
|
||||
await waitFor(m, () => m.nav().inputFocused(), "s refocuses the input");
|
||||
expect(focusedName(m)).toMatch(/^Input/);
|
||||
|
||||
// Escape defocuses; the flag stays off (no depth change re-seeds it).
|
||||
m.mockInput.pressEscape();
|
||||
await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses");
|
||||
|
||||
// Click-off a second time (blank pane interior). The flag is already
|
||||
// false from the escape, so a state-poll can't observe the click; check
|
||||
// directly that the click did NOT re-focus the input, then that j is
|
||||
// not swallowed (flag stays false, nothing re-grabs focus).
|
||||
await m.mockMouse.click(BLANK_PANE.x, BLANK_PANE.y);
|
||||
await m.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(m.nav().inputFocused()).toBe(false);
|
||||
m.mockInput.pressKey("j");
|
||||
await m.renderOnce();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(m.nav().inputFocused()).toBe(false);
|
||||
} finally {
|
||||
m.renderer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking the query input focuses it (typing mode)", async () => {
|
||||
const m = await mountApp();
|
||||
try {
|
||||
await openSearch(m);
|
||||
// Defocus first so the click has something to restore.
|
||||
m.mockInput.pressEscape();
|
||||
await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses");
|
||||
|
||||
await m.mockMouse.click(INPUT_CLICK.x, INPUT_CLICK.y);
|
||||
await waitFor(m, () => m.nav().inputFocused(), "input click focuses");
|
||||
expect(focusedName(m)).toMatch(/^Input/);
|
||||
} finally {
|
||||
m.renderer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
46
tests/source-credentials.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Credential resolution tests against the real module (no mocks).
|
||||
*
|
||||
* Only the plaintext branch is exercised: the keychain branch spawns the
|
||||
* `security` CLI and would depend on the host machine's keychain state
|
||||
* (the keychain-backed pipeline is covered in podcastindex-fallback.test.ts
|
||||
* with a stubbed module). The plaintext branch must never touch the
|
||||
* keychain — it is the fallback that keeps the source working on machines
|
||||
* without a usable macOS keychain.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { resolveSourceCredentials } from "../src/utils/source-credentials";
|
||||
import { SourceType } from "../src/types/source";
|
||||
import type { PodcastSource } from "../src/types/source";
|
||||
|
||||
const base: PodcastSource = {
|
||||
id: "podcastindex",
|
||||
name: "Podcast Index",
|
||||
type: SourceType.API,
|
||||
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||
enabled: true,
|
||||
hasCredentials: true,
|
||||
};
|
||||
|
||||
test("plaintext-storage sources resolve their own fields, no keychain call", async () => {
|
||||
const source: PodcastSource = {
|
||||
...base,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
};
|
||||
expect(await resolveSourceCredentials(source)).toEqual({
|
||||
apiKey: "PLAINTEXTKEY",
|
||||
apiSecret: "PLAINTEXTSECRET",
|
||||
});
|
||||
});
|
||||
|
||||
test("plaintext-storage sources with empty fields resolve to null", async () => {
|
||||
const source: PodcastSource = {
|
||||
...base,
|
||||
credentialStorage: "plaintext",
|
||||
apiKey: undefined,
|
||||
apiSecret: undefined,
|
||||
};
|
||||
expect(await resolveSourceCredentials(source)).toBeNull();
|
||||
});
|
||||