Compare commits
75 Commits
v0.3.1
...
df4701957b
| Author | SHA1 | Date | |
|---|---|---|---|
| df4701957b | |||
| 4b44623891 | |||
| 4ef9ab7e59 | |||
| 9df8eebf6c | |||
| badbc6a037 | |||
| 878d1e01ab | |||
| 42c48e59fb | |||
| 91d4acca90 | |||
| 20d5b57cb6 | |||
| d7aec4e810 | |||
| 77531ce41d | |||
| 26729fa5e6 | |||
| 4127fd1181 | |||
| 6c99b96b12 | |||
| acbaf2ed1c | |||
| e7ed89056e | |||
| b5432e3e5f | |||
| 13664c3cec | |||
| 0b4a551744 | |||
| ed75c2fff7 | |||
| bd7d988741 | |||
| deac6081ca | |||
| e09ae15e32 | |||
| 33af131b77 | |||
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 | |||
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 | |||
| 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 |
@@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
env: {
|
|
||||||
es2022: true,
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ["dist", "node_modules"],
|
|
||||||
}
|
|
||||||
8
.github/workflows/release.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
|||||||
plat: darwin
|
plat: darwin
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
uses: oven-sh/setup-bun@v2
|
||||||
@@ -78,7 +78,7 @@ jobs:
|
|||||||
./podtui-*/podtui --version
|
./podtui-*/podtui --version
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
name: podtui-${{ matrix.plat }}-${{ matrix.arch }}
|
||||||
path: dist/podtui-*.tar.gz
|
path: dist/podtui-*.tar.gz
|
||||||
@@ -89,12 +89,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Download all binaries
|
- name: Download all binaries
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
- name: Publish release
|
- name: Publish release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: |
|
files: |
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -34,3 +34,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.harness/
|
.harness/
|
||||||
.ralpi
|
.ralpi
|
||||||
|
notes.md
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
- `bun run lint` - Run ESLint with TypeScript rules
|
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
|||||||
14
README.md
@@ -161,6 +161,9 @@ All keys are remappable — edit `keybinds.jsonc` in your config directory
|
|||||||
| `.` | Toggle hidden |
|
| `.` | Toggle hidden |
|
||||||
| `r` | Refresh |
|
| `r` | Refresh |
|
||||||
| `x` | Unsubscribe the focused show (My Shows) |
|
| `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**
|
**Audio**
|
||||||
|
|
||||||
@@ -185,7 +188,16 @@ default (`$XDG_CONFIG_HOME/podtui` if set).
|
|||||||
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
Legacy `feeds.json`, `sources.json`, and `app-state.json` are auto-migrated
|
||||||
into `config.json` on first run.
|
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
|
## 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 |
15
build.ts
@@ -116,6 +116,21 @@ if (COMPILE) {
|
|||||||
const s = join("dist", lib);
|
const s = join("dist", lib);
|
||||||
if (existsSync(s)) copyFileSync(s, join(tarRoot, 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tar = Bun.spawnSync([
|
const tar = Bun.spawnSync([
|
||||||
"tar",
|
"tar",
|
||||||
"-czf",
|
"-czf",
|
||||||
|
|||||||
@@ -18,15 +18,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentui/core": "^0.1.77",
|
"@opentui/core": "^0.1.77",
|
||||||
"@opentui/solid": "^0.1.77",
|
"@opentui/solid": "^0.1.77",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"effect": "^3",
|
||||||
"solid-js": "^1.9.9"
|
"solid-js": "^1.9.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,13 @@ package() {
|
|||||||
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
||||||
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
||||||
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
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"
|
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() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const audio = useAudio();
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
const themeContext = useTheme();
|
const themeContext = useTheme();
|
||||||
const theme = themeContext.theme;
|
const theme = themeContext.theme;
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Multimedia keys (physical play/seek keys) still feed the audio backend
|
// Multimedia keys (physical play/volume/speed keys) still feed the audio
|
||||||
// regardless of the on-screen yazi keybinds.
|
// backend regardless of the on-screen yazi keybinds. Seek lives on the
|
||||||
|
// keybind router (< / > = shift+, / shift+.), so arrows stay on navigation.
|
||||||
useMultimediaKeys({
|
useMultimediaKeys({
|
||||||
playerFocused: () =>
|
playerFocused: () =>
|
||||||
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
nav.activeTab() === TABS.PLAYER && nav.mode() !== NavMode.NORMAL
|
||||||
? true
|
? true
|
||||||
: false,
|
: false,
|
||||||
inputFocused: () => nav.inputFocused(),
|
inputFocused: () => nav.inputFocused(),
|
||||||
hasEpisode: () => !!audio.currentEpisode(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mouse text-selection → clipboard (unchanged from the old shell).
|
// Mouse text-selection → clipboard (unchanged from the old shell).
|
||||||
|
|||||||
@@ -74,6 +74,114 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** FNV-1a 32-bit hash. Deterministic across processes and Bun versions
|
||||||
|
* (unlike Bun.hash) — used to derive stable episode ids from audio URLs so
|
||||||
|
* a feed's episode ids never change between refreshes. */
|
||||||
|
const fnv1a = (input: string): number => {
|
||||||
|
let hash = 0x811c9dc5
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i)
|
||||||
|
hash = Math.imul(hash, 0x01000193)
|
||||||
|
}
|
||||||
|
return hash >>> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable per-episode identity. The old positional id (`feedUrl#index`) was
|
||||||
|
* invalidated by ANY feed change: a new episode or a pruned one shifted
|
||||||
|
* every episode's index, so progress/downloads saved under `feedUrl#5`
|
||||||
|
* attached to whatever episode now sat at index 5 — new episodes resumed
|
||||||
|
* minutes in. Identity derives from stable content instead:
|
||||||
|
* 1. `<guid>` — the canonical per-episode identifier (required by Apple
|
||||||
|
* Podcasts; nearly universal).
|
||||||
|
* 2. The enclosure URL, hashed to keep the id compact (hosts serve
|
||||||
|
* permanent per-episode URLs; guids can be absent in hand-rolled feeds).
|
||||||
|
* 3. Positional index as a last resort: no guid AND no audio URL means
|
||||||
|
* the episode cannot be played, so nothing persistent keys off it.
|
||||||
|
*/
|
||||||
|
const stableEpisodeId = (
|
||||||
|
feedUrl: string,
|
||||||
|
item: string,
|
||||||
|
audioUrl: string,
|
||||||
|
index: number,
|
||||||
|
): string => {
|
||||||
|
const guid = getTagValue(item, "guid")
|
||||||
|
if (guid) return `${feedUrl}#guid:${guid}`
|
||||||
|
if (audioUrl) return `${feedUrl}#url:${fnv1a(audioUrl).toString(36)}`
|
||||||
|
return `${feedUrl}#${index}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||||
|
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||||
|
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||||
|
* RSS has no items outside <channel>). */
|
||||||
|
export const getRSSItems = (xml: string): string[] => {
|
||||||
|
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||||
|
* `<image><url>`. Works on the full XML — channel-level tags precede
|
||||||
|
* <item> blocks in RSS, so the first match is the channel image. */
|
||||||
|
export const parseChannelCoverUrl = (xml: string): string | undefined => {
|
||||||
|
const itunesHref = getAttr(xml, "itunes:image", "href")
|
||||||
|
if (itunesHref) return itunesHref
|
||||||
|
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||||
|
return url?.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||||
|
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||||
|
* chunks) instead of one synchronous block. */
|
||||||
|
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||||
|
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||||
|
const epDescription = cleanField(getTagValue(item, "description"))
|
||||||
|
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||||
|
|
||||||
|
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
|
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||||
|
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||||
|
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||||
|
|
||||||
|
const durationRaw = getTagValue(item, "itunes:duration")
|
||||||
|
const duration = parseDuration(durationRaw)
|
||||||
|
|
||||||
|
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||||
|
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||||
|
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||||
|
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||||
|
|
||||||
|
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||||
|
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||||
|
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||||
|
|
||||||
|
// Episode image (itunes:image has href attribute)
|
||||||
|
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||||
|
|
||||||
|
const ep: Episode = {
|
||||||
|
id: stableEpisodeId(feedUrl, item, audioUrl, index),
|
||||||
|
podcastId: feedUrl,
|
||||||
|
title: epTitle,
|
||||||
|
description: epDescription,
|
||||||
|
audioUrl,
|
||||||
|
duration,
|
||||||
|
pubDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||||
|
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||||
|
if (episodeType) ep.episodeType = episodeType
|
||||||
|
if (explicit !== undefined) ep.explicit = explicit
|
||||||
|
if (imageUrl) ep.imageUrl = imageUrl
|
||||||
|
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||||
|
if (mimeType) ep.mimeType = mimeType
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||||
|
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||||
|
* thread should prefer the store's chunked incremental parse instead. */
|
||||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||||
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||||
@@ -81,58 +189,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
const lastUpdated = new Date()
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
const items = getRSSItems(xml)
|
||||||
const episodes = items.map((item, index) => {
|
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
|
||||||
const epDescription = cleanField(getTagValue(item, "description"))
|
|
||||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
|
||||||
|
|
||||||
// Audio URL + file size + MIME type from <enclosure>
|
|
||||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
|
||||||
const audioUrl = enclosure?.[1] ?? ""
|
|
||||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
|
||||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
|
||||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
|
||||||
|
|
||||||
// Duration from <itunes:duration>
|
|
||||||
const durationRaw = getTagValue(item, "itunes:duration")
|
|
||||||
const duration = parseDuration(durationRaw)
|
|
||||||
|
|
||||||
// Episode & season numbers
|
|
||||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
|
||||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
|
||||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
|
||||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
|
||||||
|
|
||||||
// Episode type & explicit
|
|
||||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
|
||||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
|
||||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
|
||||||
|
|
||||||
// Episode image (itunes:image has href attribute)
|
|
||||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
|
||||||
|
|
||||||
const ep: Episode = {
|
|
||||||
id: `${feedUrl}#${index}`,
|
|
||||||
podcastId: feedUrl,
|
|
||||||
title: epTitle,
|
|
||||||
description: epDescription,
|
|
||||||
audioUrl,
|
|
||||||
duration,
|
|
||||||
pubDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set optional fields if present
|
|
||||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
|
||||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
|
||||||
if (episodeType) ep.episodeType = episodeType
|
|
||||||
if (explicit !== undefined) ep.explicit = explicit
|
|
||||||
if (imageUrl) ep.imageUrl = imageUrl
|
|
||||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
|
||||||
if (mimeType) ep.mimeType = mimeType
|
|
||||||
|
|
||||||
return ep
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: feedUrl,
|
id: feedUrl,
|
||||||
@@ -142,6 +200,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
feedUrl,
|
feedUrl,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
episodes,
|
episodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/components/EpisodeList.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Shared list-row and preview components for the Feed and My Shows pages.
|
||||||
|
*
|
||||||
|
* Both pages render the same episode rows (marker + title, optional subtitle
|
||||||
|
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
|
||||||
|
* hovered-episode / fetch-more preview panes; the pages differ only in the
|
||||||
|
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
|
||||||
|
* so the previously 3-4-level-nested render blocks run as flat named
|
||||||
|
* components.
|
||||||
|
*
|
||||||
|
* Anything that can change at runtime arrives as a signal getter: Solid
|
||||||
|
* components do not re-render, so only props that are called inside the
|
||||||
|
* component's own JSX stay reactive (focus, selection, download state).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { NF_ICONS } from "@/utils/nerd-fonts";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
|
||||||
|
// ── formatting helpers ──────────────────────────────────────────────────────
|
||||||
|
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
export const formatDuration = (s: number) => {
|
||||||
|
const mins = Math.floor(s / 60);
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── EpisodeRow ──────────────────────────────────────────────────────────────
|
||||||
|
export function EpisodeRow(props: {
|
||||||
|
/** The episode this row renders. */
|
||||||
|
episode: Episode;
|
||||||
|
/** Optional second line under the title (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
/** For index signal (row position). */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index in this list (-1 while the Fetch More row is
|
||||||
|
* focused, so no episode row draws the cursor). */
|
||||||
|
focused: () => number;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
/** Whether this episode is selection-marked. */
|
||||||
|
selected: () => boolean;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
|
||||||
|
{props.episode.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
{/* 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 */}
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.subtitle?.()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
|
||||||
|
{formatDate(props.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
{formatDuration(props.episode.duration)}
|
||||||
|
</text>
|
||||||
|
<Show when={props.selected()}>
|
||||||
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMoreRow ────────────────────────────────────────────────────────────
|
||||||
|
export function FetchMoreRow(props: {
|
||||||
|
/** Row index of the Fetch More button within the list. */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index. */
|
||||||
|
focused: () => number;
|
||||||
|
/** True while the Fetch More row itself is focused. */
|
||||||
|
onMore: () => boolean;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
nerd: boolean;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(props.onMore);
|
||||||
|
const bg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
|
||||||
|
{props.nerd && (
|
||||||
|
<text fg={fg()}>{NF_ICONS.more}</text>
|
||||||
|
)}
|
||||||
|
<Show
|
||||||
|
when={!props.isLoadingMore()}
|
||||||
|
fallback={<LoadingIndicator label="Fetching…" />}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>[Fetch More]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EpisodePreview ──────────────────────────────────────────────────────────
|
||||||
|
export function EpisodePreview(props: {
|
||||||
|
episode: () => Episode;
|
||||||
|
/** Optional line under the meta row (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
author: () => string | undefined;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
/** Page-specific action-hint line. */
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
|
||||||
|
{props.episode().title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<text fg={muted()}>{props.subtitle?.()}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.author()}>
|
||||||
|
<text fg={muted()}>by {props.author()}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{props.episode().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMorePreview ────────────────────────────────────────────────────────
|
||||||
|
export function FetchMorePreview(props: {
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
fetchMoreMode: () => string;
|
||||||
|
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
|
||||||
|
manualText: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{props.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: props.fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: props.manualText()}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/GlobalActivityIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useActivityStore } from "@/stores/activity";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||||
|
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
|
||||||
|
* Per-page spinners are unchanged; this overlays the content row and status
|
||||||
|
* bar as a single app-wide "something is happening" indicator.
|
||||||
|
*/
|
||||||
|
export function GlobalActivityIndicator() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
/** True while any tracked activity is in flight */
|
||||||
|
const isActive = () =>
|
||||||
|
feedStore.isLoadingFeeds() ||
|
||||||
|
feedStore.isLoadingMore() ||
|
||||||
|
searchStore.isSearching() ||
|
||||||
|
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||||
|
activity.isActive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
* PaneRow — the shared parent | current | preview 3-pane layout primitive.
|
||||||
*
|
*
|
||||||
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three columns grow at
|
* Implements yazi's `mgr.ratio` contract: three columns grow at
|
||||||
* 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list tab
|
* 20% : 50% : 30% (PANE_RATIO 2:5:3) of the row width via Yoga `flexGrow`,
|
||||||
* renders an identical, layout-stable shell. Columns use `flexBasis={0}` so
|
* so every list tab renders an identical, layout-stable shell. Columns use
|
||||||
* the ratio is exact regardless of content width — a column's content can
|
* `flexBasis={0}` so the ratio is exact regardless of content width — a
|
||||||
* never stretch its slot.
|
* column's content can never stretch its slot.
|
||||||
*
|
*
|
||||||
* Column semantics (per the yazi depth model):
|
* Column semantics (per the yazi depth model):
|
||||||
* parent — the previous-depth list. Renders a muted `—` placeholder and
|
* 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
|
* Borderless (no left/right/top/bottom edge). Carries the single
|
||||||
* header row: the CURRENT column's title renders top-left in the
|
* header row: the CURRENT column's title renders top-left in the
|
||||||
* parent's slot (the panes above current/preview were removed).
|
* parent's slot (the panes above current/preview were removed).
|
||||||
@@ -63,6 +63,9 @@ export type PaneRowProps = {
|
|||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
panes?: 2 | 3;
|
panes?: 2 | 3;
|
||||||
|
/** Which sides of the current column's border render. Defaults to
|
||||||
|
* `["left", "right"]` (the standard focused-list frame). */
|
||||||
|
currentBorder?: boolean | BorderSides[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -181,10 +184,13 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
? PANE_RATIO.current + PANE_RATIO.preview
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
: PANE_RATIO.current,
|
: PANE_RATIO.current,
|
||||||
);
|
);
|
||||||
|
const currentBorder = createMemo<boolean | BorderSides[]>(
|
||||||
|
() => props.currentBorder ?? ["left", "right"],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<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
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={currentLabel}
|
label={currentLabel}
|
||||||
@@ -197,10 +203,10 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={() => ""}
|
label={() => ""}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
border={["left", "right"]}
|
border={currentBorder()}
|
||||||
scrollFocused={() => focused()}
|
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}>
|
<Show when={panes() === 3}>
|
||||||
<Pane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
|
|||||||
@@ -11,8 +11,8 @@
|
|||||||
* event bus. There is no sidebar pane.
|
* event bus. There is no sidebar pane.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For } from "solid-js";
|
import { createEffect, createSignal, onCleanup, Show, For } from "solid-js";
|
||||||
import { useKeyboard, useRenderer } from "@opentui/solid";
|
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
import { useKeybinds, type KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
import { useNavigation, NavMode } from "@/context/NavigationContext";
|
||||||
@@ -27,6 +27,7 @@ import { TABS } from "@/utils/navigation";
|
|||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -168,7 +169,6 @@ export function Shell() {
|
|||||||
nav.backspaceCommand();
|
nav.backspaceCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// printable char
|
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.appendCommand(evt.name);
|
nav.appendCommand(evt.name);
|
||||||
@@ -216,11 +216,18 @@ export function Shell() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── Status bar fragments ──────────────────────────────────────────────────
|
// ── Status bar fragments ──────────────────────────────────────────────────
|
||||||
const nowPlaying = () => {
|
// Now-playing text carries the podcast name (custom name when set) when
|
||||||
|
// the episode's feed is resolvable, mirroring advanceEpisode's lookup.
|
||||||
|
const nowPlayingText = () => {
|
||||||
const ep = audio.currentEpisode();
|
const ep = audio.currentEpisode();
|
||||||
if (!ep) return null;
|
if (!ep) return null;
|
||||||
const title = ep.title.length > 40 ? ep.title.slice(0, 38) + "…" : ep.title;
|
const feeds = feedStore.getFilteredFeeds();
|
||||||
return `♪ ${title}`;
|
const feed =
|
||||||
|
feeds.find((f) => f.podcast.id === ep.podcastId) ??
|
||||||
|
feeds.find((f) => f.episodes.some((e) => e.id === ep.id));
|
||||||
|
return feed
|
||||||
|
? `♪ ${feed.customName || feed.podcast.title} — ${ep.title}`
|
||||||
|
: `♪ ${ep.title}`;
|
||||||
};
|
};
|
||||||
const modeLabel = () =>
|
const modeLabel = () =>
|
||||||
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
nav.mode() === NavMode.NORMAL ? "" : `-- ${nav.mode()} --`;
|
||||||
@@ -230,6 +237,68 @@ export function Shell() {
|
|||||||
.map((s) => s.key)
|
.map((s) => s.key)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
|
// ── Now-playing marquee ────────────────────────────────────────────────────
|
||||||
|
// The now-playing segment takes the full remaining status-bar width and
|
||||||
|
// marquee-scrolls 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 (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -290,12 +359,14 @@ export function Shell() {
|
|||||||
● {nav.selectedIds().length}
|
● {nav.selectedIds().length}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={nowPlaying()}>
|
<Show when={nowPlayingText()}>
|
||||||
<text fg={t.primary} paddingLeft={1}>
|
<box flexGrow={1} paddingLeft={1}>
|
||||||
{nowPlaying()}
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
</text>
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text fg={t.primary} content={visible()} />
|
||||||
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
<box flexGrow={1} />
|
|
||||||
<text fg={t.textMuted} paddingRight={1}>
|
<text fg={t.textMuted} paddingRight={1}>
|
||||||
{pendingLabel()}
|
{pendingLabel()}
|
||||||
</text>
|
</text>
|
||||||
@@ -325,6 +396,8 @@ export function Shell() {
|
|||||||
theme={t as any}
|
theme={t as any}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
||||||
|
<GlobalActivityIndicator />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -376,6 +449,7 @@ function helpSections(k: ReturnType<typeof useKeybinds>) {
|
|||||||
["enter", "open"],
|
["enter", "open"],
|
||||||
["r", "refresh"],
|
["r", "refresh"],
|
||||||
["s", "search"],
|
["s", "search"],
|
||||||
|
[p("search-scope-toggle"), "shows/episodes"],
|
||||||
["f", "filter"],
|
["f", "filter"],
|
||||||
[",", "sort"],
|
[",", "sort"],
|
||||||
[".", "hidden"],
|
[".", "hidden"],
|
||||||
|
|||||||
@@ -19,86 +19,105 @@ import { For } from "solid-js";
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
[TABS.MYSHOWS]: "My Shows",
|
[TABS.MYSHOWS]: "My Shows",
|
||||||
[TABS.DISCOVER]: "Discover",
|
[TABS.DISCOVER]: "Discover",
|
||||||
[TABS.SEARCH]: "Search",
|
[TABS.SEARCH]: "Search",
|
||||||
[TABS.PLAYER]: "Player",
|
[TABS.PLAYER]: "Player",
|
||||||
[TABS.SETTINGS]: "Settings",
|
[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). */
|
/** Numeric TABS values, in declaration order (1..TabsCount). */
|
||||||
const TAB_ORDER = Object.values(TABS).filter(
|
const TAB_ORDER = Object.values(TABS).filter(
|
||||||
(v): v is TABS => typeof v === "number",
|
(v): v is TABS => typeof v === "number",
|
||||||
) as TABS[];
|
) as TABS[];
|
||||||
|
|
||||||
export function TabListPane(props: { muted?: boolean }) {
|
export function TabListPane(props: { muted?: boolean }) {
|
||||||
const { theme } = useTheme();
|
// Static: detection never changes mid-session.
|
||||||
const nav = useNavigation();
|
const nerd = supportsNerdFonts();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const cursor = () => nav.tabCursor();
|
const cursor = () => nav.tabCursor();
|
||||||
const activeTab = () => nav.activeTab();
|
const activeTab = () => nav.activeTab();
|
||||||
/** `active=true` when this pane is the CURRENT column (Shell root);
|
/** `active=true` when this pane is the CURRENT column (Shell root);
|
||||||
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
* `false` when it is the muted UP/parent column (pages' parent pane). */
|
||||||
const active = () => !props.muted;
|
const active = () => !props.muted;
|
||||||
|
|
||||||
// Same focus-bg / focus-fg contract every other pane uses.
|
// Same focus-bg / focus-fg contract every other pane uses.
|
||||||
const focusBg = (t: TABS) =>
|
const focusBg = (t: TABS) =>
|
||||||
t === cursor() && active()
|
t === cursor() && active()
|
||||||
? theme.primary
|
? theme.primary
|
||||||
: t === cursor()
|
: t === cursor()
|
||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (t: TABS) =>
|
const focusFg = (t: TABS) =>
|
||||||
t === cursor() && active()
|
t === cursor() && active()
|
||||||
? theme.surface
|
? theme.surface
|
||||||
: t === cursor()
|
: t === cursor()
|
||||||
? theme.selectedListItemText ?? theme.text
|
? theme.selectedListItemText ?? theme.text
|
||||||
: theme.text;
|
: theme.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<For each={TAB_ORDER}>
|
<For each={TAB_ORDER}>
|
||||||
{(tab) => {
|
{(tab) => {
|
||||||
const isCursor = () => cursor() === tab;
|
const isCursor = () => cursor() === tab;
|
||||||
const isActive = () => activeTab() === tab;
|
const isActive = () => activeTab() === tab;
|
||||||
// The active tab is only accented in the Up/parent position — when this
|
// The active tab is only accented in the Up/parent position — when this
|
||||||
// pane is CURRENT, the cursor highlight is the only highlight.
|
// pane is CURRENT, the cursor highlight is the only highlight.
|
||||||
const labelFg = () =>
|
const labelFg = () =>
|
||||||
isCursor()
|
isCursor()
|
||||||
? focusFg(tab)
|
? focusFg(tab)
|
||||||
: isActive() && !active()
|
: isActive() && !active()
|
||||||
? theme.accent
|
? theme.accent
|
||||||
: theme.text;
|
: theme.text;
|
||||||
const ref = useScrollIntoView(isCursor);
|
const ref = useScrollIntoView(isCursor);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={1}
|
height={1}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(tab)}
|
backgroundColor={focusBg(tab)}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
// Click = hover + open, the yazi "open" of the row
|
// Click = hover + open, the yazi "open" of the row
|
||||||
// (switches to the tab and enters its content), the same
|
// (switches to the tab and enters its content), the same
|
||||||
// as l/Enter. Restores mouse support the tab-strip
|
// as l/Enter. Restores mouse support the tab-strip
|
||||||
// refactor dropped.
|
// refactor dropped.
|
||||||
nav.setTabCursor(tab);
|
nav.setTabCursor(tab);
|
||||||
nav.activateTabCursor();
|
nav.activateTabCursor();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
<text fg={focusFg(tab)}>{isCursor() ? marker() : " "}</text>
|
||||||
<text fg={isCursor() ? focusFg(tab) : theme.textMuted}>{tab}</text>
|
{nerd && (
|
||||||
<text fg={labelFg()} paddingLeft={1}>
|
<text fg={focusFg(tab)} paddingRight={1}>
|
||||||
{TAB_LABEL[tab]}
|
{TAB_ICON[tab]}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
)}
|
||||||
);
|
<text fg={labelFg()} paddingLeft={1}>
|
||||||
}}
|
{TAB_LABEL[tab]}
|
||||||
</For>
|
</text>
|
||||||
);
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,19 +59,28 @@
|
|||||||
"help": ["~", "f1"],
|
"help": ["~", "f1"],
|
||||||
|
|
||||||
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
// ── List operations (yazi: s search, f filter, , sort, . hidden, r refresh)
|
||||||
"search": ["s"],
|
"search": ["s"],
|
||||||
"filter": ["f"],
|
// 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": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"subscribe": ["a"], // subscribe focused show in place (Discover/Search)
|
||||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
"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) ──────────────────────────────────────────
|
// ── Audio transport (preserved) ──────────────────────────────────────────
|
||||||
// Kept on shifted single keys so they never collide with the yazi core
|
// 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.
|
// (space=select, s=search, f=filter, etc.). Edit freely in this file.
|
||||||
"audio-toggle": ["P"], // play / pause (shift+p)
|
"audio-toggle": ["P"], // play / pause (shift+p)
|
||||||
"audio-next": ["N"], // next episode (shift+n)
|
"audio-next": ["N"], // next episode (shift+n)
|
||||||
"audio-prev": ["B"], // prev episode (shift+b)
|
"audio-prev": ["B"], // prev episode (shift+b)
|
||||||
"audio-seek-forward": ["shift-."], // seek forward (shift+.)
|
"audio-seek-forward": ["shift-."], // seek forward (> = shift+.)
|
||||||
"audio-seek-backward": ["shift-,"] // seek backward (shift+,)
|
"audio-seek-backward": ["shift-,"] // seek backward (< = shift+,)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,11 +63,16 @@ export type KeybindActionName =
|
|||||||
| "quit"
|
| "quit"
|
||||||
| "help"
|
| "help"
|
||||||
| "search"
|
| "search"
|
||||||
|
| "search-scope-toggle"
|
||||||
| "filter"
|
| "filter"
|
||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "subscribe"
|
||||||
| "unsubscribe"
|
| "unsubscribe"
|
||||||
|
| "download"
|
||||||
|
| "delete-download"
|
||||||
|
| "whitelist-toggle"
|
||||||
| "audio-toggle"
|
| "audio-toggle"
|
||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
generateSyntax,
|
generateSyntax,
|
||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} from "../utils/syntax-highlighter";
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
import { resolveTerminalTheme } from "../utils/theme";
|
||||||
|
import { getCustomThemes } from "../utils/custom-themes";
|
||||||
import { detectModeFromBackground } from "../utils/system-theme";
|
import { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme();
|
resolveSystemTheme();
|
||||||
loadThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
setStore("active", "catppuccin");
|
setStore("active", "catppuccin");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Only set ready if not waiting for system theme
|
|
||||||
if (store.active !== "system") {
|
if (store.active !== "system") {
|
||||||
setStore("ready", true);
|
setStore("ready", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
*
|
*
|
||||||
* parent | current | preview
|
* 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*
|
* `@/utils/navigation` (PANE_RATIO). This module owns only the *focusable*
|
||||||
* nav model — which column is focused and where its list cursor lives. The
|
* nav model — which column is focused and where its list cursor lives. The
|
||||||
* parent/preview columns are always derived, never focused.
|
* parent/preview columns are always derived, never focused.
|
||||||
|
|||||||
85
src/effects/feed-refresh.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Feed-refresh batch as an Effect program.
|
||||||
|
*
|
||||||
|
* Replaces the hand-rolled worker pool (mapWithConcurrency) + per-feed
|
||||||
|
* fetch/apply plumbing in stores/feed.ts with Effect's structured
|
||||||
|
* concurrency:
|
||||||
|
* - `Effect.forEach(..., { concurrency })` bounds in-flight fetches to
|
||||||
|
* `concurrency` (starts exactly that many fibers; each completion pulls
|
||||||
|
* the next feed — identical semantics to the old shared-counter pool).
|
||||||
|
* - `Effect.timeout` bounds each feed's fetch to `timeoutMs`. It runs
|
||||||
|
* through the `Clock` service, so under `TestContext` the TestClock
|
||||||
|
* drives it deterministically (no real 20s wait in tests).
|
||||||
|
* - Failures are folded to a null result: a failed or timed-out feed is
|
||||||
|
* left untouched instead of failing the batch.
|
||||||
|
* - The apply callback runs inside each feed's own fiber, so a feed's
|
||||||
|
* refreshed episodes land AS ITS OWN FETCH COMPLETES — the
|
||||||
|
* per-feed-apply-as-it-lands contract, no Promise.all barrier.
|
||||||
|
*
|
||||||
|
* The store boundary (stores/feed.ts) supplies the real fetch and apply
|
||||||
|
* closures and runs the program with Effect.runPromise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Duration, Effect } from "effect"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
import type { Feed } from "../types/feed"
|
||||||
|
|
||||||
|
/** Result of fetching one feed's RSS. `episodes: null` means the fetch
|
||||||
|
* failed or timed out — callers must leave that feed untouched. */
|
||||||
|
export interface RefreshFetchResult {
|
||||||
|
episodes: Episode[] | null
|
||||||
|
coverUrl: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result guaranteed to have parsed episodes (the apply path only). */
|
||||||
|
export interface RefreshSuccess {
|
||||||
|
episodes: Episode[]
|
||||||
|
coverUrl: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefreshBatchOptions {
|
||||||
|
/** Max simultaneous in-flight fetches. */
|
||||||
|
concurrency: number
|
||||||
|
/** Per-feed fetch timeout in milliseconds. */
|
||||||
|
timeoutMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold any failure (network error, timeout, rejection) to a null result so
|
||||||
|
* one bad feed can never fail the batch. */
|
||||||
|
const failedResult: RefreshFetchResult = { episodes: null, coverUrl: undefined }
|
||||||
|
|
||||||
|
/** Fetch one feed with a timeout, applying its result as its own fetch
|
||||||
|
* lands. A failed or timed-out fetch yields null — the feed is untouched. */
|
||||||
|
const refreshOne = (
|
||||||
|
feed: Feed,
|
||||||
|
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||||
|
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Effect.Effect<void> =>
|
||||||
|
Effect.tryPromise(() => fetchOne(feed)).pipe(
|
||||||
|
Effect.timeout(Duration.millis(timeoutMs)),
|
||||||
|
Effect.catchAll(() => Effect.succeed(failedResult)),
|
||||||
|
Effect.flatMap((result) => {
|
||||||
|
if (result.episodes === null) return Effect.void
|
||||||
|
// Capture the narrowed array before the closure — TS drops the
|
||||||
|
// `episodes !== null` narrowing inside Effect.sync's callback.
|
||||||
|
const episodes = result.episodes
|
||||||
|
return Effect.sync(() => applyOne(feed, { episodes, coverUrl: result.coverUrl }))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Refresh every feed with bounded concurrency. Each feed's refreshed
|
||||||
|
* episodes are applied as its own fetch lands (no barrier); a failed or
|
||||||
|
* timed-out feed is left untouched. The program never fails — failures
|
||||||
|
* are folded to per-feed no-ops. */
|
||||||
|
export const refreshFeedsBatch = (
|
||||||
|
feeds: readonly Feed[],
|
||||||
|
fetchOne: (feed: Feed) => Promise<RefreshFetchResult>,
|
||||||
|
applyOne: (feed: Feed, result: RefreshSuccess) => void,
|
||||||
|
options: RefreshBatchOptions,
|
||||||
|
): Effect.Effect<void> =>
|
||||||
|
Effect.forEach(
|
||||||
|
feeds,
|
||||||
|
(feed) => refreshOne(feed, fetchOne, applyOne, options.timeoutMs),
|
||||||
|
{ concurrency: options.concurrency, discard: true },
|
||||||
|
)
|
||||||
@@ -12,21 +12,52 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
|
import {
|
||||||
|
cachedCoverPath,
|
||||||
|
fetchCoverArt,
|
||||||
|
} from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
|
PlayerRestartedError,
|
||||||
type AudioBackend,
|
type AudioBackend,
|
||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
} from "../utils/audio-player";
|
} from "../utils/audio-player";
|
||||||
|
import {
|
||||||
|
isPlaying,
|
||||||
|
setIsPlaying,
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
volume,
|
||||||
|
setVolume,
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
backendName,
|
||||||
|
setBackendName,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
currentEpisode,
|
||||||
|
setCurrentEpisode,
|
||||||
|
availablePlayers,
|
||||||
|
setAvailablePlayers,
|
||||||
|
} from "../utils/audio-signals";
|
||||||
import { emit, on } from "../utils/event-bus";
|
import { emit, on } from "../utils/event-bus";
|
||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
import { useProgressStore } from "../stores/progress";
|
||||||
import { useMediaRegistry } from "../utils/media-registry";
|
import { useMediaRegistry } from "../utils/media-registry";
|
||||||
import type { Episode } from "../types/episode";
|
import {
|
||||||
|
loadLastPlayerFromFile,
|
||||||
|
saveLastPlayerToFile,
|
||||||
|
saveLastPlayerSync,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -43,6 +74,8 @@ export interface AudioControls {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (episode: Episode) => Promise<void>;
|
play: (episode: Episode) => Promise<void>;
|
||||||
|
/** Load an episode into the player WITHOUT starting playback. */
|
||||||
|
load: (episode: Episode) => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
resume: () => Promise<void>;
|
resume: () => Promise<void>;
|
||||||
togglePlayback: () => Promise<void>;
|
togglePlayback: () => Promise<void>;
|
||||||
@@ -62,17 +95,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|||||||
let refCount = 0;
|
let refCount = 0;
|
||||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||||
const [position, setPosition] = createSignal(0);
|
// so non-component consumers (the visualizer store) can subscribe without
|
||||||
const [duration, setDuration] = createSignal(0);
|
// mounting a useAudio() owner.
|
||||||
const [volume, setVolume] = createSignal(0.7);
|
|
||||||
const [speed, setSpeed] = createSignal(1);
|
/** True once the current episode has been handed to the backend (play
|
||||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
* restored at boot) and the first play action must start the backend
|
||||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
* instead of unpausing it. */
|
||||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
let startedPlayback = false;
|
||||||
[],
|
|
||||||
);
|
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||||
|
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||||
|
|
||||||
|
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||||
|
* progress (never reached the persist threshold) or unknown duration count
|
||||||
|
* as eligible — they restore from the start. */
|
||||||
|
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||||
|
if (!progress || progress.duration <= 0) return true;
|
||||||
|
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBackend(): AudioBackend {
|
function ensureBackend(): AudioBackend {
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
@@ -99,6 +141,17 @@ function registerExitTeardown(): void {
|
|||||||
exitTeardownRegistered = true;
|
exitTeardownRegistered = true;
|
||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
// Persist "what's loaded in the player right now" synchronously —
|
||||||
|
// process.exit(0) runs this handler synchronously and an async write
|
||||||
|
// would never land. The next launch restores this episode paused.
|
||||||
|
try {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
backend?.dispose();
|
backend?.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,45 +172,118 @@ function registerExitTeardown(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||||
|
* UI believes playback is paused we only need to catch an external
|
||||||
|
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||||
|
* tick would just hammer mpv IPC for nothing. */
|
||||||
|
const PAUSE_WATCH_TICKS = 7;
|
||||||
|
|
||||||
|
/** The player process died while we believed playback was live — track
|
||||||
|
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||||
|
* position and stop polling. */
|
||||||
|
function finalizeTrackEnd(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
stopPolling();
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||||
|
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||||
|
* sync; the poll stays armed so an external resume is caught too. */
|
||||||
|
function reconcileExternalPause(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
emit("player.pause", { episodeId: ep.id });
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
media.setPosition(position());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||||
|
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||||
|
function reconcileExternalResume(): void {
|
||||||
|
setIsPlaying(true);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
emit("player.play", { episodeId: ep.id });
|
||||||
|
useMediaRegistry().setPlaybackState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling(): void {
|
function startPolling(): void {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollCount = 0;
|
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 () => {
|
pollTimer = setInterval(async () => {
|
||||||
if (!backend || !isPlaying()) return;
|
if (!backend || pollInFlight) return;
|
||||||
|
pollInFlight = true;
|
||||||
try {
|
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)
|
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount % 10 === 0) {
|
if (isPlaying()) {
|
||||||
const ep = currentEpisode();
|
// Track ended (eof-reached observed) or process died. Check
|
||||||
if (ep) {
|
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||||
const progressStore = useProgressStore();
|
// and reports pause=true there, which would otherwise be
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
// mistaken for an external pause and never finalize.
|
||||||
|
if (!backend.isPlaying()) {
|
||||||
const media = useMediaRegistry();
|
finalizeTrackEnd();
|
||||||
media.setPosition(pos);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if backend stopped playing (track ended)
|
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||||
if (!backend.isPlaying() && isPlaying()) {
|
// staying stuck on "playing" with a frozen waveform
|
||||||
setIsPlaying(false);
|
// (getPosition would just re-read the same frozen time-pos).
|
||||||
stopPolling();
|
const paused = await backend.getPauseState();
|
||||||
// Save final position on track end
|
if (paused === true) {
|
||||||
const ep = currentEpisode();
|
reconcileExternalPause();
|
||||||
if (ep) {
|
return;
|
||||||
const progressStore = useProgressStore();
|
}
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
|
||||||
|
const pos = await backend.getPosition();
|
||||||
|
const dur = await backend.getDuration();
|
||||||
|
setPosition(pos);
|
||||||
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
|
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||||
|
if (pollCount % 33 === 0) {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPosition(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||||
|
// Paused — watch for playback restarted from outside (AirPods,
|
||||||
|
// lock-screen/media-center play). Only while the player is
|
||||||
|
// still alive: a dead player while we thought we were paused
|
||||||
|
// means the track ended (mpv quits at EOF) or it crashed.
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
finalizeTrackEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paused = await backend.getPauseState();
|
||||||
|
if (paused === false) {
|
||||||
|
reconcileExternalResume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend may have been disposed
|
// Backend may have been disposed
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
@@ -167,6 +293,39 @@ 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.
|
||||||
|
|
||||||
|
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||||
|
* call site's latency budget:
|
||||||
|
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||||
|
* network, so a miss plays artless and warms for next time.
|
||||||
|
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||||
|
* at file LOAD, but a slow cover server must not stall audio.
|
||||||
|
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||||
|
* while feeds/progress load anyway, so the wait is free and the
|
||||||
|
* cover must be present when the file loads.
|
||||||
|
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||||
|
* nothing on a warm cache. */
|
||||||
|
async function resolveCoverArt(
|
||||||
|
coverUrl: string | undefined,
|
||||||
|
mode: "cache" | "bounded" | "await",
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!coverUrl) return null;
|
||||||
|
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||||
|
if (mode === "bounded") {
|
||||||
|
const cached = cachedCoverPath(coverUrl);
|
||||||
|
if (cached) return cached;
|
||||||
|
return Promise.race([
|
||||||
|
fetchCoverArt(coverUrl),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return fetchCoverArt(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
async function play(episode: Episode): Promise<void> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend();
|
const b = ensureBackend();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -183,6 +342,24 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = storeSpeed || speed();
|
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 || "";
|
||||||
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
|
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||||
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||||
|
// never becomes an albumart track), so a cold-cache play must wait for
|
||||||
|
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||||
|
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||||
|
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"bounded",
|
||||||
|
);
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
let startPos = 0;
|
let startPos = 0;
|
||||||
@@ -190,10 +367,12 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
startPos = savedProgress.position;
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
|
mediaTitle: episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
@@ -201,12 +380,17 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
setSpeed(spd);
|
setSpeed(spd);
|
||||||
if (episode.duration) setDuration(episode.duration);
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
startedPlayback = true;
|
||||||
|
|
||||||
|
// Remember this episode as "loaded in the player" so the next launch
|
||||||
|
// can restore it paused (cleared by stop()).
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
|
||||||
// Register with platform media controls
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setNowPlaying({
|
media.setNowPlaying({
|
||||||
title: episode.title,
|
title: episode.title,
|
||||||
artist: episode.podcastId,
|
artist: podcastTitle || episode.podcastId,
|
||||||
duration: episode.duration,
|
duration: episode.duration,
|
||||||
});
|
});
|
||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
@@ -223,12 +407,85 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||||
|
* renders it paused at its saved position; the first play action starts the
|
||||||
|
* backend from there (see togglePlayback). Used to restore the last player
|
||||||
|
* session at boot.
|
||||||
|
*/
|
||||||
|
async function load(episode: Episode): Promise<void> {
|
||||||
|
ensureBackend();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setCurrentEpisode(episode);
|
||||||
|
setIsPlaying(false);
|
||||||
|
startedPlayback = false;
|
||||||
|
|
||||||
|
// Show the saved position so the player tab reflects where playback
|
||||||
|
// will resume; episodes at/above the completion threshold start from 0.
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
const saved = progressStore.get(episode.id);
|
||||||
|
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||||
|
setPosition(pos);
|
||||||
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
|
setSpeed(storeSpeed || speed());
|
||||||
|
|
||||||
|
// Surface the loaded-but-paused track to the OS media controls.
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setNowPlaying({
|
||||||
|
title: episode.title,
|
||||||
|
artist: podcastTitle || episode.podcastId,
|
||||||
|
duration: episode.duration,
|
||||||
|
});
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
if (pos > 0) media.setPosition(pos);
|
||||||
|
|
||||||
|
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||||
|
// fills its demuxer cache while parked, so the user's first Play flips
|
||||||
|
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||||
|
// — a failed preload just makes the first play take the cold path.
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
if (episode.audioUrl && backend) {
|
||||||
|
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||||
|
// applies when the file loads, and the runtime video-add fallback
|
||||||
|
// never becomes an albumart track (verified). Restore already waits
|
||||||
|
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||||
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
|
// the feed has no channel cover.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"await",
|
||||||
|
);
|
||||||
|
const backendSnap = backend;
|
||||||
|
backendSnap
|
||||||
|
.preload(url, {
|
||||||
|
volume: volume(),
|
||||||
|
speed: storeSpeed || speed(),
|
||||||
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
|
mediaTitle: episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
async function pause(): Promise<void> {
|
async function pause(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
await backend.pause();
|
await backend.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
stopPolling();
|
// Polling stays armed (paused-watch mode): playback can be resumed
|
||||||
|
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
|
||||||
|
// and the poll must be live to catch it.
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
// Save progress on pause
|
// Save progress on pause
|
||||||
@@ -246,8 +503,25 @@ async function pause(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** mpv was killed/crashed: respawn it and restart playback from the saved
|
||||||
|
* position via the full play path (fresh loadfile, cover art, media
|
||||||
|
* registry). A bare unpause would target a dead — or freshly-idle —
|
||||||
|
* daemon and silently do nothing. */
|
||||||
|
async function recoverPlayback(): Promise<void> {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep && ep.audioUrl) {
|
||||||
|
await play(ep);
|
||||||
|
} else {
|
||||||
|
setError("Player is not running");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resume(): Promise<void> {
|
async function resume(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await backend.resume();
|
await backend.resume();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -259,6 +533,13 @@ async function resume(): Promise<void> {
|
|||||||
media.setPlaybackState(true);
|
media.setPlaybackState(true);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Race: the daemon died between the liveness check above and the
|
||||||
|
// unpause — backend.resume() respawned it and threw
|
||||||
|
// PlayerRestartedError (the fresh daemon has no file loaded).
|
||||||
|
if (err instanceof PlayerRestartedError) {
|
||||||
|
await recoverPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError(err instanceof Error ? err.message : "Resume failed");
|
setError(err instanceof Error ? err.message : "Resume failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +548,15 @@ async function togglePlayback(): Promise<void> {
|
|||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await pause();
|
await pause();
|
||||||
} else if (currentEpisode()) {
|
} else if (currentEpisode()) {
|
||||||
await resume();
|
if (startedPlayback) {
|
||||||
|
await resume();
|
||||||
|
} else {
|
||||||
|
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||||
|
// was never started, so unpausing a dead player would fail
|
||||||
|
// silently. Start playback from the saved position instead.
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) await play(ep);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,9 +573,13 @@ async function stop(): Promise<void> {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setPosition(0);
|
setPosition(0);
|
||||||
setCurrentEpisode(null);
|
setCurrentEpisode(null);
|
||||||
|
startedPlayback = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
|
// Player is empty again — nothing to restore on the next launch.
|
||||||
|
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||||
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -319,6 +612,10 @@ async function doSetVolume(vol: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVolume(clamped);
|
setVolume(clamped);
|
||||||
|
|
||||||
|
// Sync back to app store (persisted to config.json for the next launch).
|
||||||
|
const appStore = useAppStore();
|
||||||
|
appStore.updateSettings({ volume: clamped });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetSpeed(spd: number): Promise<void> {
|
async function doSetSpeed(spd: number): Promise<void> {
|
||||||
@@ -357,12 +654,26 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
// Resume playback if we were playing
|
// Resume playback if we were playing
|
||||||
if (wasPlaying && ep && ep.audioUrl) {
|
if (wasPlaying && ep && ep.audioUrl) {
|
||||||
try {
|
try {
|
||||||
await backend.play(ep.audioUrl, {
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore
|
||||||
|
.feeds()
|
||||||
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const url =
|
||||||
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||||
|
"cache",
|
||||||
|
);
|
||||||
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
|
mediaTitle: ep.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
startedPlayback = true;
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||||
@@ -371,6 +682,46 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serialized restore chain: the boot-triggered restore and any explicit
|
||||||
|
* call run one after another, so a late-finishing earlier restore can never
|
||||||
|
* overwrite state changed by a later one (and callers can await the latest
|
||||||
|
* attempt deterministically). */
|
||||||
|
let restoreChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time session restore: reload the episode that was loaded in the
|
||||||
|
* player when the previous run ended (persisted on play/load and at exit),
|
||||||
|
* paused at its saved position — never autostarted. Episodes at/above the
|
||||||
|
* completion threshold are skipped. Silently no-ops when there is nothing
|
||||||
|
* to restore (empty player, unsubscribed show, or completed episode).
|
||||||
|
*/
|
||||||
|
export async function restoreLastSession(): Promise<void> {
|
||||||
|
const attempt = restoreChain.then(async () => {
|
||||||
|
const marker = await loadLastPlayerFromFile();
|
||||||
|
if (!marker?.episodeId) return;
|
||||||
|
|
||||||
|
// Feeds and progress load asynchronously at boot; wait for both
|
||||||
|
// before looking the episode up.
|
||||||
|
await Promise.all([
|
||||||
|
useProgressStore().whenReady(),
|
||||||
|
useFeedStore().whenReady(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const episode = useFeedStore().findEpisode(marker.episodeId);
|
||||||
|
if (!episode) return;
|
||||||
|
|
||||||
|
// Only restore episodes below the completion threshold.
|
||||||
|
const saved = useProgressStore().get(episode.id);
|
||||||
|
if (!isRestoreEligible(saved)) return;
|
||||||
|
|
||||||
|
await load(episode);
|
||||||
|
});
|
||||||
|
// Keep the chain alive even when an attempt fails; the caller awaiting
|
||||||
|
// this attempt still observes its own outcome.
|
||||||
|
restoreChain = attempt.catch(() => {});
|
||||||
|
await attempt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive audio controls hook.
|
* Reactive audio controls hook.
|
||||||
*
|
*
|
||||||
@@ -381,13 +732,29 @@ export function useAudio(): AudioControls {
|
|||||||
// Initialize backend on first use
|
// Initialize backend on first use
|
||||||
ensureBackend();
|
ensureBackend();
|
||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed/volume from app store (reuse the previous session's
|
||||||
|
// playback levels; defaults are 1x and 100%).
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
if (storeSpeed && storeSpeed !== speed()) {
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
setSpeed(storeSpeed);
|
setSpeed(storeSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume re-syncs once settings finish loading (async config read)
|
||||||
|
// so a level persisted last session is applied at boot.
|
||||||
|
appStore
|
||||||
|
.whenReady()
|
||||||
|
.then(() => {
|
||||||
|
const storeVolume = appStore.state().settings.volume;
|
||||||
|
if (storeVolume !== undefined && storeVolume !== volume()) {
|
||||||
|
setVolume(storeVolume);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Restore the last player session once at boot (loaded, not playing).
|
||||||
|
restoreLastSession().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
@@ -421,14 +788,6 @@ export function useAudio(): AudioControls {
|
|||||||
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))));
|
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 unsubMediaSpeed = on("media.speedCycle", async () => {
|
||||||
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2));
|
||||||
await doSetSpeed(next);
|
await doSetSpeed(next);
|
||||||
@@ -515,8 +874,6 @@ export function useAudio(): AudioControls {
|
|||||||
unsubMediaToggle();
|
unsubMediaToggle();
|
||||||
unsubMediaVolUp();
|
unsubMediaVolUp();
|
||||||
unsubMediaVolDown();
|
unsubMediaVolDown();
|
||||||
unsubMediaSeekFwd();
|
|
||||||
unsubMediaSeekBack();
|
|
||||||
unsubMediaSpeed();
|
unsubMediaSpeed();
|
||||||
|
|
||||||
if (refCount <= 0) {
|
if (refCount <= 0) {
|
||||||
@@ -545,6 +902,7 @@ export function useAudio(): AudioControls {
|
|||||||
availablePlayers,
|
availablePlayers,
|
||||||
|
|
||||||
play,
|
play,
|
||||||
|
load,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Global multimedia key handler hook.
|
* 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
|
* regardless of which component is focused. Uses the event bus to
|
||||||
* decouple key detection from audio control logic.
|
* decouple key detection from audio control logic.
|
||||||
*
|
*
|
||||||
* Volume and speed are app-level settings — adjustable with or without
|
* Volume and speed are app-level settings — adjustable with or without
|
||||||
* an episode loaded (they apply to the next playback and persist). Seek
|
* 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";
|
import { useKeyboard } from "@opentui/solid";
|
||||||
@@ -17,8 +18,6 @@ export type MediaKeyAction =
|
|||||||
| "media.toggle"
|
| "media.toggle"
|
||||||
| "media.volumeUp"
|
| "media.volumeUp"
|
||||||
| "media.volumeDown"
|
| "media.volumeDown"
|
||||||
| "media.seekForward"
|
|
||||||
| "media.seekBackward"
|
|
||||||
| "media.speedCycle";
|
| "media.speedCycle";
|
||||||
|
|
||||||
export interface MultimediaKeysOptions {
|
export interface MultimediaKeysOptions {
|
||||||
@@ -26,8 +25,6 @@ export interface MultimediaKeysOptions {
|
|||||||
playerFocused?: () => boolean;
|
playerFocused?: () => boolean;
|
||||||
/** When true, skip handling (text input has focus) */
|
/** When true, skip handling (text input has focus) */
|
||||||
inputFocused?: () => boolean;
|
inputFocused?: () => boolean;
|
||||||
/** Whether an episode is currently loaded */
|
|
||||||
hasEpisode?: () => boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,17 +56,9 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
|||||||
emit("media.volumeDown", {});
|
emit("media.volumeDown", {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "left":
|
|
||||||
if (!options.hasEpisode?.()) return;
|
|
||||||
emit("media.seekBackward", {});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "right":
|
|
||||||
if (!options.hasEpisode?.()) return;
|
|
||||||
emit("media.seekForward", {});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
|
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||||
|
if (!key.shift) return;
|
||||||
emit("media.speedCycle", {});
|
emit("media.speedCycle", {});
|
||||||
break;
|
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 { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.3.1";
|
const VERSION = "0.6.2";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -42,7 +42,6 @@ if (cliArgs.version) {
|
|||||||
|
|
||||||
// ── CLI handlers ──────────────────────────────────────────────────────
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Find the most recent episode across all feeds */
|
|
||||||
function findLatestEpisode(
|
function findLatestEpisode(
|
||||||
feeds: Feed[],
|
feeds: Feed[],
|
||||||
): { feed: Feed; episode: Episode } | null {
|
): { feed: Feed; episode: Episode } | null {
|
||||||
@@ -182,9 +181,22 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { createAudioBackend } = await import("./utils/audio-player")
|
const { createAudioBackend } = await import("./utils/audio-player")
|
||||||
|
const { fetchCoverArt } = await import("./utils/cover-art")
|
||||||
const backend = createAudioBackend()
|
const backend = createAudioBackend()
|
||||||
if (episodeResult.audioUrl) {
|
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. Falls
|
||||||
|
// back to the episode's own image when the feed has no channel
|
||||||
|
// cover (URL-added feeds).
|
||||||
|
const coverUrl =
|
||||||
|
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||||
|
const coverArtPath = coverUrl
|
||||||
|
? await fetchCoverArt(coverUrl)
|
||||||
|
: null
|
||||||
|
await backend.play(episodeResult.audioUrl, {
|
||||||
|
mediaTitle: episodeResult.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
|
})
|
||||||
console.log("Playback started (use the UI to control)")
|
console.log("Playback started (use the UI to control)")
|
||||||
} else {
|
} else {
|
||||||
console.log("No audio URL available for this episode")
|
console.log("No audio URL available for this episode")
|
||||||
|
|||||||
@@ -5,18 +5,28 @@
|
|||||||
* placeholder (1/5 slot kept).
|
* placeholder (1/5 slot kept).
|
||||||
* depth 1 (current) — podcast results for the drilled category. Parent
|
* depth 1 (current) — podcast results for the drilled category. Parent
|
||||||
* pane = the categories list.
|
* pane = the categories list.
|
||||||
* preview — detail of the hovered item (category summary, or
|
* depth 2 (current) — episodes of the drilled show, fetched on demand
|
||||||
* podcast detail + subscribe action).
|
* WITHOUT subscribing. Parent pane = the results list.
|
||||||
|
* preview — detail of the hovered item (category summary,
|
||||||
|
* podcast detail, or episode detail).
|
||||||
*
|
*
|
||||||
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (category → results) or subscribes (on a
|
* remains. `l`/Enter drills in (category → results → episodes); `a`
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* subscribes the focused show (enter/l never subscribe — they open the
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* episode list); `h` pops a depth (noop at 0). j/k move only within the
|
||||||
* category so the preview follows.
|
* current column. Moving through categories at depth 0 updates the store's
|
||||||
|
* selected category so the preview follows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
import type { Podcast } from "@/types/podcast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -27,20 +37,31 @@ import {
|
|||||||
type DepthFrame,
|
type DepthFrame,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { EpisodeRow, EpisodePreview } from "@/components/EpisodeList";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const DiscoverPaneCount = 1;
|
export const DiscoverPaneCount = 1;
|
||||||
|
|
||||||
function DiscoverPage() {
|
function DiscoverPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const discoverStore = useDiscoverStore();
|
const discoverStore = useDiscoverStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
const focus = (d: number = depth()) => nav.depthFocus(d);
|
const focus = (d: number = depth()) => nav.depthFocus(d);
|
||||||
|
|
||||||
@@ -55,14 +76,37 @@ function DiscoverPage() {
|
|||||||
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
podcasts().length === 0 ? 0 : Math.min(focus(1), podcasts().length - 1);
|
||||||
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
const focusedPodcast = createMemo(() => podcasts()[focusedPodIdx()]);
|
||||||
|
|
||||||
|
// depth-2 frame ctx = the drilled podcast id (episode preview, no
|
||||||
|
// subscription). Episodes come from the discover store's session cache.
|
||||||
|
const drilledPodcastId = (): string => stack()[2]?.ctx ?? "";
|
||||||
|
const drilledPodcast = (): Podcast | undefined =>
|
||||||
|
podcasts().find((p) => p.id === drilledPodcastId());
|
||||||
|
const episodes = createMemo<Episode[]>(() => {
|
||||||
|
if (depth() < 2) return [];
|
||||||
|
return discoverStore.episodesForPodcast(drilledPodcastId());
|
||||||
|
});
|
||||||
|
const episodesLoading = () =>
|
||||||
|
depth() >= 2 && discoverStore.isLoadingEpisodesFor(drilledPodcastId());
|
||||||
|
const episodesError = () =>
|
||||||
|
depth() >= 2 ? discoverStore.previewError(drilledPodcastId()) : undefined;
|
||||||
|
const focusedEpIdx = () =>
|
||||||
|
episodes().length === 0 ? 0 : Math.min(focus(2), episodes().length - 1);
|
||||||
|
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const curLen = () =>
|
const curLen = () =>
|
||||||
depth() === 0 ? categories().length : podcasts().length;
|
depth() === 0
|
||||||
|
? categories().length
|
||||||
|
: depth() === 1
|
||||||
|
? podcasts().length
|
||||||
|
: episodes().length;
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (categories().length > 0 && focus(0) >= categories().length)
|
if (categories().length > 0 && focus(0) >= categories().length)
|
||||||
nav.setDepthFocus(categories().length - 1, 0);
|
nav.setDepthFocus(categories().length - 1, 0);
|
||||||
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
if (podcasts().length > 0 && focus(1) >= podcasts().length)
|
||||||
nav.setDepthFocus(podcasts().length - 1, 1);
|
nav.setDepthFocus(podcasts().length - 1, 1);
|
||||||
|
if (episodes().length > 0 && focus(2) >= episodes().length)
|
||||||
|
nav.setDepthFocus(episodes().length - 1, 2);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
@@ -75,13 +119,56 @@ function DiscoverPage() {
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return categories()[i]?.id;
|
if (depth() === 0) return categories()[i]?.id;
|
||||||
return podcasts()[i]?.id;
|
if (depth() === 1) return podcasts()[i]?.id;
|
||||||
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
/** The subscribed feed backing a podcast, if any (matched by directory id
|
||||||
|
* or feed URL — a Discover show may already be subscribed). */
|
||||||
|
const feedForPodcast = (p: Podcast) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === p.id ||
|
||||||
|
(!!p.feedUrl && f.podcast.feedUrl === p.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const playEpisode = (ep: Episode) => {
|
||||||
|
audio.play(ep).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, drilledPodcast()?.id);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
@@ -92,9 +179,19 @@ function DiscoverPage() {
|
|||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) discoverStore.toggleSubscription(pod.id);
|
if (!pod) return;
|
||||||
|
// Drill into the show's episode list WITHOUT subscribing — `l`,
|
||||||
|
// right, and Enter open the episodes; `a` is the subscribe key.
|
||||||
|
discoverStore.openEpisodes(pod).catch(() => {});
|
||||||
|
nav.pushDepth({ kind: "episodes", ctx: pod.id, focus: 0 } as DepthFrame);
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,12 +207,59 @@ function DiscoverPage() {
|
|||||||
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
"goto-bottom": () => nav.gotoIndex(curLen() - 1, curLen()),
|
||||||
open: () => open(),
|
open: () => open(),
|
||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() >= 1) {
|
if (depth() === 1) {
|
||||||
const pod = focusedPodcast();
|
const pod = focusedPodcast();
|
||||||
if (pod) nav.toggleSelected(pod.id);
|
if (pod) nav.toggleSelected(pod.id);
|
||||||
}
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() !== 2) return;
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (!pod || !ep) return;
|
||||||
|
// Under its subscribed feed when already subscribed, otherwise as
|
||||||
|
// an "unsubscribed show" download (mirrors Search).
|
||||||
|
const feed = feedForPodcast(pod);
|
||||||
|
if (feed) downloadStore.startDownload(ep, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(ep, pod);
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 2) 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(() => {});
|
||||||
|
},
|
||||||
|
// `a`/`x` — the dedicated subscribe/unsubscribe keys (enter/l now open
|
||||||
|
// the episode list, so subscribing moved off open).
|
||||||
|
subscribe: () => {
|
||||||
|
if (depth() === 1) {
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod && !pod.isSubscribed) discoverStore.subscribe(pod.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const pod = focusedPodcast();
|
||||||
|
if (pod?.isSubscribed) discoverStore.unsubscribe(pod.id);
|
||||||
},
|
},
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
|
if (depth() >= 2) {
|
||||||
|
const pod = drilledPodcast();
|
||||||
|
if (pod) discoverStore.refreshEpisodes(pod).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
discoverStore.refresh().catch(() => {});
|
discoverStore.refresh().catch(() => {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -156,37 +300,78 @@ function DiscoverPage() {
|
|||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? "Categories"
|
? "Categories"
|
||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
: depth() === 1
|
||||||
|
? `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`
|
||||||
|
: `${drilledPodcast()?.title ?? "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
// Sibling <Show> blocks per depth (the known-good opentui disposal
|
||||||
// mounts/unmounts cleanly on depth change.
|
// 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 = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<>
|
||||||
<For each={categories()}>
|
<Show when={depth() === 0}>
|
||||||
{(cat, index) => {
|
<TabListPane muted />
|
||||||
const lf = () => nav.depthFocus(0);
|
</Show>
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
<Show when={depth() === 1}>
|
||||||
return (
|
<For each={categories()}>
|
||||||
<box
|
{(cat, index) => {
|
||||||
ref={ref}
|
const lf = () => nav.depthFocus(0);
|
||||||
flexDirection="row"
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
gap={1}
|
return (
|
||||||
paddingLeft={1}
|
<box
|
||||||
paddingRight={1}
|
ref={ref}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
flexDirection="row"
|
||||||
>
|
gap={1}
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
paddingRight={1}
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
</text>
|
>
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
{cat.name}
|
{index() === nav.depthFocus(0) ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
{nerd && (
|
||||||
);
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
}}
|
{cat.icon}
|
||||||
</For>
|
</text>
|
||||||
</Show>
|
)}
|
||||||
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(podcast, index) => {
|
||||||
|
const lf = () => nav.depthFocus(1);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
|
>
|
||||||
|
<text fg={focusFg(index(), lf(), false)}>
|
||||||
|
{index() === lf() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={focusFg(index(), lf(), false)}>
|
||||||
|
{podcast.title}
|
||||||
|
</text>
|
||||||
|
<Show when={podcast.isSubscribed}>
|
||||||
|
<text flexShrink={0} fg={muted()}>[+]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── current pane ───────────────────────────────────────────────────────────
|
// ── current pane ───────────────────────────────────────────────────────────
|
||||||
@@ -203,7 +388,6 @@ function DiscoverPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -213,8 +397,13 @@ function DiscoverPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
|
{nerd && (
|
||||||
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
|
{cat.icon}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -222,7 +411,7 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: results */}
|
{/* depth ≥1: results */}
|
||||||
<Show when={depth() >= 1}>
|
<Show when={depth() === 1}>
|
||||||
<Show
|
<Show
|
||||||
when={podcasts().length > 0}
|
when={podcasts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -247,7 +436,6 @@ function DiscoverPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={focusBg(index(), lf(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -257,7 +445,7 @@ function DiscoverPage() {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
<text fg={focusFg(index(), lf(), isActive())}>
|
||||||
{podcast.title}
|
{podcast.title}
|
||||||
@@ -284,11 +472,59 @@ function DiscoverPage() {
|
|||||||
</For>
|
</For>
|
||||||
<Show when={discoverStore.isLoading()}>
|
<Show when={discoverStore.isLoading()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box paddingLeft={2} paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* depth ≥2: episodes of the drilled show (preview, no subscription) */}
|
||||||
|
<Show when={depth() >= 2}>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found. :refresh</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length > 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={episodes()}>
|
||||||
|
{(ep, index) => (
|
||||||
|
<EpisodeRow
|
||||||
|
episode={ep}
|
||||||
|
index={index}
|
||||||
|
focused={focusedEpIdx}
|
||||||
|
active={isActive}
|
||||||
|
selected={() => nav.isSelected(ep.id)}
|
||||||
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 2);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -337,8 +573,8 @@ function DiscoverPage() {
|
|||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : depth() === 1 ? (
|
||||||
// depth ≥1 preview: hovered podcast + subscribe
|
// depth 1 preview: hovered podcast + episode-list hint
|
||||||
<Show
|
<Show
|
||||||
when={focusedPodcast()}
|
when={focusedPodcast()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -356,10 +592,10 @@ function DiscoverPage() {
|
|||||||
<text fg={muted()}>by {pod().author}</text>
|
<text fg={muted()}>by {pod().author}</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={pod().isSubscribed}>
|
<Show when={pod().isSubscribed}>
|
||||||
<text fg={theme.success}>✓ Subscribed</text>
|
<text fg={theme.success}>✓ Subscribed · x: unsubscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!pod().isSubscribed}>
|
<Show when={!pod().isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>a: subscribe</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
@@ -378,10 +614,67 @@ function DiscoverPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
<text fg={muted()}>Updated: {formatDate(pod().lastUpdated)}</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: subscribe · h: back · r: refresh</text>
|
<text fg={muted()}>enter/l: episodes · h: back · r: refresh</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
) : (
|
||||||
|
// depth ≥2 preview: hovered episode (or loading/error/empty)
|
||||||
|
<>
|
||||||
|
<Show when={episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<LoadingIndicator label="Loading episodes…" />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show when={episodesError() && !episodesLoading()}>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={theme.error}>{episodesError()}</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>r: retry · h: back</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() && !episodesError() && episodes().length === 0
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episodes found.</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<Show
|
||||||
|
when={
|
||||||
|
!episodesLoading() &&
|
||||||
|
!episodesError() &&
|
||||||
|
episodes().length > 0 &&
|
||||||
|
focusedEpisode()
|
||||||
|
}
|
||||||
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No episode focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(ep) => (
|
||||||
|
<EpisodePreview
|
||||||
|
episode={() => ep()}
|
||||||
|
author={() => drilledPodcast()?.author}
|
||||||
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
|
hint={() =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(ep().id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
drilledPodcast()?.isSubscribed ? "" : " · a: subscribe"
|
||||||
|
} · h: back`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
|
|||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { prefetchCoverArt } from "@/utils/cover-art";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -32,19 +32,28 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
type EpItem = { episode: Episode; feed: Feed };
|
type EpItem = { episode: Episode; feed: Feed };
|
||||||
|
|
||||||
function FeedPage() {
|
function FeedPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
@@ -52,20 +61,36 @@ function FeedPage() {
|
|||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
// ── flat episode list (depth 0 — the only depth Feed has) ────────────────
|
||||||
const episodes = createMemo<EpItem[]>(
|
const episodes = createMemo<EpItem[]>(
|
||||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Cover warm-up ────────────────────────────────────────────────────────
|
||||||
|
// Prefetch covers for episodes around the focus (plus the top of the
|
||||||
|
// list) so plays land on a warm cache: cover-art-files only applies at
|
||||||
|
// file load, and there is no working runtime fallback. Single-flight +
|
||||||
|
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
|
||||||
|
createEffect(() => {
|
||||||
|
const list = episodes();
|
||||||
|
const focusIdx = focusedEpIdx();
|
||||||
|
const start = Math.max(0, focusIdx - 10);
|
||||||
|
const end = Math.min(list.length, focusIdx + 11);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const item = list[i];
|
||||||
|
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
// reaching the bottom row fetches automatically (see the effect below).
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
// Total navigable rows: episodes + the optional Fetch More row.
|
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(0);
|
const focus = () => nav.depthFocus(0);
|
||||||
const focusedRow = () =>
|
const focusedRow = () =>
|
||||||
@@ -81,7 +106,30 @@ function FeedPage() {
|
|||||||
const focusedItem = (): EpItem | undefined =>
|
const focusedItem = (): EpItem | undefined =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const curLen = () => rowCount();
|
const curLen = () => rowCount();
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
|
||||||
|
// ── Render window ────────────────────────────────────────────────────────
|
||||||
|
// The union grows to thousands of episodes after repeated fetch-more
|
||||||
|
// presses; rendering every row per frame froze the UI. Render only a
|
||||||
|
// bounded slice around the focus (real indexes preserved) — the scrollbox
|
||||||
|
// still keeps the focused row in view. Spacers above/below the window
|
||||||
|
// restore the full content height so the scrollbar tracks the real list.
|
||||||
|
// Each EpisodeRow is 3 lines tall (title, subtitle, date).
|
||||||
|
const LIST_WINDOW = 30;
|
||||||
|
const ROW_HEIGHT = 3;
|
||||||
|
const listWindow = createMemo<[number, number]>(() => {
|
||||||
|
const len = episodes().length;
|
||||||
|
// Focusing the Fetch More button keeps the window anchored at the
|
||||||
|
// last episode — no jump when the focus crosses onto the button.
|
||||||
|
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||||
|
return [
|
||||||
|
Math.max(0, f - LIST_WINDOW),
|
||||||
|
Math.min(len, f + LIST_WINDOW + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const visibleEpisodes = createMemo(() => {
|
||||||
|
const [start, end] = listWindow();
|
||||||
|
return episodes().slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (rowCount() > 0 && focus() >= rowCount())
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
@@ -107,12 +155,6 @@ function FeedPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -171,6 +213,18 @@ function FeedPage() {
|
|||||||
const item = focusedItem();
|
const item = focusedItem();
|
||||||
if (item) nav.toggleSelected(item.episode.id);
|
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: () => {
|
refresh: () => {
|
||||||
feedStore.refreshAllFeeds().catch(() => {});
|
feedStore.refreshAllFeeds().catch(() => {});
|
||||||
},
|
},
|
||||||
@@ -195,19 +249,6 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
// Row highlight within the list. `active=true` only for the current pane.
|
|
||||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active
|
|
||||||
? theme.primary
|
|
||||||
: i === listFocus
|
|
||||||
? theme.border
|
|
||||||
: undefined;
|
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active
|
|
||||||
? theme.surface
|
|
||||||
: i === listFocus
|
|
||||||
? theme.selectedListItemText ?? theme.text
|
|
||||||
: theme.text;
|
|
||||||
|
|
||||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -219,7 +260,7 @@ function FeedPage() {
|
|||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1} alignItems="center">
|
||||||
<Show
|
<Show
|
||||||
when={feedStore.isLoadingFeeds()}
|
when={feedStore.isLoadingFeeds()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -228,114 +269,79 @@ function FeedPage() {
|
|||||||
</text>
|
</text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* Spacers keep the scrollbox content at the FULL list height so
|
||||||
{(item, index) => {
|
the scrollbar reflects the real list, not the render window. */}
|
||||||
const fi = () => focusedEpIdx();
|
<Show when={listWindow()[0] > 0}>
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||||
return (
|
</Show>
|
||||||
<box
|
<For each={visibleEpisodes()}>
|
||||||
ref={ref}
|
{(item, index) => (
|
||||||
flexDirection="column"
|
<EpisodeRow
|
||||||
gap={0}
|
episode={item.episode}
|
||||||
paddingLeft={1}
|
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||||
paddingRight={1}
|
index={() => listWindow()[0] + index()}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
focused={focusedEpIdx}
|
||||||
onMouseDown={() => {
|
active={isActive}
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
selected={() => nav.isSelected(item.episode.id)}
|
||||||
nav.setDepthFocus(index(), 0);
|
downloadLabel={() => downloadLabel(item.episode.id)}
|
||||||
}}
|
downloadColor={() => downloadColor(item.episode.id)}
|
||||||
>
|
marker={marker}
|
||||||
<box flexDirection="row" gap={1}>
|
onMouseDown={() => {
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
{index() === fi() ? "❯" : " "}
|
nav.setDepthFocus(listWindow()[0] + index(), 0);
|
||||||
</text>
|
}}
|
||||||
<text 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()}>
|
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
|
||||||
<text fg={downloadColor(item.episode.id)}>
|
|
||||||
{downloadLabel(item.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
|
</Show>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box
|
<FetchMoreRow
|
||||||
ref={moreRef}
|
index={() => episodes().length}
|
||||||
flexDirection="row"
|
focused={focusedRow}
|
||||||
gap={1}
|
onMore={focusedOnMore}
|
||||||
paddingLeft={1}
|
active={isActive}
|
||||||
paddingRight={1}
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(episodes().length, 0);
|
nav.setDepthFocus(episodes().length, 0);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
{focusedOnMore() ? "❯" : " "}
|
|
||||||
</text>
|
|
||||||
<Show
|
|
||||||
when={!feedStore.isLoadingMore()}
|
|
||||||
fallback={<LoadingIndicator label="Fetching…" />}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
[Fetch More]
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
|
const episodeHint = (item: EpItem) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
} · space: select · h back`;
|
||||||
|
|
||||||
const previewContent = () => (
|
const previewContent = () => (
|
||||||
<>
|
<>
|
||||||
<Show when={focusedOnMore()}>
|
<Show when={focusedOnMore()}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<FetchMorePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<strong>[Fetch More]</strong>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</text>
|
manualText={() =>
|
||||||
<text fg={muted()}>
|
"Load the next batch of older episodes across all feeds (Enter)."
|
||||||
{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 across all feeds (Enter)."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: load more · h back</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!focusedOnMore()}>
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -347,39 +353,16 @@ function FeedPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<EpisodePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
episode={() => item().episode}
|
||||||
<strong>
|
subtitle={() =>
|
||||||
{item().episode.episodeNumber
|
item().feed.customName || item().feed.podcast.title
|
||||||
? `#${item().episode.episodeNumber} `
|
}
|
||||||
: ""}
|
author={() => item().feed.podcast.author}
|
||||||
{item().episode.title}
|
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||||
</strong>
|
downloadColor={() => downloadColor(item().episode.id)}
|
||||||
</text>
|
hint={() => episodeHint(item())}
|
||||||
<box flexDirection="row" gap={2}>
|
/>
|
||||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
|
||||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
|
||||||
<Show when={downloadLabel(item().episode.id)}>
|
|
||||||
<text fg={downloadColor(item().episode.id)}>
|
|
||||||
{downloadLabel(item().episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{item().feed.customName || item().feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={item().feed.podcast.author}>
|
|
||||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{item().episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: play · space: select · h back</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,16 +6,20 @@
|
|||||||
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
* depth 1 (current) — episodes of the drilled show. Parent pane = shows.
|
||||||
* preview — detail of the hovered item in the current column.
|
* 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
|
* Renders entirely through `<PaneRow>`; no bespoke 3-column flexbox JSX
|
||||||
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
* remains. `l`/Enter drills in (show → episodes); `h` pops a depth (noop at
|
||||||
* 0). j/k move only within the current column.
|
* 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 type { RGBA } from "@opentui/core";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -27,24 +31,232 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
formatDate,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
|
// ── render components ────────────────────────────────────────────────────────
|
||||||
|
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||||
|
// preview panes are My Shows-specific; episode rows/previews are shared with
|
||||||
|
// the Feed page (see EpisodeList.tsx).
|
||||||
|
|
||||||
|
/** A subscribed-show row (depth 0). */
|
||||||
|
function ShowRow(props: {
|
||||||
|
feed: Feed;
|
||||||
|
title: string;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
wlScope: () => boolean;
|
||||||
|
wlInList: () => boolean;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
{/* Long titles truncate with middle-ellipsis instead of wrapping —
|
||||||
|
a wrapped title grows the row to 2+ lines and shifts every row
|
||||||
|
below (see EpisodeList for the same guard). The episode-count
|
||||||
|
and watchlist cells are flexShrink=0 so they never shrink or
|
||||||
|
wrap; the flexible title takes the remaining width. */}
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.title}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
({props.feed.episodes.length})
|
||||||
|
</text>
|
||||||
|
<Show when={props.wlScope()}>
|
||||||
|
<text
|
||||||
|
flexShrink={0}
|
||||||
|
fg={
|
||||||
|
isFocused()
|
||||||
|
? theme.surface
|
||||||
|
: props.wlInList()
|
||||||
|
? theme.warning
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.wlInList() ? "●" : "○"}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unsubscribed-show download row (depth 0, below the shows list). */
|
||||||
|
function UnsubscribedRow(props: {
|
||||||
|
d: DownloadedEpisode;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.d.episodeTitle ?? props.d.episodeId}
|
||||||
|
</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.d.podcastTitle ?? props.d.feedId}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered subscribed show. */
|
||||||
|
function ShowPreview(props: {
|
||||||
|
show: () => Feed;
|
||||||
|
title: () => string;
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const show = props.show;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{props.title()}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered unsubscribed-show download. */
|
||||||
|
function UnsubscribedPreview(props: {
|
||||||
|
d: () => DownloadedEpisode;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const d = props.d;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<Show when={d().pubDate}>
|
||||||
|
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Downloaded from episode search — the show is not subscribed.
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · D: delete download · h: back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
|
// Static: detection never changes mid-session.
|
||||||
|
const nerd = supportsNerdFonts();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const downloadStore = useDownloadStore();
|
const downloadStore = useDownloadStore();
|
||||||
|
const app = useAppStore();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
@@ -52,9 +264,27 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
|
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||||
|
// listed as their own section under the shows list. Reads feeds() so an
|
||||||
|
// entry drops out the moment the user subscribes to its show.
|
||||||
|
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||||
|
|
||||||
|
const depth0Count = () => shows().length + unsubs().length;
|
||||||
|
|
||||||
const focusedShowIdx = () =>
|
const focusedShowIdx = () =>
|
||||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||||
|
* row (past the shows list). */
|
||||||
|
const focusedOnUnsub = () =>
|
||||||
|
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||||
|
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||||
|
if (!focusedOnUnsub()) return undefined;
|
||||||
|
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||||
|
};
|
||||||
|
const selectedShow = (): Feed | undefined => {
|
||||||
|
if (focusedOnUnsub()) return undefined;
|
||||||
|
return shows()[focusedShowIdx()];
|
||||||
|
};
|
||||||
|
|
||||||
// depth-1 frame ctx = the drilled feed id
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||||
@@ -67,34 +297,87 @@ export function MyShowsPage() {
|
|||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(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 ?? "auto";
|
||||||
|
const showFetchMore = () =>
|
||||||
|
depth() >= 1 &&
|
||||||
|
!!drilledShowId() &&
|
||||||
|
feedStore.hasMoreEpisodes(drilledShowId());
|
||||||
|
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 = () =>
|
const focusedEpIdx = () =>
|
||||||
episodes().length === 0 ? 0 : Math.min(focus(1), episodes().length - 1);
|
focusedOnMore()
|
||||||
const focusedEpisode = () => episodes()[focusedEpIdx()];
|
? -1
|
||||||
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
|
const focusedEpisode = () =>
|
||||||
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? shows().length : episodes().length);
|
// ── Render window ────────────────────────────────────────────────────────
|
||||||
|
// The drilled show's list grows deep after repeated fetch-more presses;
|
||||||
|
// rendering every row per frame froze the UI. Render only a bounded slice
|
||||||
|
// around the focus (real indexes preserved) — the scrollbox still keeps
|
||||||
|
// the focused row in view. Spacers above/below the window restore the
|
||||||
|
// full content height so the scrollbar tracks the real list.
|
||||||
|
// Each episode row is 2 lines tall (title, date) — no subtitle here.
|
||||||
|
const LIST_WINDOW = 30;
|
||||||
|
const ROW_HEIGHT = 2;
|
||||||
|
const listWindow = createMemo<[number, number]>(() => {
|
||||||
|
const len = episodes().length;
|
||||||
|
// Focusing the Fetch More button keeps the window anchored at the
|
||||||
|
// last episode — no jump when the focus crosses onto the button.
|
||||||
|
const f = focusedOnMore() ? len - 1 : focusedEpIdx();
|
||||||
|
return [
|
||||||
|
Math.max(0, f - LIST_WINDOW),
|
||||||
|
Math.min(len, f + LIST_WINDOW + 1),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const visibleEpisodes = createMemo(() => {
|
||||||
|
const [start, end] = listWindow();
|
||||||
|
return episodes().slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (shows().length > 0 && focus(0) >= shows().length)
|
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||||
nav.setDepthFocus(shows().length - 1, 0);
|
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||||
if (depth() >= 1 && episodes().length > 0 && focus(1) >= episodes().length)
|
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||||
nav.setDepthFocus(episodes().length - 1, 1);
|
nav.setDepthFocus(rowCount() - 1, 1);
|
||||||
};
|
};
|
||||||
onMount(ensureFocus);
|
onMount(ensureFocus);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return shows()[i]?.id;
|
if (depth() === 0) {
|
||||||
|
if (i < shows().length) return shows()[i]?.id;
|
||||||
|
return unsubs()[i - shows().length]?.episodeId;
|
||||||
|
}
|
||||||
return episodes()[i]?.id;
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -128,9 +411,31 @@ export function MyShowsPage() {
|
|||||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stream an unsubscribed-show download. The record carries only what was
|
||||||
|
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||||
|
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||||
|
audio
|
||||||
|
.play({
|
||||||
|
id: d.episodeId,
|
||||||
|
podcastId: d.feedId,
|
||||||
|
title: d.episodeTitle ?? d.episodeId,
|
||||||
|
description: "",
|
||||||
|
audioUrl: d.audioUrl ?? "",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
playUnsubscribedDownload(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
@@ -139,6 +444,10 @@ export function MyShowsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (depth() >= 1) {
|
if (depth() >= 1) {
|
||||||
|
if (focusedOnMore()) {
|
||||||
|
feedStore.loadMoreEpisodes(drilledShowId()).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ep = focusedEpisode();
|
const ep = focusedEpisode();
|
||||||
if (ep) playEpisode(ep);
|
if (ep) playEpisode(ep);
|
||||||
}
|
}
|
||||||
@@ -161,6 +470,41 @@ export function MyShowsPage() {
|
|||||||
if (ep) nav.toggleSelected(ep.id);
|
if (ep) nav.toggleSelected(ep.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => {
|
||||||
|
if (depth() < 1) return;
|
||||||
|
const ep = focusedEpisode();
|
||||||
|
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||||
|
},
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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: () => {
|
refresh: () => {
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||||
@@ -196,19 +540,13 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active
|
|
||||||
? theme.surface
|
|
||||||
: i === lf
|
|
||||||
? theme.selectedListItemText ?? theme.text
|
|
||||||
: theme.text;
|
|
||||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Shows (${shows().length})`
|
? `Shows (${shows().length})${
|
||||||
|
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||||
|
}`
|
||||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
@@ -220,20 +558,30 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const focused = () => index() === lf();
|
||||||
|
const fg = () =>
|
||||||
|
focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
backgroundColor={focused() ? theme.border : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), false)}>
|
<text flexShrink={0} fg={fg()}>
|
||||||
{index() === lf() ? "❯" : " "}
|
{focused() ? marker() : " "}
|
||||||
|
</text>
|
||||||
|
{/* 20%-wide parent pane truncates hard — same
|
||||||
|
middle-ellipsis guard as the depth-0 rows. */}
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{showTitle(feed)}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={muted()}>
|
||||||
|
({feed.episodes.length})
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -247,7 +595,7 @@ export function MyShowsPage() {
|
|||||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={depth0Count() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
@@ -257,35 +605,53 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => (
|
||||||
const lf = () => focusedShowIdx();
|
<ShowRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
feed={feed}
|
||||||
return (
|
title={showTitle(feed)}
|
||||||
<box
|
index={index}
|
||||||
ref={ref}
|
focused={focusedShowIdx}
|
||||||
flexDirection="row"
|
active={isActive}
|
||||||
gap={1}
|
marker={marker}
|
||||||
paddingLeft={1}
|
wlScope={() =>
|
||||||
paddingRight={1}
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
}
|
||||||
|
wlInList={() =>
|
||||||
|
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
feed.id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={unsubs().length > 0}>
|
||||||
|
<box paddingLeft={1} paddingTop={1}>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
Unsubscribed Show Downloads
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<For each={unsubs()}>
|
||||||
|
{(d, index) => (
|
||||||
|
<UnsubscribedRow
|
||||||
|
d={d}
|
||||||
|
index={() => shows().length + index()}
|
||||||
|
focused={() => nav.depthFocus(0)}
|
||||||
|
active={isActive}
|
||||||
|
marker={marker}
|
||||||
|
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||||
|
downloadColor={() => downloadColor(d.episodeId)}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(shows().length + index(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
)}
|
||||||
{index() === lf() ? "❯" : " "}
|
</For>
|
||||||
</text>
|
</Show>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
|
||||||
{showTitle(feed)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
||||||
({feed.episodes.length})
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: episodes */}
|
{/* depth ≥1: episodes */}
|
||||||
@@ -298,56 +664,47 @@ export function MyShowsPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
{/* Spacers keep the scrollbox content at the FULL list
|
||||||
{(ep, index) => {
|
height so the scrollbar reflects the real list, not the
|
||||||
const lf = () => focusedEpIdx();
|
render window. */}
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
<Show when={listWindow()[0] > 0}>
|
||||||
return (
|
<box height={listWindow()[0] * ROW_HEIGHT} />
|
||||||
<box
|
</Show>
|
||||||
ref={ref}
|
<For each={visibleEpisodes()}>
|
||||||
flexDirection="column"
|
{(ep, index) => (
|
||||||
gap={0}
|
<EpisodeRow
|
||||||
paddingLeft={1}
|
episode={ep}
|
||||||
paddingRight={1}
|
index={() => listWindow()[0] + index()}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
focused={focusedEpIdx}
|
||||||
onMouseDown={() => {
|
active={isActive}
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
selected={() => nav.isSelected(ep.id)}
|
||||||
nav.setDepthFocus(index(), 1);
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
}}
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
>
|
marker={marker}
|
||||||
<box flexDirection="row" gap={1}>
|
onMouseDown={() => {
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
{index() === lf() ? "❯" : " "}
|
nav.setDepthFocus(listWindow()[0] + index(), 1);
|
||||||
</text>
|
}}
|
||||||
<text 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}>
|
|
||||||
{formatDate(ep.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
||||||
{formatDuration(ep.duration)}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
|
||||||
<text fg={theme.warning}>●</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(ep.id)}>
|
|
||||||
<text fg={downloadColor(ep.id)}>
|
|
||||||
{downloadLabel(ep.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={feedStore.isLoadingMore()}>
|
<Show when={episodes().length - listWindow()[1] > 0}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box height={(episodes().length - listWindow()[1]) * ROW_HEIGHT} />
|
||||||
<LoadingIndicator label="Loading more…" />
|
</Show>
|
||||||
</box>
|
<Show when={showFetchMore()}>
|
||||||
|
<FetchMoreRow
|
||||||
|
index={() => episodes().length}
|
||||||
|
focused={focusedRow}
|
||||||
|
onMore={focusedOnMore}
|
||||||
|
active={isActive}
|
||||||
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(episodes().length, 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -355,76 +712,96 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const episodeHint = (epId: string) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
drilledShowId(),
|
||||||
|
)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
} · space: select · h: back`;
|
||||||
|
|
||||||
|
const showHint = (show: Feed) =>
|
||||||
|
`enter/l: open · h: back · x: unsubscribe${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered show
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
|
// hovered show.
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={focusedUnsub()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<Show
|
||||||
<text fg={muted()}>No show focused</text>
|
when={selectedShow()}
|
||||||
</box>
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(show) => (
|
||||||
|
<ShowPreview
|
||||||
|
show={() => show()}
|
||||||
|
title={() => showTitle(show())}
|
||||||
|
hint={() => showHint(show())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(show) => (
|
{(d) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<UnsubscribedPreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
d={() => d()}
|
||||||
<strong>{showTitle(show())}</strong>
|
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||||
</text>
|
downloadColor={() => downloadColor(d().episodeId)}
|
||||||
<Show when={show().podcast.author}>
|
/>
|
||||||
<text fg={muted()}>by {show().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{show().episodes.length} episodes
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : (
|
||||||
// depth ≥1 preview: hovered episode
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<Show
|
<>
|
||||||
when={focusedEpisode()}
|
<Show when={focusedOnMore()}>
|
||||||
fallback={
|
<FetchMorePreview
|
||||||
<box padding={1}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<text fg={muted()}>No episode focused</text>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</box>
|
manualText={() =>
|
||||||
}
|
"Load the next batch of older episodes for this show (Enter)."
|
||||||
>
|
}
|
||||||
{(ep) => (
|
/>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
</Show>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<Show when={!focusedOnMore()}>
|
||||||
<strong>
|
<Show
|
||||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
when={focusedEpisode()}
|
||||||
{ep().title}
|
fallback={
|
||||||
</strong>
|
<box padding={1}>
|
||||||
</text>
|
<text fg={muted()}>No episode focused</text>
|
||||||
<box flexDirection="row" gap={2}>
|
</box>
|
||||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
}
|
||||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
>
|
||||||
<Show when={downloadLabel(ep().id)}>
|
{(ep) => (
|
||||||
<text fg={downloadColor(ep().id)}>
|
<EpisodePreview
|
||||||
{downloadLabel(ep().id)}
|
episode={() => ep()}
|
||||||
</text>
|
author={() => selectedShow()?.podcast.author}
|
||||||
</Show>
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
</box>
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
<Show when={selectedShow()?.podcast.author}>
|
hint={() => episodeHint(ep().id)}
|
||||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
/>
|
||||||
</Show>
|
)}
|
||||||
<box height={1} />
|
</Show>
|
||||||
<text fg={theme.textSecondary}>
|
</Show>
|
||||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
</>
|
||||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: play · space: select · h: back</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,70 +14,81 @@ type PlaybackControlsProps = {
|
|||||||
onSpeedChange: (value: number) => void;
|
onSpeedChange: (value: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
|
||||||
mpv: "mpv",
|
|
||||||
none: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
|
flexWrap="wrap"
|
||||||
gap={1}
|
gap={1}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
border
|
border
|
||||||
padding={1}
|
padding={1}
|
||||||
borderColor={theme.border}
|
borderColor={theme.border}
|
||||||
>
|
>
|
||||||
<box
|
{/* transport buttons — wrap as a unit, centered on their own line */}
|
||||||
border
|
<box flexDirection="row" gap={1} alignItems="center" flexShrink={0}>
|
||||||
padding={0}
|
<box
|
||||||
onMouseDown={props.onPrev}
|
border
|
||||||
borderColor={theme.border}
|
padding={0}
|
||||||
>
|
onMouseDown={props.onPrev}
|
||||||
<text fg={theme.primary}>[Prev]</text>
|
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>
|
</box>
|
||||||
|
{/* status group — always follows the buttons; wrap point is here */}
|
||||||
<box
|
<box
|
||||||
border
|
flexDirection="row"
|
||||||
padding={0}
|
gap={1}
|
||||||
onMouseDown={props.onToggle}
|
alignItems="center"
|
||||||
borderColor={theme.border}
|
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.textMuted}>Vol</text>
|
||||||
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
|
||||||
<text fg={theme.textMuted}>↑↓</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}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>via</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
|
<text fg={theme.textMuted}>S</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
</box>
|
||||||
{props.backendName === "none" && (
|
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||||
<box marginLeft={2}>
|
{(props.backendName === "none" || props.hasAudioUrl === false) && (
|
||||||
<text fg={theme.warning}>No audio player found</text>
|
<box
|
||||||
</box>
|
flexDirection="row"
|
||||||
)}
|
gap={1}
|
||||||
{props.hasAudioUrl === false && (
|
alignItems="center"
|
||||||
<box marginLeft={2}>
|
flexShrink={0}
|
||||||
<text fg={theme.warning}>No audio URL</text>
|
>
|
||||||
|
{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>
|
||||||
)}
|
)}
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
* tab root.
|
* tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show, onMount, onCleanup } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
@@ -26,7 +28,19 @@ export function PlayerPage() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const viz = useVisualizer();
|
||||||
|
const app = useAppStore();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
|
// Settings master switch: off hides the waveform entirely (the store
|
||||||
|
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||||
|
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||||
|
|
||||||
|
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||||
|
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||||
|
// visualizer store: losing focus starts the unload grace timer instead
|
||||||
|
// of killing the pipeline with the page; regaining focus restarts it.
|
||||||
|
onMount(() => viz.setFocused(true));
|
||||||
|
onCleanup(() => viz.setFocused(false));
|
||||||
|
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
@@ -79,18 +93,11 @@ export function PlayerPage() {
|
|||||||
{ep().description?.slice(0, 500) ?? "No description available."}
|
{ep().description?.slice(0, 500) ?? "No description available."}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
<RealtimeWaveform
|
<ProgressBar />
|
||||||
visualizerConfig={(() => {
|
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
<Show when={vizEnabled()}>
|
||||||
// bars is width-derived in RealtimeWaveform; pass only the
|
<RealtimeWaveform />
|
||||||
// audio-processing params here.
|
</Show>
|
||||||
return {
|
|
||||||
noiseReduction: viz.noiseReduction,
|
|
||||||
lowCutOff: viz.lowCutOff,
|
|
||||||
highCutOff: viz.highCutOff,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -109,9 +116,13 @@ export function PlayerPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
{/* content prop (not a text child): the babel-preset-solid JSX
|
||||||
{"P play/pause N next B prev ◀▶ seek h back"}
|
* transform HTML-escapes static string children (`<` → `<`),
|
||||||
</text>
|
* which opentui renders verbatim; content bypasses that. */}
|
||||||
|
<text
|
||||||
|
fg={muted()}
|
||||||
|
content={"P play/pause N next B prev < > seek h back"}
|
||||||
|
/>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -122,6 +133,7 @@ export function PlayerPage() {
|
|||||||
currentLabel="Player"
|
currentLabel="Player"
|
||||||
panes={2}
|
panes={2}
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
|
currentBorder={["left"]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/pages/Player/ProgressBar.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* 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}
|
||||||
|
// The bar's block-char texts are non-selectable below: a drag
|
||||||
|
// over the bar is a seek gesture, not a text selection — otherwise
|
||||||
|
// mouse-up would copy █/░ to the clipboard via the global
|
||||||
|
// selection handler.
|
||||||
|
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} selectable={false}>
|
||||||
|
{"\u2588".repeat(playedChars())}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
<text fg={remainingColor} selectable={false}>
|
||||||
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,62 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||||
*
|
*
|
||||||
* Spawns an independent ffmpeg
|
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||||
* for FFT analysis, and renders frequency bars as colored terminal
|
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||||
* characters at ~30fps.
|
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||||
|
*
|
||||||
|
* This component only subscribes to store state, reports the width-derived
|
||||||
|
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||||
|
* a braille spinner while the pipeline is loading its first frames, the
|
||||||
|
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createEffect, on } from "solid-js";
|
||||||
import { useTerminalDimensions } from "@opentui/solid";
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
loadCavaCore,
|
|
||||||
type CavaCore,
|
|
||||||
type CavaCoreConfig,
|
|
||||||
} from "@/utils/cavacore";
|
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
|
||||||
const SAMPLES_PER_FRAME = 512;
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const viz = useVisualizer();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
|
||||||
let reader: AudioStreamReader | null = null;
|
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let sampleBuffer: Float64Array | null = null;
|
|
||||||
|
|
||||||
// Bar count scales with terminal width so the waveform fills its pane.
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
// The player is a 2-pane row: current column = (current+preview) of
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
@@ -75,197 +43,49 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// Keep the store's bar count in sync with the terminal width; the store
|
||||||
|
// re-inits the running pipeline when it changes (terminal resize).
|
||||||
const initCava = () => {
|
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||||
if (cava) return true;
|
|
||||||
|
|
||||||
cava = loadCavaCore();
|
|
||||||
if (!cava) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
|
||||||
stopVisualization();
|
|
||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides.
|
|
||||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
|
||||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
|
||||||
const config: CavaCoreConfig = {
|
|
||||||
bars: numBars(),
|
|
||||||
sampleRate: 44100,
|
|
||||||
channels: 1,
|
|
||||||
...props.visualizerConfig,
|
|
||||||
};
|
|
||||||
cava.init(config);
|
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
|
||||||
if (!reader || reader.url !== url) {
|
|
||||||
if (reader) reader.stop();
|
|
||||||
reader = new AudioStreamReader({ url });
|
|
||||||
}
|
|
||||||
reader.start(position, speed);
|
|
||||||
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopVisualization = () => {
|
|
||||||
if (frameTimer) {
|
|
||||||
clearInterval(frameTimer);
|
|
||||||
frameTimer = null;
|
|
||||||
}
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
|
||||||
}
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
sampleBuffer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
|
||||||
|
|
||||||
const renderFrame = () => {
|
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
|
||||||
|
|
||||||
const count = reader.read(sampleBuffer);
|
|
||||||
if (count === 0) return;
|
|
||||||
|
|
||||||
const input =
|
|
||||||
count < sampleBuffer.length
|
|
||||||
? sampleBuffer.subarray(0, count)
|
|
||||||
: sampleBuffer;
|
|
||||||
const output = cava.execute(input);
|
|
||||||
|
|
||||||
// Copy bar values to a new array for the signal
|
|
||||||
setBarData(Array.from(output as Float64Array));
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
[
|
|
||||||
audio.isPlaying,
|
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "",
|
|
||||||
audio.speed,
|
|
||||||
numBars,
|
|
||||||
],
|
|
||||||
([playing, url, speed]) => {
|
|
||||||
if (playing && url) {
|
|
||||||
const pos = untrack(audio.position);
|
|
||||||
startVisualization(url, pos, speed);
|
|
||||||
} else {
|
|
||||||
stopVisualization();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
|
||||||
//
|
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
|
||||||
createEffect(
|
|
||||||
on(audio.position, (pos) => {
|
|
||||||
if (!audio.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
|
|
||||||
if (delta > 2) {
|
|
||||||
reader.restart(pos, audio.speed() ?? 1);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stopVisualization();
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const playedRatio = () =>
|
|
||||||
audio.duration() <= 0
|
|
||||||
? 0
|
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = viz.barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
|
|
||||||
|
// Loading state: the braille spinner shows while the pipeline warms
|
||||||
|
// up — but only when there are no bars to render yet (first play /
|
||||||
|
// after an unload). On resume/seek the last bars stay on screen
|
||||||
|
// until fresh frames arrive, so the waveform never blanks out for
|
||||||
|
// the (multi-second, network-bound) cold start.
|
||||||
|
if (bars.length === 0 && viz.isLoading()) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg="#3b4252">{placeholder}</text>
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
|
<text fg={theme.primary}>{placeholder}</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const played = Math.floor(count * playedRatio());
|
const pairs = bars.map((v) => barChars(Math.floor(v * BAR_LEVELS)));
|
||||||
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
|
const top = pairs.map((pair) => pair.top).join("");
|
||||||
const futureColor = "#3b4252";
|
const bottom = pairs.map((pair) => pair.bottom).join("");
|
||||||
|
|
||||||
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("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={0}>
|
<box flexDirection="column" gap={0}>
|
||||||
<text fg={playedColor}>{playedChars || " "}</text>
|
<text fg={theme.primary}>{top}</text>
|
||||||
<text fg={futureColor}>{futureChars || " "}</text>
|
<text fg={theme.primary}>{bottom}</text>
|
||||||
</box>
|
</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 (
|
return (
|
||||||
<box
|
<box border borderColor={theme.border} padding={1}>
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={handleClick}
|
|
||||||
>
|
|
||||||
{renderLine()}
|
{renderLine()}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
* query (muted, read-only); preview shows the detail of
|
* query (muted, read-only); preview shows the detail of
|
||||||
* the focused result.
|
* 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
|
* 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
|
* router yields). Escape defocuses the input (handled in Shell) so j/k/h
|
||||||
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
* navigation resumes; `s` (the `search` action) refocuses it. Enter on the
|
||||||
@@ -26,6 +30,11 @@ import {
|
|||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import { useToast } from "@/ui/toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import {
|
import {
|
||||||
@@ -37,21 +46,28 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
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 { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
export const SearchPaneCount = 1;
|
export const SearchPaneCount = 1;
|
||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
|
const toast = useToast();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const marker = useSelectionMarker();
|
||||||
|
|
||||||
const stack = nav.depthStack;
|
const stack = nav.depthStack;
|
||||||
const depth = nav.currentDepth;
|
const depth = nav.currentDepth;
|
||||||
@@ -61,25 +77,21 @@ function SearchPage() {
|
|||||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||||
|
|
||||||
// ── input focusing ────────────────────────────────────────────────────────
|
// ── input focusing ────────────────────────────────────────────────────────
|
||||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
// `inputFocused` tells the Shell router to yield keys to the query input.
|
||||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
|
||||||
//
|
// input drops it and the router resumes j/k/h — no stuck "typing" state.
|
||||||
// Typing is the default only on the query depth (0); the results depth
|
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||||
// (1) is always list-navigation. Drive `inputFocused` straight off
|
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||||
// `depth()` rather than seeding it `true` on mount and patching on change:
|
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||||
// the depth stack persists across tab switches, so re-mounting this page
|
// signal — without the memo every j/k at query depth re-focuses the input
|
||||||
// at depth 1 (e.g. after searching, leaving, and returning to the tab)
|
// and strands the recents list.
|
||||||
// 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 effect only re-runs on a depth transition, so Escape (defocus) and
|
|
||||||
// `s` (refocus) at the same depth are not clobbered.
|
|
||||||
onMount(() => nav.setInputFocused(depth() === 0));
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
onCleanup(() => nav.setInputFocused(false));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
|
const focusNavRef = useInputFocusNav();
|
||||||
|
const isQueryDepth = createMemo(() => depth() === 0);
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
nav.setInputFocused(depth() === 0);
|
nav.setInputFocused(isQueryDepth());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── results (depth 1) ─────────────────────────────────────────────────────
|
// ── results (depth 1) ─────────────────────────────────────────────────────
|
||||||
@@ -105,12 +117,44 @@ function SearchPage() {
|
|||||||
// Register a visual-mode resolver for the results list (depth 1).
|
// Register a visual-mode resolver for the results list (depth 1).
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const key = `${nav.activeTab()}:${DEPTH_CENTER_PANE}`;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runSearch = (query: string) => {
|
const runSearch = (query: string) => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -130,10 +174,89 @@ function SearchPage() {
|
|||||||
runSearch(query);
|
runSearch(query);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubscribe = (result: SearchResult) => {
|
/** Set show/episode scope; when viewing results, re-run the current query
|
||||||
// Actually add the feed to the feed store, then mark the result subscribed
|
* so the list switches immediately (the toggle is otherwise invisible on
|
||||||
feedStore.addFeed(result.podcast, result.sourceId).catch(() => {});
|
* a list of results). */
|
||||||
searchStore.markSubscribed(result.podcast.id);
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The subscribed feed backing a search result, if any (matched by
|
||||||
|
* directory id or feed URL). */
|
||||||
|
const feedForResult = (r: SearchResult) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === r.podcast.id ||
|
||||||
|
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Download the focused episode: under its subscribed feed when the show
|
||||||
|
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||||
|
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||||
|
const downloadFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
audio.play(r.episode).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || !r.podcast.isSubscribed) return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) {
|
||||||
|
feedStore.removeFeed(feed.id);
|
||||||
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||||
|
.catch(() => {});
|
||||||
|
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribe the focused result's show in place (episode or podcast
|
||||||
|
* result). `enter` plays episodes regardless of subscription, so an
|
||||||
|
* unsubscribed show's episode needs this explicit path. */
|
||||||
|
const subscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.podcast.isSubscribed) return;
|
||||||
|
handleSubscribe(r);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
@@ -150,13 +273,29 @@ function SearchPage() {
|
|||||||
"toggle-select": () => {
|
"toggle-select": () => {
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (r) nav.toggleSelected(r.podcast.id);
|
if (r)
|
||||||
|
nav.toggleSelected(
|
||||||
|
r.kind === "episode" ? r.episode.id : r.podcast.id,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => downloadFocusedEpisode(),
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const id = r.episode.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
unsubscribe: () => unsubscribeFocused(),
|
||||||
|
subscribe: () => subscribeFocused(),
|
||||||
search: () => {
|
search: () => {
|
||||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
if (depth() === 0) nav.setInputFocused(true);
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
},
|
},
|
||||||
|
"search-scope-toggle": () => toggleScope(),
|
||||||
refresh: () => {
|
refresh: () => {
|
||||||
const q = submittedQuery() || inputValue().trim();
|
const q = submittedQuery() || inputValue().trim();
|
||||||
if (q) searchStore.search(q).catch(() => {});
|
if (q) searchStore.search(q).catch(() => {});
|
||||||
@@ -177,7 +316,15 @@ function SearchPage() {
|
|||||||
}
|
}
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (r) handleSubscribe(r);
|
if (!r) return;
|
||||||
|
if (r.kind === "episode") {
|
||||||
|
// Any episode result streams directly — subscribed or not
|
||||||
|
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||||
|
// show's episode in place.
|
||||||
|
playFocusedEpisode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSubscribe(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,15 +360,28 @@ function SearchPage() {
|
|||||||
: theme.text;
|
: theme.text;
|
||||||
|
|
||||||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
// ── 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 = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<Show when={depth() === 0}>
|
||||||
<text fg={theme.textSecondary}>Query</text>
|
<TabListPane muted />
|
||||||
<text fg={muted()}>{submittedQuery() || "(empty)"}</text>
|
</Show>
|
||||||
<box height={1} />
|
<Show when={depth() >= 1}>
|
||||||
<text fg={muted()}>h: back to query</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
</box>
|
<text fg={theme.textSecondary}>Query</text>
|
||||||
</Show>
|
<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 ────────────────────────────────────────────────────────────
|
// ── current pane ────────────────────────────────────────────────────────────
|
||||||
@@ -233,14 +393,78 @@ function SearchPage() {
|
|||||||
<box flexDirection="row" gap={1} alignItems="center">
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
<text fg={muted()}>Query:</text>
|
<text fg={muted()}>Query:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={focusNavRef}
|
||||||
value={inputValue()}
|
value={inputValue()}
|
||||||
onInput={setInputValue}
|
onInput={setInputValue}
|
||||||
onSubmit={() => handleSubmit()}
|
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()}
|
focused={inputActive()}
|
||||||
width={28}
|
width={28}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</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()}>
|
<Show when={searchStore.isSearching()}>
|
||||||
<LoadingIndicator label="Searching…" />
|
<LoadingIndicator label="Searching…" />
|
||||||
</Show>
|
</Show>
|
||||||
@@ -263,23 +487,47 @@ function SearchPage() {
|
|||||||
{(query, index) => {
|
{(query, index) => {
|
||||||
const lf = () => focus(0);
|
const lf = () => focus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
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 (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
backgroundColor={
|
||||||
|
typing()
|
||||||
|
? undefined
|
||||||
|
: focusBg(index(), lf(), isActive())
|
||||||
|
}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
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())}>
|
<text
|
||||||
{index() === lf() ? "❯" : " "}
|
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>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{query}</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -289,7 +537,7 @@ function SearchPage() {
|
|||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{inputActive()
|
{inputActive()
|
||||||
? "Enter to search · Esc to defocus"
|
? "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>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -305,7 +553,9 @@ function SearchPage() {
|
|||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
{searchStore.query()
|
{searchStore.query()
|
||||||
? "No results found"
|
? "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>
|
</text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -318,12 +568,18 @@ function SearchPage() {
|
|||||||
{(result, index) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
|
// Episode download status badge ("" when absent).
|
||||||
|
const dlLabel = () =>
|
||||||
|
result.kind === "episode"
|
||||||
|
? downloadLabel(result.episode.id)
|
||||||
|
: "";
|
||||||
|
const dlEpId = () =>
|
||||||
|
result.kind === "episode" ? result.episode.id : "";
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
backgroundColor={focusBg(index(), fi(), isActive())}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -333,11 +589,18 @@ function SearchPage() {
|
|||||||
>
|
>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
{index() === fi() ? "❯" : " "}
|
{index() === fi() ? marker() : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), fi(), isActive())}>
|
<text fg={focusFg(index(), fi(), isActive())}>
|
||||||
{result.podcast.title}
|
{result.kind === "episode"
|
||||||
|
? result.episode.title
|
||||||
|
: result.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={dlLabel()}>
|
||||||
|
<text fg={downloadColor(dlEpId())}>
|
||||||
|
{dlLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<Show when={result.podcast.isSubscribed}>
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : theme.success}
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
@@ -346,14 +609,24 @@ function SearchPage() {
|
|||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
<Show when={result.podcast.author}>
|
{result.kind === "episode" ? (
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : muted()}
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
paddingLeft={2}
|
paddingLeft={2}
|
||||||
>
|
>
|
||||||
by {result.podcast.author}
|
{result.podcast.title} ·{" "}
|
||||||
|
{formatDate(result.episode.pubDate)}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
) : (
|
||||||
|
<Show when={result.podcast.author}>
|
||||||
|
<text
|
||||||
|
fg={index() === fi() ? theme.surface : muted()}
|
||||||
|
paddingLeft={2}
|
||||||
|
>
|
||||||
|
by {result.podcast.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -371,6 +644,10 @@ function SearchPage() {
|
|||||||
<strong>Search</strong>
|
<strong>Search</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={muted()}>Type a query, press Enter to search.</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>
|
<text fg={muted()}>Esc defocuses the input; h goes back.</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
<text fg={theme.textSecondary}>Recent · {recents().length}</text>
|
||||||
@@ -387,52 +664,131 @@ function SearchPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(result) => (
|
{(result) => {
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
const r = result();
|
||||||
<text fg={theme.text}>
|
if (r.kind === "episode") {
|
||||||
<strong>{result().podcast.title}</strong>
|
return (
|
||||||
</text>
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
<Show when={result().podcast.author}>
|
<text fg={theme.text}>
|
||||||
<text fg={muted()}>by {result().podcast.author}</text>
|
<strong>{r.episode.title}</strong>
|
||||||
</Show>
|
</text>
|
||||||
<Show when={result().podcast.description}>
|
<text fg={theme.textSecondary}>{r.podcast.title}</text>
|
||||||
<text fg={theme.textSecondary}>
|
<Show when={r.podcast.author}>
|
||||||
{result().podcast.description!.slice(0, 400)}
|
<text fg={muted()}>by {r.podcast.author}</text>
|
||||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
</Show>
|
||||||
</text>
|
<Show when={r.episode.description}>
|
||||||
</Show>
|
<text fg={theme.textSecondary}>
|
||||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
{r.episode.description!.slice(0, 400)}
|
||||||
<box flexDirection="row" gap={1}>
|
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
<For each={(result().podcast.categories ?? []).slice(0, 4)}>
|
</text>
|
||||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
</Show>
|
||||||
</For>
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Published: {formatDate(r.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<Show when={downloadLabel(r.episode.id)}>
|
||||||
|
<text fg={downloadColor(r.episode.id)}>
|
||||||
|
{downloadLabel(r.episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<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 (a)</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={r.podcast.isSubscribed}>
|
||||||
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<Show
|
||||||
|
when={r.podcast.isSubscribed}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · a: subscribe · d: download · h: back to query
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · d: download · x: unsubscribe
|
||||||
|
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
);
|
||||||
<text fg={muted()}>Feed: {result().podcast.feedUrl}</text>
|
}
|
||||||
<text fg={muted()}>
|
return (
|
||||||
Updated: {formatDate(result().podcast.lastUpdated)}
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
</text>
|
<text fg={theme.text}>
|
||||||
<Show when={result().sourceName}>
|
<strong>{r.podcast.title}</strong>
|
||||||
<text fg={muted()}>Source: {result().sourceName}</text>
|
</text>
|
||||||
</Show>
|
<Show when={r.podcast.author}>
|
||||||
<box height={1} />
|
<text fg={muted()}>by {r.podcast.author}</text>
|
||||||
<Show when={!result().podcast.isSubscribed}>
|
</Show>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<Show when={r.podcast.description}>
|
||||||
</Show>
|
<text fg={theme.textSecondary}>
|
||||||
<Show when={result().podcast.isSubscribed}>
|
{r.podcast.description!.slice(0, 400)}
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
{(r.podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</Show>
|
</text>
|
||||||
<box height={1} />
|
</Show>
|
||||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||||
</box>
|
<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}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: subscribe
|
||||||
|
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Search · ${recents().length} recent`
|
? `Search · ${recents().length} recent`
|
||||||
: `Results · ${results().length}`;
|
: `Results (${searchStore.scope() === "episode" ? "episodes" : "shows"}) · ${results().length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneRow
|
<PaneRow
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||||
*
|
*
|
||||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||||
* • one item per show — action item; Enter deletes all that show's
|
* • one item per subscribed show — action item; Enter deletes all that
|
||||||
* downloads (file + metadata, aborts in-flight).
|
* show's downloads (file + metadata, aborts
|
||||||
* • one item per episode — action item; Enter deletes a single download.
|
* in-flight).
|
||||||
|
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||||
|
* shows that aren't subscribed, grouped
|
||||||
|
* under their own header.
|
||||||
|
* • one item per episode — action item; Enter deletes a single download.
|
||||||
*
|
*
|
||||||
* Titles resolve from the feed store at render time (reactive), falling back
|
* Titles resolve from the feed store at render time (reactive), falling back
|
||||||
* to the episode id when the feed is no longer loaded. Movement flows through
|
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||||
* nav.action — no own useKeyboard (matches the other panels).
|
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||||
|
* panels).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
/** Episode title for a download, resolved from the feed store (reactive);
|
||||||
|
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||||
function episodeTitle(
|
function episodeTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
d: DownloadedEpisode,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(d.feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||||
return ep?.title ?? d.episodeId;
|
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show title for a download's feed id. */
|
/** Show title for a download's feed id; falls back to the persisted show
|
||||||
|
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||||
function feedTitle(
|
function feedTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
feedId: string,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
if (feed) return feed.customName || feed.podcast.title;
|
||||||
|
return d.podcastTitle ?? d.feedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadItems(): SettingItem[] {
|
export function useDownloadItems(): SettingItem[] {
|
||||||
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group downloads by feed so each show gets a delete-by-show item.
|
// Group downloads by feed so each subscribed show gets a delete-by-show
|
||||||
|
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||||
|
// ids) are kept out of these groups and listed under their own section
|
||||||
|
// below.
|
||||||
|
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||||
|
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
const arr = byFeed.get(d.feedId) ?? [];
|
const arr = byFeed.get(d.feedId) ?? [];
|
||||||
arr.push(d);
|
arr.push(d);
|
||||||
byFeed.set(d.feedId, arr);
|
byFeed.set(d.feedId, arr);
|
||||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||||
items.push({
|
items.push({
|
||||||
id: `feed:${feedId}`,
|
id: `feed:${feedId}`,
|
||||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// One item per individual episode download.
|
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||||
|
if (unsubscribed.length > 0) {
|
||||||
|
items.push({
|
||||||
|
id: "unsubscribed-header",
|
||||||
|
label: "Unsubscribed Show Downloads",
|
||||||
|
kind: "info",
|
||||||
|
display: () => `${unsubscribed.length} files`,
|
||||||
|
help: () =>
|
||||||
|
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const d of unsubscribed) {
|
||||||
|
items.push({
|
||||||
|
id: `unsub:${d.episodeId}`,
|
||||||
|
label: episodeTitle(feedStore, d),
|
||||||
|
kind: "action",
|
||||||
|
display: () =>
|
||||||
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
|
help: () =>
|
||||||
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One item per individual (subscribed-show) episode download.
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: `ep:${d.episodeId}`,
|
id: `ep:${d.episodeId}`,
|
||||||
label: episodeTitle(feedStore, d),
|
label: episodeTitle(feedStore, d),
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () =>
|
display: () =>
|
||||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete this single download (file + metadata). Enter to run.`,
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export function ExportDialog() {
|
|||||||
value={filename[0]()}
|
value={filename[0]()}
|
||||||
onInput={filename[1]}
|
onInput={filename[1]}
|
||||||
style={{ width: 30 }}
|
style={{ width: 30 }}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export function FilePicker(props: FilePickerProps) {
|
|||||||
onInput={props.onChange}
|
onInput={props.onChange}
|
||||||
placeholder="/path/to/sync-file.json"
|
placeholder="/path/to/sync-file.json"
|
||||||
style={{ width: 40 }}
|
style={{ width: 40 }}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
<text fg={theme.text}>Format: {format}</text>
|
<text fg={theme.text}>Format: {format}</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -2,10 +2,38 @@
|
|||||||
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
* PreferencesPanel — exposes theme/font/speed/explicit/auto-download as
|
||||||
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
* SettingItems for the yazi depth-stack. No own useKeyboard; all movement is
|
||||||
* driven by the Shell router via nav.action.
|
* driven by the Shell router via nav.action.
|
||||||
|
*
|
||||||
|
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||||
|
* • 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).
|
||||||
|
* • Episode Cache Mode — date or count bound for the episode list
|
||||||
|
* (default: date)
|
||||||
|
* • Episode Cache Count — N most recent episodes when mode is count
|
||||||
|
* (default: 25)
|
||||||
|
* • Episode Cache Days — rolling N-day window when mode is date
|
||||||
|
* (default: 60)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||||
|
import { RenderableEvents, type InputRenderable } from "@opentui/core";
|
||||||
import { useAppStore } from "@/stores/app";
|
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, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||||
|
import type { Feed } from "@/types/feed";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||||
@@ -17,13 +45,32 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
|||||||
{ value: "custom", label: "Custom" },
|
{ value: "custom", label: "Custom" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
||||||
|
{ value: "all", label: "All" },
|
||||||
|
{ value: "none", label: "None" },
|
||||||
|
{ value: "whitelist", label: "Whitelist" },
|
||||||
|
];
|
||||||
|
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "count", label: "Count" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||||
|
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope: AutoDownloadScope): string {
|
||||||
|
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||||
|
}
|
||||||
|
|
||||||
export function usePreferencesItems(): SettingItem[] {
|
export function usePreferencesItems(): SettingItem[] {
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
const settings = () => app.state().settings;
|
const settings = () => app.state().settings;
|
||||||
const prefs = () => app.state().preferences;
|
const prefs = () => app.state().preferences;
|
||||||
|
|
||||||
return [
|
const items: SettingItem[] = [
|
||||||
{
|
{
|
||||||
id: "theme",
|
id: "theme",
|
||||||
label: "Theme",
|
label: "Theme",
|
||||||
@@ -52,6 +99,18 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
transparentBackground: !settings().transparentBackground,
|
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",
|
id: "fontSize",
|
||||||
label: "Font Size",
|
label: "Font Size",
|
||||||
@@ -97,11 +156,52 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
display: () => (prefs().autoDownload ? "On" : "Off"),
|
display: () => (prefs().autoDownload ? "On" : "Off"),
|
||||||
help: () =>
|
help: () =>
|
||||||
`Download new episodes automatically.\nType: toggle\nDefault: false\nCurrent: ${prefs().autoDownload}\nSpace/Enter to toggle.`,
|
`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: () =>
|
toggle: () => {
|
||||||
app.updatePreferences({
|
app.updatePreferences({ autoDownload: !prefs().autoDownload });
|
||||||
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",
|
id: "autoJumpToPlayer",
|
||||||
@@ -115,19 +215,371 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheMode",
|
||||||
|
label: "Episode Cache Mode",
|
||||||
|
kind: "select",
|
||||||
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: the Feed list is the N most-recent episodes across ALL shows (not N per show); Fetch More reveals N more of the newest episodes each press — deep history only appears once you page to it.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
CACHE_MODE_LABELS[
|
||||||
|
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||||
|
].value;
|
||||||
|
app.updatePreferences({ episodeCacheMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheCount",
|
||||||
|
label: "Episode Cache Count",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "count"
|
||||||
|
? `${prefs().episodeCacheCount} eps`
|
||||||
|
: "(date mode)",
|
||||||
|
help: () =>
|
||||||
|
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||||
|
app.updatePreferences({ episodeCacheCount: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Count"
|
||||||
|
value={() => prefs().episodeCacheCount}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheCount: Math.max(1, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheDays",
|
||||||
|
label: "Episode Cache Days",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "date"
|
||||||
|
? `${prefs().episodeCacheDays} days`
|
||||||
|
: "(count mode)",
|
||||||
|
help: () =>
|
||||||
|
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
365,
|
||||||
|
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ episodeCacheDays: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Days"
|
||||||
|
value={() => prefs().episodeCacheDays}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fetchMore",
|
id: "fetchMore",
|
||||||
label: "Fetch More",
|
label: "Fetch More",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
help: () =>
|
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: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||||
cycle: (dir) => {
|
cycle: (dir) => {
|
||||||
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||||
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
|
||||||
const next = modes[(idx + dir + modes.length) % modes.length];
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
app.updatePreferences({ fetchMoreMode: next });
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "refreshInterval",
|
||||||
|
label: "Feed Refresh Interval",
|
||||||
|
kind: "number",
|
||||||
|
display: () => `${prefs().refreshIntervalMinutes} min`,
|
||||||
|
help: () =>
|
||||||
|
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
120,
|
||||||
|
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ refreshIntervalMinutes: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Feed Refresh Interval (minutes)"
|
||||||
|
value={() => prefs().refreshIntervalMinutes}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
refreshIntervalMinutes: Math.min(120, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 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,
|
type PaneId,
|
||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
|
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { SettingItem, SettingsSectionDef } from "./types";
|
import type { SettingItem, SettingsSectionDef } from "./types";
|
||||||
import { usePreferencesItems } from "./PreferencesPanel";
|
import { usePreferencesItems } from "./PreferencesPanel";
|
||||||
@@ -37,6 +38,7 @@ import { useDownloadItems } from "./DownloadManager";
|
|||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const SettingsPaneCount = 1;
|
export const SettingsPaneCount = 1;
|
||||||
|
|
||||||
@@ -45,29 +47,38 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
id: 0,
|
id: 0,
|
||||||
label: "Sync",
|
label: "Sync",
|
||||||
description: "Import/export subscriptions and sync status.",
|
description: "Import/export subscriptions and sync status.",
|
||||||
|
icon: NF_ICONS.sync,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
label: "Sources",
|
label: "Sources",
|
||||||
description: "Podcast search/RSS sources — add, enable, remove.",
|
description: "Podcast search/RSS sources — add, enable, remove.",
|
||||||
|
icon: NF_ICONS.sources,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
label: "Preferences",
|
label: "Preferences",
|
||||||
description: "Theme, font, playback speed, explicit/auto-download.",
|
description: "Theme, font, playback speed, explicit/auto-download.",
|
||||||
|
icon: NF_ICONS.preferences,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
label: "Visualizer",
|
label: "Visualizer",
|
||||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
|
||||||
|
icon: NF_ICONS.visualizer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
label: "Downloads",
|
label: "Downloads",
|
||||||
description: "Manage downloaded episodes — delete by show or individually.",
|
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. */
|
/** Resolve the items for a section id at render time. */
|
||||||
function sectionItems(sectionId: number): SettingItem[] {
|
function sectionItems(sectionId: number): SettingItem[] {
|
||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
@@ -129,7 +140,6 @@ export function SettingsPage() {
|
|||||||
function open() {
|
function open() {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 0) {
|
if (d === 0) {
|
||||||
// drill into the focused section's items
|
|
||||||
const id = focusedSection().id;
|
const id = focusedSection().id;
|
||||||
nav.pushDepth({
|
nav.pushDepth({
|
||||||
kind: `settings:${id}`,
|
kind: `settings:${id}`,
|
||||||
@@ -195,7 +205,6 @@ export function SettingsPage() {
|
|||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 2) {
|
if (d === 2) {
|
||||||
// editor: j/k nudges the value
|
|
||||||
const it = editorItem();
|
const it = editorItem();
|
||||||
if (it?.kind === "number" || it?.kind === "select")
|
if (it?.kind === "number" || it?.kind === "select")
|
||||||
it.cycle?.(delta as -1 | 1);
|
it.cycle?.(delta as -1 | 1);
|
||||||
@@ -209,7 +218,6 @@ export function SettingsPage() {
|
|||||||
pane: PaneId;
|
pane: PaneId;
|
||||||
mode: NavMode;
|
mode: NavMode;
|
||||||
}) => {
|
}) => {
|
||||||
// ignore actions meant for non-center panes
|
|
||||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
const handler = PAGE_ACTIONS[data.action];
|
const handler = PAGE_ACTIONS[data.action];
|
||||||
@@ -286,6 +294,7 @@ export function SettingsPage() {
|
|||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<Row
|
<Row
|
||||||
label={section.label}
|
label={section.label}
|
||||||
|
icon={section.icon}
|
||||||
focused={index() === focusedSectionIdx()}
|
focused={index() === focusedSectionIdx()}
|
||||||
active={false}
|
active={false}
|
||||||
/>
|
/>
|
||||||
@@ -315,6 +324,7 @@ export function SettingsPage() {
|
|||||||
{(section, index) => (
|
{(section, index) => (
|
||||||
<Row
|
<Row
|
||||||
label={section.label}
|
label={section.label}
|
||||||
|
icon={section.icon}
|
||||||
focused={index() === focusedSectionIdx()}
|
focused={index() === focusedSectionIdx()}
|
||||||
active={isActive()}
|
active={isActive()}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
@@ -407,6 +417,7 @@ function Row(props: {
|
|||||||
focused: boolean;
|
focused: boolean;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
icon?: string;
|
||||||
onMouseDown?: () => void;
|
onMouseDown?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -423,17 +434,18 @@ function Row(props: {
|
|||||||
? theme.selectedListItemText ?? theme.text
|
? theme.selectedListItemText ?? theme.text
|
||||||
: theme.text;
|
: theme.text;
|
||||||
const ref = useScrollIntoView(() => props.focused);
|
const ref = useScrollIntoView(() => props.focused);
|
||||||
|
const marker = useSelectionMarker();
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={bg()}
|
backgroundColor={bg()}
|
||||||
onMouseDown={props.onMouseDown}
|
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>
|
<text fg={fg()}>{props.label}</text>
|
||||||
<Show when={props.value}>
|
<Show when={props.value}>
|
||||||
<box flexGrow={1} />
|
<box flexGrow={1} />
|
||||||
|
|||||||
@@ -11,16 +11,24 @@
|
|||||||
* right-pane key conflicts).
|
* 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 { useFeedStore } from "@/stores/feed";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
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 { SourceType } from "@/types/source";
|
||||||
import type { PodcastSource } from "@/types/source";
|
import type { PodcastSource } from "@/types/source";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
export function useSourceItems(): SettingItem[] {
|
export function useSourceItems(): SettingItem[] {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
const typeBadge = (s: PodcastSource) =>
|
const typeBadge = (s: PodcastSource) =>
|
||||||
s.type === SourceType.API
|
s.type === SourceType.API
|
||||||
@@ -48,8 +56,20 @@ export function useSourceItems(): SettingItem[] {
|
|||||||
kind: "toggle",
|
kind: "toggle",
|
||||||
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
display: () => `${typeBadge(s)} ${s.enabled ? "on" : "off"}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Source: ${s.name}\nType: ${s.type}\nEnabled: ${s.enabled}\nURL: ${s.baseUrl ?? "(none)"}\nSpace/Enter to toggle.`,
|
s.id === "podcastindex"
|
||||||
toggle: () => feedStore.toggleSource(s.id),
|
? `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}
|
onInput={setName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
width={25}
|
width={25}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
@@ -116,6 +139,9 @@ function AddSourceForm() {
|
|||||||
}}
|
}}
|
||||||
placeholder="https://example.com/feed.rss"
|
placeholder="https://example.com/feed.rss"
|
||||||
width={35}
|
width={35}
|
||||||
|
textColor={theme.text}
|
||||||
|
focusedTextColor={theme.accent}
|
||||||
|
cursorColor={theme.accent}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<box
|
<box
|
||||||
@@ -145,3 +171,152 @@ function AddSourceForm() {
|
|||||||
</box>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export function useVisualizerItems(): SettingItem[] {
|
|||||||
const viz = () => app.state().settings.visualizer;
|
const viz = () => app.state().settings.visualizer;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "Waveform",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (viz().enabled ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Realtime waveform visualizer in the player.\nType: toggle\nDefault: on\nCurrent: ${viz().enabled ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () => app.updateVisualizer({ enabled: !viz().enabled }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "bars",
|
id: "bars",
|
||||||
label: "Bars",
|
label: "Bars",
|
||||||
|
|||||||
@@ -41,5 +41,7 @@ export interface SettingsSectionDef {
|
|||||||
id: number;
|
id: number;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Nerd Font glyph for the section row (rendered only when supported). */
|
||||||
|
icon: string;
|
||||||
items?: () => SettingItem[];
|
items?: () => SettingItem[];
|
||||||
}
|
}
|
||||||
|
|||||||
73
src/stores/activity.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Activity store for PodTUI
|
||||||
|
*
|
||||||
|
* Shared leak-proof activity counter: any store can surface "something is
|
||||||
|
* loading/downloading" to the global top-right indicator. beginActivity
|
||||||
|
* returns an end token that removes exactly THAT instance, so concurrent
|
||||||
|
* overlapping activities compose correctly; prefer track() so callers
|
||||||
|
* cannot strand the counter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
function createActivityStore() {
|
||||||
|
const [count, setCount] = createSignal(0);
|
||||||
|
const [labels, setLabels] = createSignal<string[]>([]);
|
||||||
|
|
||||||
|
/** Begin a tracked activity and return its end function. Every begin
|
||||||
|
* MUST be paired with exactly one call of the returned end (via the
|
||||||
|
* token); prefer track() so the pairing is automatic. Duplicate labels
|
||||||
|
* are allowed — each end removes exactly one instance (found by
|
||||||
|
* indexOf). */
|
||||||
|
const beginActivity = (label: string): (() => void) => {
|
||||||
|
setLabels((prev) => [...prev, label]);
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
let ended = false;
|
||||||
|
return () => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
setLabels((prev) => {
|
||||||
|
const idx = prev.indexOf(label);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(idx, 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCount((c) => Math.max(0, c - 1));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Track a promise: begin an activity, auto-end when it settles, and
|
||||||
|
* re-throw on rejection so the caller's error handling is untouched. */
|
||||||
|
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
|
||||||
|
const end = beginActivity(label);
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True while at least one activity is in flight */
|
||||||
|
const isActive = (): boolean => count() > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
count,
|
||||||
|
labels,
|
||||||
|
// Actions
|
||||||
|
beginActivity,
|
||||||
|
track,
|
||||||
|
// Getters
|
||||||
|
isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||||
|
|
||||||
|
export function useActivityStore() {
|
||||||
|
if (!activityStoreInstance) {
|
||||||
|
activityStoreInstance = createActivityStore();
|
||||||
|
}
|
||||||
|
return activityStoreInstance;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
|
enabled: true,
|
||||||
bars: 64,
|
bars: 64,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
@@ -28,16 +29,25 @@ const defaultSettings: AppSettings = {
|
|||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
|
volume: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
transparentBackground: false,
|
transparentBackground: false,
|
||||||
|
showSelectionMarker: false,
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoDownloadCount: 2,
|
||||||
|
autoDownloadScope: "all",
|
||||||
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -50,12 +60,14 @@ function createAppStore() {
|
|||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState);
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
// Fire-and-forget async initialisation
|
// Fire-and-forget async initialisation; the promise is exposed via
|
||||||
|
// whenReady() so boot-time consumers (audio-level restore) can await
|
||||||
|
// the config read before reading settings.
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const loaded = await loadAppStateFromFile();
|
const loaded = await loadAppStateFromFile();
|
||||||
setState(loaded);
|
setState(loaded);
|
||||||
};
|
};
|
||||||
init();
|
const appInit = init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next);
|
saveAppStateToFile(next);
|
||||||
@@ -114,6 +126,8 @@ function createAppStore() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
/** Resolves once persisted settings are loaded from disk. */
|
||||||
|
whenReady: () => appInit,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateCustomTheme,
|
updateCustomTheme,
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import {
|
|||||||
saveAudioNavToFile,
|
saveAudioNavToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
/** Source type for audio navigation */
|
|
||||||
export enum AudioSource {
|
export enum AudioSource {
|
||||||
FEED = "feed",
|
FEED = "feed",
|
||||||
MY_SHOWS = "my_shows",
|
MY_SHOWS = "my_shows",
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audio navigation state */
|
|
||||||
export interface AudioNavState {
|
export interface AudioNavState {
|
||||||
/** Current source type */
|
/** Current source type */
|
||||||
source: AudioSource;
|
source: AudioSource;
|
||||||
@@ -28,14 +26,12 @@ export interface AudioNavState {
|
|||||||
lastUpdated: Date;
|
lastUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default navigation state */
|
|
||||||
const defaultNavState: AudioNavState = {
|
const defaultNavState: AudioNavState = {
|
||||||
source: AudioSource.FEED,
|
source: AudioSource.FEED,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Create audio navigation store */
|
|
||||||
function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
@@ -56,12 +52,10 @@ function createAudioNavStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Get current navigation state */
|
|
||||||
get state(): AudioNavState {
|
get state(): AudioNavState {
|
||||||
return navState();
|
return navState();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update source type */
|
|
||||||
setSource: (source: AudioSource, podcastId?: string) => {
|
setSource: (source: AudioSource, podcastId?: string) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -72,7 +66,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to next episode */
|
|
||||||
next: (currentIndex: number) => {
|
next: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -82,7 +75,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to previous episode */
|
|
||||||
prev: (currentIndex: number) => {
|
prev: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -92,23 +84,19 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset to default state */
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
setNavState(defaultNavState);
|
setNavState(defaultNavState);
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current index */
|
|
||||||
getCurrentIndex: (): number => {
|
getCurrentIndex: (): number => {
|
||||||
return navState().currentIndex;
|
return navState().currentIndex;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current source */
|
|
||||||
getSource: (): AudioSource => {
|
getSource: (): AudioSource => {
|
||||||
return navState().source;
|
return navState().source;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current podcast ID */
|
|
||||||
getPodcastId: (): string | undefined => {
|
getPodcastId: (): string | undefined => {
|
||||||
return navState().podcastId;
|
return navState().podcastId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import type { Podcast } from "../types/podcast";
|
import type { Podcast } from "../types/podcast";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
export interface DiscoverCategory {
|
export interface DiscoverCategory {
|
||||||
@@ -20,17 +21,17 @@ export interface DiscoverCategory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
|
||||||
{ id: "all", name: "All", icon: "*" },
|
{ id: "all", name: "All", icon: "\uF0CA" },
|
||||||
{ id: "technology", name: "Technology", icon: ">" },
|
{ id: "technology", name: "Technology", icon: "\uF2DB" },
|
||||||
{ id: "science", name: "Science", icon: "~" },
|
{ id: "science", name: "Science", icon: "\uF0C3" },
|
||||||
{ id: "comedy", name: "Comedy", icon: ")" },
|
{ id: "comedy", name: "Comedy", icon: "\uF118" },
|
||||||
{ id: "news", name: "News", icon: "!" },
|
{ id: "news", name: "News", icon: "\uF1EA" },
|
||||||
{ id: "business", name: "Business", icon: "$" },
|
{ id: "business", name: "Business", icon: "\uF0B1" },
|
||||||
{ id: "health", name: "Health", icon: "+" },
|
{ id: "health", name: "Health", icon: "\uF21E" },
|
||||||
{ id: "education", name: "Education", icon: "?" },
|
{ id: "education", name: "Education", icon: "\uF19D" },
|
||||||
{ id: "sports", name: "Sports", icon: "#" },
|
{ id: "sports", name: "Sports", icon: "\uF1E3" },
|
||||||
{ id: "true-crime", name: "True Crime", icon: "%" },
|
{ id: "true-crime", name: "True Crime", icon: "\uF00E" },
|
||||||
{ id: "arts", name: "Arts", icon: "@" },
|
{ id: "arts", name: "Arts", icon: "\uF1FC" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
// ── Remote featured-shows manifest ───────────────────────────────────────────
|
||||||
@@ -42,6 +43,10 @@ const FEATURED_JSON_URL =
|
|||||||
/** Cache window for the remote featured list (24 hours) */
|
/** Cache window for the remote featured list (24 hours) */
|
||||||
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const FEATURED_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Max episodes to load when previewing an unsubscribed show's episode list
|
||||||
|
* from Discover (drill-in, no subscription). Mirrors the refresh window. */
|
||||||
|
const PREVIEW_EPISODE_LIMIT = 50;
|
||||||
|
|
||||||
/** Shape of a single entry in the remote JSON */
|
/** Shape of a single entry in the remote JSON */
|
||||||
interface FeaturedEntry {
|
interface FeaturedEntry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -85,12 +90,24 @@ function syncSubscriptionState(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create discover store */
|
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
const [podcasts, setPodcasts] = createSignal<Podcast[]>([]);
|
||||||
|
|
||||||
|
// Episodes fetched for an unsubscribed show's preview list (drill-in from
|
||||||
|
// a podcast result, no subscription). Cached per podcast id for the
|
||||||
|
// session; keyed by id so switching shows never clobbers another's list.
|
||||||
|
const [previewEpisodes, setPreviewEpisodes] = createSignal<
|
||||||
|
Record<string, Episode[]>
|
||||||
|
>({});
|
||||||
|
const [previewLoading, setPreviewLoading] = createSignal<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [previewErrors, setPreviewErrors] = createSignal<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
|
||||||
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
// In-memory cache timestamp for the remote manifest (within 24h, skip refetch)
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
||||||
@@ -107,7 +124,6 @@ export function createDiscoverStore() {
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Skip if cache is still fresh
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
@@ -131,7 +147,6 @@ export function createDiscoverStore() {
|
|||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
|
|
||||||
// Reflect current feed-store subscriptions
|
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure — keep whatever we have (stale or empty)
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
@@ -140,7 +155,6 @@ export function createDiscoverStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory();
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
@@ -155,7 +169,6 @@ export function createDiscoverStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -168,7 +181,6 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Unsubscribe from a podcast */
|
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -180,14 +192,64 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle subscription */
|
// ── episode preview (drill-in, no subscription) ──────────────────────────
|
||||||
const toggleSubscription = (podcastId: string) => {
|
/** Cached episode list for a previewed show (empty until first drill-in). */
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const episodesForPodcast = (podcastId: string): Episode[] =>
|
||||||
if (podcast?.isSubscribed) {
|
previewEpisodes()[podcastId] ?? [];
|
||||||
unsubscribe(podcastId);
|
|
||||||
} else {
|
const isLoadingEpisodesFor = (podcastId: string): boolean =>
|
||||||
subscribe(podcastId);
|
previewLoading().has(podcastId);
|
||||||
|
|
||||||
|
const previewError = (podcastId: string): string | undefined =>
|
||||||
|
previewErrors()[podcastId];
|
||||||
|
|
||||||
|
/** Fetch a show's episode list WITHOUT subscribing (Discover preview).
|
||||||
|
* The list is cached per podcast id; a failed fetch records an error
|
||||||
|
* and keeps any previous cache (a retry via refreshEpisodes clears it). */
|
||||||
|
const openEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||||
|
if (previewEpisodes()[podcast.id] || previewLoading().has(podcast.id))
|
||||||
|
return;
|
||||||
|
if (!podcast.feedUrl) {
|
||||||
|
setPreviewErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[podcast.id]: "No RSS feed listed for this show.",
|
||||||
|
}));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
setPreviewLoading((prev) => new Set(prev).add(podcast.id));
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const { episodes } = await feedStore.fetchEpisodes(
|
||||||
|
podcast.feedUrl,
|
||||||
|
PREVIEW_EPISODE_LIMIT,
|
||||||
|
);
|
||||||
|
if (episodes) {
|
||||||
|
setPreviewEpisodes((prev) => ({ ...prev, [podcast.id]: episodes }));
|
||||||
|
} else {
|
||||||
|
setPreviewErrors((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[podcast.id]: "Couldn't load episodes.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
setPreviewLoading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(podcast.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Re-fetch a previewed show's episode list (`r` on the episodes depth). */
|
||||||
|
const refreshEpisodes = async (podcast: Podcast): Promise<void> => {
|
||||||
|
setPreviewErrors((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[podcast.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPreviewEpisodes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[podcast.id];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await openEpisodes(podcast);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -202,12 +264,17 @@ export function createDiscoverStore() {
|
|||||||
setSelectedCategory,
|
setSelectedCategory,
|
||||||
subscribe,
|
subscribe,
|
||||||
unsubscribe,
|
unsubscribe,
|
||||||
toggleSubscription,
|
|
||||||
refresh,
|
refresh,
|
||||||
|
|
||||||
|
// Episode preview (drill-in, no subscription)
|
||||||
|
episodesForPodcast,
|
||||||
|
isLoadingEpisodesFor,
|
||||||
|
previewError,
|
||||||
|
openEpisodes,
|
||||||
|
refreshEpisodes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton discover store */
|
|
||||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||||
|
|
||||||
export function useDiscoverStore() {
|
export function useDiscoverStore() {
|
||||||
|
|||||||
@@ -10,12 +10,32 @@ import { createSignal } from "solid-js";
|
|||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
import type { DownloadedEpisode } from "../types/episode";
|
import type { DownloadedEpisode } from "../types/episode";
|
||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
|
import type { Podcast } from "../types/podcast";
|
||||||
import { downloadEpisode } from "../utils/episode-downloader";
|
import { downloadEpisode } from "../utils/episode-downloader";
|
||||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||||
|
import { useFeedStore } from "./feed";
|
||||||
|
|
||||||
const DOWNLOADS_FILE = "downloads.json";
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2;
|
const MAX_CONCURRENT = 2;
|
||||||
|
|
||||||
|
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
|
||||||
|
* downloads). The id doubles as the file subdirectory name, so it must be
|
||||||
|
* filesystem-safe. */
|
||||||
|
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
|
||||||
|
|
||||||
|
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
|
||||||
|
* its search downloads together (and names their file subdirectory) without
|
||||||
|
* colliding with real feed ids (UUIDs). */
|
||||||
|
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
|
||||||
|
const base = podcast.feedUrl || podcast.title;
|
||||||
|
const slug = base
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializable download record for persistence */
|
/** Serializable download record for persistence */
|
||||||
interface DownloadRecord {
|
interface DownloadRecord {
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
@@ -27,6 +47,12 @@ interface DownloadRecord {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
episodeTitle: string;
|
episodeTitle: string;
|
||||||
|
/** ISO publication date, for unsubscribed-show downloads. */
|
||||||
|
pubDate?: string;
|
||||||
|
/** Show title, for downloads whose show isn't subscribed. */
|
||||||
|
podcastTitle?: string;
|
||||||
|
/** The show's RSS feed URL (re-classifies the download once subscribed). */
|
||||||
|
podcastFeedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue item for pending downloads */
|
/** Queue item for pending downloads */
|
||||||
@@ -37,7 +63,62 @@ interface QueueItem {
|
|||||||
episodeTitle: string;
|
episodeTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create download store */
|
// ── post-download decoration ─────────────────────────────────────────────────
|
||||||
|
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
|
||||||
|
* picks it up for Now Playing art when the local file plays (same basename,
|
||||||
|
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
|
||||||
|
* hangs in compiled binaries, so the shipped app never wrote this file. */
|
||||||
|
function writeCoverArt(filePath: string, coverUrl: string): void {
|
||||||
|
const dot = filePath.lastIndexOf(".");
|
||||||
|
if (dot <= 0) return;
|
||||||
|
const coverPath = filePath.slice(0, dot) + ".jpg";
|
||||||
|
Bun.spawn([
|
||||||
|
"curl",
|
||||||
|
"-sS",
|
||||||
|
"--fail",
|
||||||
|
"-m",
|
||||||
|
"8",
|
||||||
|
"--max-filesize",
|
||||||
|
"2097152",
|
||||||
|
"-o",
|
||||||
|
coverPath,
|
||||||
|
coverUrl,
|
||||||
|
])
|
||||||
|
.exited.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
|
||||||
|
* metadata for local playback is title=episode, artist=podcast — the source
|
||||||
|
* streams carry no usable tags and macOS composes "title - artist" from
|
||||||
|
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
|
||||||
|
* into place. */
|
||||||
|
function tagLocalFile(
|
||||||
|
filePath: string,
|
||||||
|
episode: Episode,
|
||||||
|
podcastTitle: string,
|
||||||
|
): void {
|
||||||
|
const tmp = `${filePath}.tag.mp3`;
|
||||||
|
Bun.spawn([
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
filePath,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-metadata",
|
||||||
|
`title=${episode.title}`,
|
||||||
|
"-metadata",
|
||||||
|
`artist=${podcastTitle}`,
|
||||||
|
tmp,
|
||||||
|
])
|
||||||
|
.exited.then(async (code) => {
|
||||||
|
if (code !== 0) return;
|
||||||
|
const { renameSync } = await import("node:fs");
|
||||||
|
renameSync(tmp, filePath);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function createDownloadStore() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<
|
const [downloads, setDownloads] = createSignal<
|
||||||
Map<string, DownloadedEpisode>
|
Map<string, DownloadedEpisode>
|
||||||
@@ -80,6 +161,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: rec.fileSize,
|
fileSize: rec.fileSize,
|
||||||
error: rec.error,
|
error: rec.error,
|
||||||
|
episodeTitle: rec.episodeTitle || undefined,
|
||||||
|
audioUrl: rec.audioUrl || undefined,
|
||||||
|
pubDate: rec.pubDate || undefined,
|
||||||
|
podcastTitle: rec.podcastTitle || undefined,
|
||||||
|
podcastFeedUrl: rec.podcastFeedUrl || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -105,8 +191,11 @@ function createDownloadStore() {
|
|||||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||||
fileSize: dl.fileSize,
|
fileSize: dl.fileSize,
|
||||||
error: dl.error,
|
error: dl.error,
|
||||||
audioUrl: qItem?.audioUrl ?? "",
|
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
|
||||||
episodeTitle: qItem?.episodeTitle ?? "",
|
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
|
||||||
|
pubDate: dl.pubDate,
|
||||||
|
podcastTitle: dl.podcastTitle,
|
||||||
|
podcastFeedUrl: dl.podcastFeedUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||||
@@ -161,7 +250,6 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute a single download */
|
|
||||||
async function executeDownload(item: QueueItem): Promise<void> {
|
async function executeDownload(item: QueueItem): Promise<void> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllers.set(item.episodeId, controller);
|
abortControllers.set(item.episodeId, controller);
|
||||||
@@ -201,6 +289,26 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Decorate the local file: cover art + ID3 tags (see the
|
||||||
|
// module-level helpers above) — the source streams carry neither.
|
||||||
|
// Cover falls back to the episode's own image when the feed has
|
||||||
|
// no channel cover (URL-added feeds).
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const episode = feedStore.findEpisode(item.episodeId);
|
||||||
|
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
|
||||||
|
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
|
||||||
|
if (result.filePath && coverUrl) {
|
||||||
|
writeCoverArt(result.filePath, coverUrl);
|
||||||
|
}
|
||||||
|
if (result.filePath && episode) {
|
||||||
|
const podcastTitle =
|
||||||
|
feed?.podcast.title ??
|
||||||
|
downloads().get(item.episodeId)?.podcastTitle;
|
||||||
|
if (podcastTitle) {
|
||||||
|
tagLocalFile(result.filePath, episode, podcastTitle);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
status: DownloadStatus.FAILED,
|
status: DownloadStatus.FAILED,
|
||||||
@@ -214,22 +322,18 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get download status for an episode */
|
|
||||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get download progress for an episode (0-100) */
|
|
||||||
const getDownloadProgress = (episodeId: string): number => {
|
const getDownloadProgress = (episodeId: string): number => {
|
||||||
return downloads().get(episodeId)?.progress ?? 0;
|
return downloads().get(episodeId)?.progress ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get full download info for an episode */
|
|
||||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||||
return downloads().get(episodeId);
|
return downloads().get(episodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the local file path for a completed download */
|
|
||||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||||
@@ -238,8 +342,19 @@ function createDownloadStore() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Start downloading an episode */
|
/** Optional metadata for a download whose show isn't subscribed (search
|
||||||
const startDownload = (episode: Episode, feedId: string): void => {
|
* downloads) — without it the record cannot render a title or be
|
||||||
|
* re-classified once the show is subscribed. */
|
||||||
|
interface UnsubscribedMeta {
|
||||||
|
podcastTitle: string;
|
||||||
|
podcastFeedUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDownload = (
|
||||||
|
episode: Episode,
|
||||||
|
feedId: string,
|
||||||
|
meta?: UnsubscribedMeta,
|
||||||
|
): void => {
|
||||||
const existing = downloads().get(episode.id);
|
const existing = downloads().get(episode.id);
|
||||||
if (
|
if (
|
||||||
existing?.status === DownloadStatus.DOWNLOADING ||
|
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||||
@@ -258,6 +373,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: episode.fileSize ?? 0,
|
fileSize: episode.fileSize ?? 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
episodeTitle: episode.title,
|
||||||
|
audioUrl: episode.audioUrl,
|
||||||
|
pubDate: episode.pubDate.toISOString(),
|
||||||
|
podcastTitle: meta?.podcastTitle,
|
||||||
|
podcastFeedUrl: meta?.podcastFeedUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
@@ -278,7 +398,21 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
processQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cancel a download */
|
/** Start downloading an episode of a show that is NOT subscribed. The
|
||||||
|
* download gets a deterministic synthetic feed id (also its file
|
||||||
|
* subdirectory) plus the show's metadata so it can render under
|
||||||
|
* "Unsubscribed Show Downloads" and re-classify if the user later
|
||||||
|
* subscribes to the show. */
|
||||||
|
const startUnsubscribedDownload = (
|
||||||
|
episode: Episode,
|
||||||
|
podcast: Podcast,
|
||||||
|
): void => {
|
||||||
|
startDownload(episode, unsubscribedFeedId(podcast), {
|
||||||
|
podcastTitle: podcast.title,
|
||||||
|
podcastFeedUrl: podcast.feedUrl || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const cancelDownload = (episodeId: string): void => {
|
const cancelDownload = (episodeId: string): void => {
|
||||||
// Abort active download
|
// Abort active download
|
||||||
const controller = abortControllers.get(episodeId);
|
const controller = abortControllers.get(episodeId);
|
||||||
@@ -299,13 +433,17 @@ function createDownloadStore() {
|
|||||||
saveDownloads().catch(() => {});
|
saveDownloads().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a completed download (delete file and metadata) */
|
|
||||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.filePath) {
|
if (dl?.filePath) {
|
||||||
try {
|
try {
|
||||||
const { unlink } = await import("fs/promises");
|
const { unlink } = await import("fs/promises");
|
||||||
await unlink(dl.filePath);
|
await unlink(dl.filePath);
|
||||||
|
const dot = dl.filePath.lastIndexOf(".");
|
||||||
|
if (dot > 0) {
|
||||||
|
const coverPath = dl.filePath.slice(0, dot) + ".jpg";
|
||||||
|
await unlink(coverPath);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// File may already be gone
|
// File may already be gone
|
||||||
}
|
}
|
||||||
@@ -321,10 +459,18 @@ function createDownloadStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Remove every download (active/queued/completed) belonging to a feed —
|
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||||
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
* abort in-flight transfers, drop queued items, delete files + metadata.
|
||||||
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
* Also removes downloads of the same show made while it was unsubscribed
|
||||||
|
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
|
||||||
|
* of that show too. */
|
||||||
|
const removeDownloadsForFeed = async (
|
||||||
|
feedId: string,
|
||||||
|
podcastFeedUrl?: string,
|
||||||
|
): Promise<void> => {
|
||||||
const eps = Array.from(downloads().values()).filter(
|
const eps = Array.from(downloads().values()).filter(
|
||||||
(d) => d.feedId === feedId,
|
(d) =>
|
||||||
|
d.feedId === feedId ||
|
||||||
|
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
|
||||||
);
|
);
|
||||||
for (const d of eps) {
|
for (const d of eps) {
|
||||||
cancelDownload(d.episodeId);
|
cancelDownload(d.episodeId);
|
||||||
@@ -332,17 +478,32 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get all downloads as an array */
|
|
||||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
return Array.from(downloads().values());
|
return Array.from(downloads().values());
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the current queue */
|
/** Downloads whose show is not subscribed — the "Unsubscribed Show
|
||||||
|
* Downloads" list shown in My Shows and the settings download manager.
|
||||||
|
* Reads feeds() so the list re-classifies (drops out) the moment the
|
||||||
|
* user subscribes to the show. Matched by feed id, or by the show's
|
||||||
|
* feed URL (covers downloads made before the show was subscribed). */
|
||||||
|
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
|
||||||
|
const feeds = useFeedStore().feeds();
|
||||||
|
return Array.from(downloads().values()).filter((d) => {
|
||||||
|
if (feeds.some((f) => f.id === d.feedId)) return false;
|
||||||
|
if (d.podcastFeedUrl) {
|
||||||
|
return !feeds.some(
|
||||||
|
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getQueue = (): QueueItem[] => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return queue();
|
return queue();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get count of active downloads */
|
|
||||||
const getActiveCount = (): number => {
|
const getActiveCount = (): number => {
|
||||||
return activeCount();
|
return activeCount();
|
||||||
};
|
};
|
||||||
@@ -354,18 +515,19 @@ function createDownloadStore() {
|
|||||||
getDownload,
|
getDownload,
|
||||||
getDownloadedFilePath,
|
getDownloadedFilePath,
|
||||||
getAllDownloads,
|
getAllDownloads,
|
||||||
|
getUnsubscribedDownloads,
|
||||||
getQueue,
|
getQueue,
|
||||||
getActiveCount,
|
getActiveCount,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
startDownload,
|
startDownload,
|
||||||
|
startUnsubscribedDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
removeDownload,
|
removeDownload,
|
||||||
removeDownloadsForFeed,
|
removeDownloadsForFeed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton download store */
|
|
||||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||||
|
|
||||||
export function useDownloadStore() {
|
export function useDownloadStore() {
|
||||||
|
|||||||
@@ -53,21 +53,21 @@ async function initProgress(): Promise<void> {
|
|||||||
setProgressMap(parsed);
|
setProgressMap(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget init
|
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
|
||||||
initProgress();
|
// consumers (e.g. player-session restore) can await the file load.
|
||||||
|
const progressInit = initProgress();
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Get progress for a specific episode.
|
* Resolves once the persisted progress map has been loaded from disk.
|
||||||
*/
|
*/
|
||||||
|
whenReady: () => progressInit,
|
||||||
|
|
||||||
get(episodeId: string): Progress | undefined {
|
get(episodeId: string): Progress | undefined {
|
||||||
return progressMap()[episodeId];
|
return progressMap()[episodeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all progress entries.
|
|
||||||
*/
|
|
||||||
all(): Record<string, Progress> {
|
all(): Record<string, Progress> {
|
||||||
return progressMap();
|
return progressMap();
|
||||||
},
|
},
|
||||||
@@ -96,18 +96,12 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an episode is completed.
|
|
||||||
*/
|
|
||||||
isCompleted(episodeId: string): boolean {
|
isCompleted(episodeId: string): boolean {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return false;
|
if (!p || p.duration <= 0) return false;
|
||||||
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress percentage (0-100) for an episode.
|
|
||||||
*/
|
|
||||||
getPercent(episodeId: string): number {
|
getPercent(episodeId: string): number {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return 0;
|
if (!p || p.duration <= 0) return 0;
|
||||||
@@ -145,9 +139,6 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all progress data.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
setProgressMap({});
|
setProgressMap({});
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||||
|
import {
|
||||||
|
loadSearchHistoryFromFile,
|
||||||
|
saveSearchHistoryToFile,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
import { useFeedStore } from "./feed";
|
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;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -20,36 +24,66 @@ export interface SearchState {
|
|||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 5;
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
|
|
||||||
/** Load search history from localStorage */
|
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
|
||||||
function loadHistory(): string[] {
|
* wins), cap at MAX_HISTORY. */
|
||||||
if (typeof localStorage === "undefined") return [];
|
function sanitizeHistory(items: string[]): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const cleaned: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
cleaned.push(trimmed);
|
||||||
|
}
|
||||||
|
return cleaned.slice(0, MAX_HISTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||||
|
function loadScope(): SearchScope {
|
||||||
|
if (typeof localStorage === "undefined") return "podcast";
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_SCOPE_KEY);
|
||||||
return stored ? JSON.parse(stored) : [];
|
return stored === "episode" ? "episode" : "podcast";
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return "podcast";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save search history to localStorage */
|
/** Save search scope to localStorage */
|
||||||
function saveHistory(history: string[]): void {
|
function saveScope(scope: SearchScope): void {
|
||||||
if (typeof localStorage === "undefined") return;
|
if (typeof localStorage === "undefined") return;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
localStorage.setItem(STORAGE_SCOPE_KEY, scope);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create search store */
|
|
||||||
export function createSearchStore() {
|
export function createSearchStore() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const [query, setQuery] = createSignal("");
|
const [query, setQuery] = createSignal("");
|
||||||
const [isSearching, setIsSearching] = createSignal(false);
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
const [history, setHistory] = createSignal<string[]>([]);
|
||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
|
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||||
|
|
||||||
|
/** Load search history from file (fire-and-forget; recents appear as
|
||||||
|
* soon as the file is read). */
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
const loaded = await loadSearchHistoryFromFile();
|
||||||
|
if (loaded.length > 0) setHistory(sanitizeHistory(loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
|
/** Set the search scope (shows vs episodes) and persist it. */
|
||||||
|
const setScope = (next: SearchScope) => {
|
||||||
|
setScopeState(next);
|
||||||
|
saveScope(next);
|
||||||
|
};
|
||||||
|
|
||||||
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
|
||||||
const feeds = feedStore.feeds();
|
const feeds = feedStore.feeds();
|
||||||
@@ -110,9 +144,14 @@ export function createSearchStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = await searchPodcasts(q, sourceIds, sources, {
|
const searchResults =
|
||||||
cacheTtl: CACHE_TTL,
|
scope() === "episode"
|
||||||
});
|
? await searchEpisodes(q, sourceIds, sources, {
|
||||||
|
cacheTtl: CACHE_TTL,
|
||||||
|
})
|
||||||
|
: await searchPodcasts(q, sourceIds, sources, {
|
||||||
|
cacheTtl: CACHE_TTL,
|
||||||
|
});
|
||||||
|
|
||||||
setResults(applySubscribedStatus(searchResults));
|
setResults(applySubscribedStatus(searchResults));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -127,39 +166,33 @@ export function createSearchStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add query to history */
|
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const updated = sanitizeHistory([q, ...prev]);
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
saveSearchHistoryToFile(updated);
|
||||||
saveHistory(updated);
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear search history */
|
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
saveHistory([]);
|
saveSearchHistoryToFile([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q);
|
const updated = prev.filter((h) => h !== q);
|
||||||
saveHistory(updated);
|
saveSearchHistoryToFile(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear results */
|
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mark a podcast as subscribed in results */
|
|
||||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
setResults((prev) =>
|
setResults((prev) =>
|
||||||
prev.map((result) => {
|
prev.map((result) => {
|
||||||
@@ -179,6 +212,27 @@ export function createSearchStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Mark a podcast as unsubscribed in results (after an in-place
|
||||||
|
* unsubscribe from the results list). */
|
||||||
|
const markUnsubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
|
setResults((prev) =>
|
||||||
|
prev.map((result) => {
|
||||||
|
const matchesId = result.podcast.id === podcastId;
|
||||||
|
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
|
||||||
|
if (matchesId || matchesUrl) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
podcast: {
|
||||||
|
...result.podcast,
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
query,
|
query,
|
||||||
@@ -187,6 +241,7 @@ export function createSearchStore() {
|
|||||||
error,
|
error,
|
||||||
history,
|
history,
|
||||||
selectedSources,
|
selectedSources,
|
||||||
|
scope,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
search,
|
search,
|
||||||
@@ -195,11 +250,12 @@ export function createSearchStore() {
|
|||||||
clearHistory,
|
clearHistory,
|
||||||
removeFromHistory,
|
removeFromHistory,
|
||||||
setSelectedSources,
|
setSelectedSources,
|
||||||
|
setScope,
|
||||||
markSubscribed,
|
markSubscribed,
|
||||||
|
markUnsubscribed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton search store */
|
|
||||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||||
|
|
||||||
export function useSearchStore() {
|
export function useSearchStore() {
|
||||||
|
|||||||
450
src/stores/visualizer.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* visualizer-store — module-level singleton owning the realtime waveform
|
||||||
|
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
|
||||||
|
*
|
||||||
|
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
|
||||||
|
* an ffmpeg process decodes the episode at full speed into a
|
||||||
|
* position-indexed PCM cache; the render loop reads the window ending at
|
||||||
|
* the player's current position from that cache. Because reads are
|
||||||
|
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
|
||||||
|
* choreography at all — and cannot desync:
|
||||||
|
*
|
||||||
|
* - Pause: stop the render loop and the decode pass; the PCM cache stays
|
||||||
|
* resident. Bars freeze on the last rendered frame.
|
||||||
|
* - Resume: re-arm the render loop — bars render instantly from the cache
|
||||||
|
* — and continue the tail decode in the background. No cold start, no
|
||||||
|
* coverage guessing, no clamped-buffer freeze (the old bug: resume
|
||||||
|
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
|
||||||
|
* buffer, then froze on a repeated stale window forever).
|
||||||
|
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
|
||||||
|
* decode segment there; the last frame holds until data arrives.
|
||||||
|
* - Speed changes: nothing. The cache is position-indexed raw PCM.
|
||||||
|
*
|
||||||
|
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
|
||||||
|
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
|
||||||
|
* after the Player tab stops being focused it tears down. Reads outside
|
||||||
|
* decoded coverage return empty — the renderer simply holds the last frame
|
||||||
|
* until the decode frontier arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
createRoot,
|
||||||
|
on,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js";
|
||||||
|
import {
|
||||||
|
loadCavaCore,
|
||||||
|
type CavaCore,
|
||||||
|
type CavaCoreConfig,
|
||||||
|
} from "@/utils/cavacore";
|
||||||
|
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
|
||||||
|
import { createBarScaler } from "@/utils/bar-mapping";
|
||||||
|
import { audioPlaybackSignals } from "@/utils/audio-signals";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** How long the pipeline keeps running after the Player tab loses focus. */
|
||||||
|
export const VISUALIZER_UNLOAD_DELAY_MS = 30_000;
|
||||||
|
|
||||||
|
/** Target frame interval in ms (~30 fps) */
|
||||||
|
const FRAME_INTERVAL = 33;
|
||||||
|
|
||||||
|
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||||
|
const SAMPLES_PER_FRAME = 512;
|
||||||
|
|
||||||
|
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VisualizerStore {
|
||||||
|
/** Frequency bar values (0.0–1.0 per bar), empty until the first frame. */
|
||||||
|
barData: () => number[];
|
||||||
|
/** True from pipeline start until the first complete FFT frame renders. */
|
||||||
|
isLoading: () => boolean;
|
||||||
|
/** True while the ~30fps render loop is armed. */
|
||||||
|
isRunning: () => boolean;
|
||||||
|
/** Report whether the Player tab is the visible tab. */
|
||||||
|
setFocused: (focused: boolean) => void;
|
||||||
|
/** Report the terminal-width-derived bar count (resize re-inits). */
|
||||||
|
setBarCount: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store factory ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createVisualizerStore(): VisualizerStore {
|
||||||
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
|
// True from pipeline start until the first complete FFT frame renders.
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
|
||||||
|
// Whether the Player tab is the visible tab (fed by PlayerPage).
|
||||||
|
const [focused, setFocused] = createSignal(false);
|
||||||
|
|
||||||
|
// Width-derived bar count (fed by RealtimeWaveform; default before the
|
||||||
|
// renderer reports a real size).
|
||||||
|
const [barCount, setBarCount] = createSignal(64);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
// Position-indexed PCM cache for the current episode. Kept across
|
||||||
|
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||||
|
// dropped only on episode change, stop, disable, or unload.
|
||||||
|
let pcm: EpisodePcmCache | null = null;
|
||||||
|
let frameTimer: TimerHandle | null = null;
|
||||||
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
let unloadTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
|
// What the running pipeline was started with — lets the playback effect
|
||||||
|
// tell "nothing changed, stay warm" from "must restart".
|
||||||
|
let activeUrl = "";
|
||||||
|
let activeBars = 64;
|
||||||
|
|
||||||
|
// ── Lifecycle helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const clearUnloadTimer = () => {
|
||||||
|
if (unloadTimer) {
|
||||||
|
clearTimeout(unloadTimer);
|
||||||
|
unloadTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCava = () => {
|
||||||
|
if (cava) return true;
|
||||||
|
|
||||||
|
cava = loadCavaCore();
|
||||||
|
if (!cava) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = audioPlaybackSignals.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 * (audioPlaybackSignals.speed() ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
|
|
||||||
|
const startVisualization = (url: string, position: number) => {
|
||||||
|
stopVisualization();
|
||||||
|
|
||||||
|
if (!url || !initCava() || !cava) return;
|
||||||
|
|
||||||
|
// Initialize cavacore with current resolution + the user's
|
||||||
|
// 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 viz = useAppStore().state().settings.visualizer;
|
||||||
|
const config: CavaCoreConfig = {
|
||||||
|
bars: barCount(),
|
||||||
|
sampleRate: PCM_SAMPLE_RATE,
|
||||||
|
channels: 1,
|
||||||
|
noiseReduction: viz.noiseReduction,
|
||||||
|
lowCutOff: viz.lowCutOff,
|
||||||
|
highCutOff: viz.highCutOff,
|
||||||
|
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.
|
||||||
|
cava.execute(new Float64Array(8192));
|
||||||
|
|
||||||
|
// Pre-allocate sample read buffer
|
||||||
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
|
|
||||||
|
// PCM cache per episode (reuse when the episode is unchanged)
|
||||||
|
if (!pcm || pcm.url !== url) {
|
||||||
|
if (pcm) pcm.stop();
|
||||||
|
pcm = new EpisodePcmCache({ url });
|
||||||
|
}
|
||||||
|
// Decode from 1s before the position so the window ENDING at the
|
||||||
|
// position is covered as soon as the first PCM lands.
|
||||||
|
pcm.startDecode(Math.max(0, position - 1));
|
||||||
|
|
||||||
|
// Seed the smooth position clock with the start position. Without
|
||||||
|
// this, a fresh play at position 0 would sample the window ending at
|
||||||
|
// exactly 0 — a 1-sample slice — so bars would be starved until the
|
||||||
|
// first mpv poll advanced the position clock.
|
||||||
|
lastPolledPosition = position;
|
||||||
|
lastPolledAt = performance.now();
|
||||||
|
|
||||||
|
activeUrl = url;
|
||||||
|
activeBars = barCount();
|
||||||
|
setIsLoading(true);
|
||||||
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopVisualization = () => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
if (pcm) {
|
||||||
|
pcm.stop();
|
||||||
|
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||||
|
// same episode reuses it; segments re-decode in seconds at 80x.
|
||||||
|
}
|
||||||
|
if (cava?.isReady) {
|
||||||
|
cava.destroy();
|
||||||
|
}
|
||||||
|
sampleBuffer = null;
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pause: freeze the loop, keep the cache ──────────────────────────
|
||||||
|
//
|
||||||
|
// The render loop stops (bars hold their last frame) and the ffmpeg
|
||||||
|
// pass dies (no background CPU), but the decoded PCM stays: resume
|
||||||
|
// serves it instantly.
|
||||||
|
|
||||||
|
const suspendVisualization = () => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
// Cancel any debounced seek-decode: it would restart ffmpeg while
|
||||||
|
// paused, defeating the "no background CPU while paused" contract.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
if (pcm) pcm.pauseDecode();
|
||||||
|
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||||
|
// Clear the loading spinner: if the pipeline never produced bars
|
||||||
|
// (still cold-starting when paused), the component should fall back
|
||||||
|
// to the placeholder, not freeze on a spinner.
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Resume: re-arm the render loop, top up the cache ───────────────
|
||||||
|
//
|
||||||
|
// Returns true if the pipeline resumed, false if there was nothing to
|
||||||
|
// resume (no prior pipeline).
|
||||||
|
|
||||||
|
const resumeVisualization = (): boolean => {
|
||||||
|
// Already running — nothing to do.
|
||||||
|
if (frameTimer !== null) return true;
|
||||||
|
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
|
||||||
|
|
||||||
|
const pos = untrack(audioPlaybackSignals.position);
|
||||||
|
|
||||||
|
// Bars come from the cache on the next frame tick (~33ms) whenever
|
||||||
|
// the position is covered; any gap (uncached region) restarts the
|
||||||
|
// decode pass in the background with the last frame holding.
|
||||||
|
pcm.ensureDecodeAround(pos);
|
||||||
|
|
||||||
|
lastPolledPosition = pos;
|
||||||
|
lastPolledAt = performance.now();
|
||||||
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||||
|
|
||||||
|
const renderFrame = () => {
|
||||||
|
if (!cava?.isReady || !sampleBuffer || !pcm) return;
|
||||||
|
|
||||||
|
// Sample the FFT window at the player's position. Outside decoded
|
||||||
|
// coverage (decode cold start, seek into a hole) the read is empty
|
||||||
|
// and the LAST FRAME simply holds — never clamped/repeated junk.
|
||||||
|
const target = smoothPosition();
|
||||||
|
const count = pcm.readWindow(sampleBuffer, target);
|
||||||
|
// Never feed a partial FFT window to cava.
|
||||||
|
if (count < sampleBuffer.length) return;
|
||||||
|
|
||||||
|
const output = cava.execute(sampleBuffer);
|
||||||
|
|
||||||
|
// Normalize against the running peak and copy to a new array
|
||||||
|
setBarData(scaler(output));
|
||||||
|
if (isLoading()) setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Playback subscription ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keeps the pipeline matched to playback. Pause suspends (render loop +
|
||||||
|
// decode pass die, cache survives) so resume is instant. Stop/track-end/
|
||||||
|
// disable fully tears down. `focused` is a dep so focus regain
|
||||||
|
// re-evaluates; the guards make a focus flip on an already-correct warm
|
||||||
|
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
|
||||||
|
// position-indexed, so playback-rate changes need no pipeline restart.
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
[
|
||||||
|
audioPlaybackSignals.isPlaying,
|
||||||
|
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
|
||||||
|
barCount,
|
||||||
|
focused,
|
||||||
|
() => useAppStore().state().settings.visualizer.enabled,
|
||||||
|
],
|
||||||
|
([playing, url, , , enabled]) => {
|
||||||
|
if (!url || !enabled) {
|
||||||
|
stopVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!playing) {
|
||||||
|
// Pause: freeze the loop, keep the cache. Only if the
|
||||||
|
// pipeline is actually running — otherwise no-op.
|
||||||
|
if (frameTimer !== null) suspendVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing — try a fast resume first. If it succeeds and the
|
||||||
|
// pipeline matches, done.
|
||||||
|
if (
|
||||||
|
frameTimer === null &&
|
||||||
|
pcm &&
|
||||||
|
cava?.isReady &&
|
||||||
|
url === activeUrl &&
|
||||||
|
barCount() === activeBars
|
||||||
|
) {
|
||||||
|
if (resumeVisualization()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm and already correct — nothing to do (e.g. focus
|
||||||
|
// regained within the unload delay while still playing).
|
||||||
|
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!focused()) return; // playing away: stay warm; unload timer decides
|
||||||
|
startVisualization(url, untrack(audioPlaybackSignals.position));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Focus subscription: unload after the grace delay ───────────────
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(focused, (f) => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (f) {
|
||||||
|
// Pipeline was unloaded (or never started) but playback is
|
||||||
|
// still going — restart from the current position. When the
|
||||||
|
// pipeline is warm the playback effect above is the one that
|
||||||
|
// acts (guard: no-op for an unchanged warm pipeline).
|
||||||
|
if (
|
||||||
|
audioPlaybackSignals.isPlaying() &&
|
||||||
|
audioPlaybackSignals.currentEpisode()?.audioUrl &&
|
||||||
|
useAppStore().state().settings.visualizer.enabled &&
|
||||||
|
frameTimer === null
|
||||||
|
) {
|
||||||
|
startVisualization(
|
||||||
|
audioPlaybackSignals.currentEpisode()!.audioUrl,
|
||||||
|
untrack(audioPlaybackSignals.position),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (frameTimer !== null) {
|
||||||
|
unloadTimer = setTimeout(() => {
|
||||||
|
unloadTimer = null;
|
||||||
|
stopVisualization();
|
||||||
|
}, VISUALIZER_UNLOAD_DELAY_MS);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Seek detection: jump coverage, not pipeline restarts ───────────
|
||||||
|
//
|
||||||
|
// Watches position for significant jumps (>2s = user seek). Decoded
|
||||||
|
// audio at the new position is served instantly with zero action; a
|
||||||
|
// jump into an undecoded hole kicks a background segment decode there
|
||||||
|
// while the last frame holds.
|
||||||
|
|
||||||
|
let lastSyncPosition = 0;
|
||||||
|
let seekDecodeTimer: TimerHandle | undefined;
|
||||||
|
createEffect(
|
||||||
|
on(audioPlaybackSignals.position, (pos) => {
|
||||||
|
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||||
|
lastSyncPosition = pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Math.abs(pos - lastSyncPosition);
|
||||||
|
lastSyncPosition = pos;
|
||||||
|
|
||||||
|
if (delta > 2) {
|
||||||
|
// Debounce: holding the seek key fires a jump per poll tick —
|
||||||
|
// without debounce each one restarts ffmpeg, spamming network
|
||||||
|
// reconnects against the stream's server. Wait for the user to
|
||||||
|
// settle, then decode at the final position.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
const target = pcm; // capture for the timer
|
||||||
|
seekDecodeTimer = setTimeout(() => {
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ── Process-exit teardown ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The pipeline lives in a detached createRoot that is never disposed,
|
||||||
|
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
|
||||||
|
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
|
||||||
|
// handler. This handler runs synchronously on `exit` and kills the
|
||||||
|
// ffmpeg child + destroys the cava plan so they don't outlive the host.
|
||||||
|
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
|
||||||
|
process.on("exit", () => {
|
||||||
|
stopVisualization();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
barData,
|
||||||
|
isLoading,
|
||||||
|
isRunning: () => frameTimer !== null,
|
||||||
|
// inputs
|
||||||
|
setFocused,
|
||||||
|
setBarCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let visualizerStoreInstance: VisualizerStore | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessor for the shared visualizer store. Created once inside a
|
||||||
|
* `createRoot` so its effects are owned by a detached root — not by
|
||||||
|
* whichever component happens to call first (PlayerPage unmounts would
|
||||||
|
* otherwise dispose the pipeline effects with it).
|
||||||
|
*/
|
||||||
|
export function useVisualizer(): VisualizerStore {
|
||||||
|
if (!visualizerStoreInstance) {
|
||||||
|
visualizerStoreInstance = createRoot(() => createVisualizerStore());
|
||||||
|
}
|
||||||
|
return visualizerStoreInstance;
|
||||||
|
}
|
||||||
@@ -98,7 +98,9 @@ export enum DownloadStatus {
|
|||||||
export interface DownloadedEpisode {
|
export interface DownloadedEpisode {
|
||||||
/** Episode ID */
|
/** Episode ID */
|
||||||
episodeId: string
|
episodeId: string
|
||||||
/** Feed ID the episode belongs to */
|
/** Feed ID the episode belongs to. For downloads of shows that aren't
|
||||||
|
* subscribed (search downloads) this is a deterministic synthetic id
|
||||||
|
* ("unsub-<slug>") that also names the file subdirectory. */
|
||||||
feedId: string
|
feedId: string
|
||||||
/** Current download status */
|
/** Current download status */
|
||||||
status: DownloadStatus
|
status: DownloadStatus
|
||||||
@@ -114,4 +116,16 @@ export interface DownloadedEpisode {
|
|||||||
fileSize: number
|
fileSize: number
|
||||||
/** Error message if failed */
|
/** Error message if failed */
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** Episode title, persisted so unsubscribed-show downloads render without
|
||||||
|
* a loaded feed. */
|
||||||
|
episodeTitle?: string
|
||||||
|
/** Audio URL, persisted so queued downloads survive a restart. */
|
||||||
|
audioUrl?: string
|
||||||
|
/** Publication date (ISO), for display of unsubscribed-show downloads. */
|
||||||
|
pubDate?: string
|
||||||
|
/** Show title, kept for downloads whose show isn't subscribed. */
|
||||||
|
podcastTitle?: string
|
||||||
|
/** The show's RSS feed URL, used to re-classify a download as subscribed
|
||||||
|
* once the user subscribes to its show. */
|
||||||
|
podcastFeedUrl?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,10 +33,6 @@ export interface Feed {
|
|||||||
isPinned: boolean
|
isPinned: boolean
|
||||||
/** Feed color for UI */
|
/** Feed color for UI */
|
||||||
color?: string
|
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 */
|
/** Feed item for display in lists */
|
||||||
|
|||||||
@@ -12,8 +12,12 @@ export interface Podcast {
|
|||||||
description: string
|
description: string
|
||||||
/** Cover image URL */
|
/** Cover image URL */
|
||||||
coverUrl?: string
|
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
|
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/creator name */
|
||||||
author?: string
|
author?: string
|
||||||
/** Podcast categories */
|
/** Podcast categories */
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type DesktopTheme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type VisualizerSettings = {
|
export type VisualizerSettings = {
|
||||||
|
/** Master on/off switch for the player's realtime waveform (default: on). */
|
||||||
|
enabled: boolean;
|
||||||
/** Number of frequency bars (8–128, default: 64) */
|
/** Number of frequency bars (8–128, default: 64) */
|
||||||
bars: number;
|
bars: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
@@ -78,22 +80,48 @@ export type AppSettings = {
|
|||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
|
/** Playback volume 0–1 (default: 1 = 100%). */
|
||||||
|
volume: number;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
/** Render the app background transparent (let the terminal's own bg show). */
|
/** Render the app background transparent (let the terminal's own bg show). */
|
||||||
transparentBackground: boolean;
|
transparentBackground: boolean;
|
||||||
|
/** Show the `❯` cursor marker on the focused row of every list (default: off). */
|
||||||
|
showSelectionMarker: boolean;
|
||||||
visualizer: VisualizerSettings;
|
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: auto). */
|
||||||
export type FetchMoreMode = "manual" | "auto";
|
export type FetchMoreMode = "manual" | "auto";
|
||||||
|
|
||||||
|
/** Which shows the auto-download setting applies to (default: all). */
|
||||||
|
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
||||||
|
|
||||||
|
/** How the episode cache (the Feed / My Shows list + the pagination cache)
|
||||||
|
* is bounded: by a rolling date window or by a count of most-recent
|
||||||
|
* episodes (default: date). */
|
||||||
|
export type EpisodeCacheMode = "date" | "count";
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: 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) */
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
autoJumpToPlayer: boolean;
|
autoJumpToPlayer: boolean;
|
||||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
|
||||||
fetchMoreMode: FetchMoreMode;
|
fetchMoreMode: FetchMoreMode;
|
||||||
|
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||||
|
refreshIntervalMinutes: number;
|
||||||
|
/** How the episode list cache is bounded — by date or by count (default: date). */
|
||||||
|
episodeCacheMode: EpisodeCacheMode;
|
||||||
|
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
|
||||||
|
episodeCacheCount: number;
|
||||||
|
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
|
||||||
|
episodeCacheDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
* Podcast source type definitions for PodTUI
|
* Podcast source type definitions for PodTUI
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { Episode } from "./episode"
|
||||||
|
import type { Podcast } from "./podcast"
|
||||||
|
|
||||||
/** Source type enumeration */
|
/** Source type enumeration */
|
||||||
export enum SourceType {
|
export enum SourceType {
|
||||||
/** RSS feed URL */
|
/** RSS feed URL */
|
||||||
@@ -22,8 +25,21 @@ export interface PodcastSource {
|
|||||||
type: SourceType
|
type: SourceType
|
||||||
/** Base URL for the source */
|
/** Base URL for the source */
|
||||||
baseUrl: string
|
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
|
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 */
|
/** Whether source is enabled */
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
/** Source icon/logo URL */
|
/** Source icon/logo URL */
|
||||||
@@ -78,20 +94,39 @@ export enum SearchSortField {
|
|||||||
POPULARITY = "popularity",
|
POPULARITY = "popularity",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Search result */
|
/** What a directory search targets: shows or individual episodes. */
|
||||||
export interface SearchResult {
|
export type SearchScope = "podcast" | "episode"
|
||||||
|
|
||||||
|
/** Fields shared by every search result. */
|
||||||
|
export interface SearchResultBase {
|
||||||
/** Source that returned this result */
|
/** Source that returned this result */
|
||||||
sourceId: string
|
sourceId: string
|
||||||
/** Source display name */
|
/** Source display name */
|
||||||
sourceName?: string
|
sourceName?: string
|
||||||
/** Source type */
|
/** Source type */
|
||||||
sourceType?: SourceType
|
sourceType?: SourceType
|
||||||
/** Podcast data */
|
|
||||||
podcast: import("./podcast").Podcast
|
|
||||||
/** Relevance score (0-1) */
|
/** Relevance score (0-1) */
|
||||||
score?: number
|
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 */
|
/** Default podcast sources */
|
||||||
export const DEFAULT_SOURCES: PodcastSource[] = [
|
export const DEFAULT_SOURCES: PodcastSource[] = [
|
||||||
{
|
{
|
||||||
@@ -106,11 +141,14 @@ export const DEFAULT_SOURCES: PodcastSource[] = [
|
|||||||
allowExplicit: true,
|
allowExplicit: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "rss",
|
id: "podcastindex",
|
||||||
name: "RSS Feed",
|
name: "Podcast Index",
|
||||||
type: SourceType.RSS,
|
type: SourceType.API,
|
||||||
baseUrl: "",
|
baseUrl: "https://api.podcastindex.org/api/1.0/search/byterm",
|
||||||
enabled: true,
|
enabled: false,
|
||||||
description: "Add podcasts via RSS feed URL",
|
description:
|
||||||
|
"Open podcast directory. Fallback when other sources return few results; requires a free API key + secret from podcastindex.org.",
|
||||||
|
language: "en",
|
||||||
|
allowExplicit: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -156,9 +156,6 @@ function init() {
|
|||||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Get all visible options.
|
|
||||||
*/
|
|
||||||
get options() {
|
get options() {
|
||||||
return visibleOptions();
|
return visibleOptions();
|
||||||
},
|
},
|
||||||
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Command palette dialog component.
|
|
||||||
*/
|
|
||||||
function CommandDialog(props: {
|
function CommandDialog(props: {
|
||||||
options: CommandOption[];
|
options: CommandOption[];
|
||||||
suggestedOptions: CommandOption[];
|
suggestedOptions: CommandOption[];
|
||||||
@@ -274,7 +268,7 @@ function CommandDialog(props: {
|
|||||||
{/* Search input */}
|
{/* Search input */}
|
||||||
<box marginBottom={1}>
|
<box marginBottom={1}>
|
||||||
<text fg={theme.textMuted}>{"> "}</text>
|
<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>
|
</box>
|
||||||
|
|
||||||
{/* Command list */}
|
{/* Command list */}
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* Clear all dialogs from the stack.
|
|
||||||
*/
|
|
||||||
clear() {
|
clear() {
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
@@ -113,9 +110,6 @@ function init() {
|
|||||||
emit("dialog.close", {})
|
emit("dialog.close", {})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace all dialogs with a new one.
|
|
||||||
*/
|
|
||||||
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -130,9 +124,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a new dialog onto the stack.
|
|
||||||
*/
|
|
||||||
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -143,9 +134,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Pop the top dialog from the stack.
|
|
||||||
*/
|
|
||||||
pop() {
|
pop() {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* No backups — writes always overwrite.
|
* No backups — writes always overwrite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { loadConfig, updateConfig } from "./config";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -20,6 +21,7 @@ import { DEFAULT_THEME } from "../constants/themes";
|
|||||||
// --- Defaults ---
|
// --- Defaults ---
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
|
enabled: true,
|
||||||
bars: 32,
|
bars: 32,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
@@ -31,16 +33,25 @@ const defaultSettings: AppSettings = {
|
|||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
|
volume: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
transparentBackground: false,
|
transparentBackground: false,
|
||||||
|
showSelectionMarker: false,
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoDownloadCount: 2,
|
||||||
|
autoDownloadScope: "all",
|
||||||
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -51,13 +62,23 @@ const defaultState: AppState = {
|
|||||||
|
|
||||||
// ── App State (config.json) ─────────────────────────────────────────────────
|
// ── App State (config.json) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Load app state from config.json */
|
|
||||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (!cfg || typeof cfg !== "object") return defaultState;
|
if (!cfg || typeof cfg !== "object") return defaultState;
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings, ...cfg.settings },
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
...cfg.settings,
|
||||||
|
// Visualizer is nested: a plain spread would let a config
|
||||||
|
// saved before a field was added (e.g. `enabled`) clobber
|
||||||
|
// the whole object and leave the new field undefined.
|
||||||
|
// Deep-merge so defaults backfill missing nested keys.
|
||||||
|
visualizer: {
|
||||||
|
...defaultVisualizerSettings,
|
||||||
|
...cfg.settings?.visualizer,
|
||||||
|
},
|
||||||
|
},
|
||||||
preferences: { ...defaultPreferences, ...cfg.preferences },
|
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||||
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||||
};
|
};
|
||||||
@@ -66,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save app state to config.json */
|
|
||||||
export function saveAppStateToFile(state: AppState): void {
|
export function saveAppStateToFile(state: AppState): void {
|
||||||
updateConfig({
|
updateConfig({
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
@@ -87,7 +107,6 @@ interface ProgressEntry {
|
|||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load progress map from JSON file */
|
|
||||||
export async function loadProgressFromFile(): Promise<
|
export async function loadProgressFromFile(): Promise<
|
||||||
Record<string, ProgressEntry>
|
Record<string, ProgressEntry>
|
||||||
> {
|
> {
|
||||||
@@ -119,11 +138,41 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search History (separate file — changes on every search) ────────────────
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_FILE = "search-history.json";
|
||||||
|
|
||||||
|
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
|
||||||
|
if (!(await file.exists())) return [];
|
||||||
|
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((item): item is string => typeof item === "string");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSearchHistoryToFile(history: string[]): void {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(SEARCH_HISTORY_FILE),
|
||||||
|
JSON.stringify(history, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||||
|
|
||||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
|
|
||||||
/** Load audio navigation state from JSON file */
|
|
||||||
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||||
@@ -152,3 +201,70 @@ export function saveAudioNavToFile<T>(data: T): void {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Last Player State (separate file — written on every load/stop) ──────────
|
||||||
|
|
||||||
|
const LAST_PLAYER_FILE = "last-player.json";
|
||||||
|
|
||||||
|
/** Which episode is currently loaded in the player, persisted so the next
|
||||||
|
* launch can restore it paused. `episodeId: null` means the player is empty
|
||||||
|
* (e.g. after Stop). */
|
||||||
|
export interface LastPlayerState {
|
||||||
|
episodeId: string | null;
|
||||||
|
timestamp: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the last-loaded-player marker (null when absent or unreadable) */
|
||||||
|
export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(LAST_PLAYER_FILE));
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
return raw as LastPlayerState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized marker-write chain: concurrent writes land in submission
|
||||||
|
* order, and callers can await the last one (tests read the file back
|
||||||
|
* deterministically). Mirrors updateConfig's write serialization. */
|
||||||
|
let lastPlayerWriteChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/** Save the last-loaded-player marker (fire-and-forget) */
|
||||||
|
export function saveLastPlayerToFile(state: LastPlayerState): void {
|
||||||
|
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(LAST_PLAYER_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every marker write submitted so far has landed on disk. */
|
||||||
|
export function waitForLastPlayerWrite(): Promise<void> {
|
||||||
|
return lastPlayerWriteChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous variant for the process-exit teardown. `q` quits through
|
||||||
|
* `process.exit(0)`, which runs exit listeners synchronously — an async
|
||||||
|
* write would never land. */
|
||||||
|
export function saveLastPlayerSync(state: LastPlayerState): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(getConfigDir(), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
getConfigFilePath(LAST_PLAYER_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
471
src/utils/audio-pcm-cache.ts
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Position-indexed PCM cache for visualization.
|
||||||
|
*
|
||||||
|
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
|
||||||
|
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
|
||||||
|
* enough that a remote episode isn't ripped at 84x while mpv is trying to
|
||||||
|
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
|
||||||
|
* The renderer then reads the PCM
|
||||||
|
* window ending at the player's current position with zero sync machinery:
|
||||||
|
* there is no pacing (-readrate), no lead-burst, no decode-head/player
|
||||||
|
* drift math, no ring wrap, and nothing that knows or cares about pause,
|
||||||
|
* resume, seek, or playback speed — those all collapse to "read at a
|
||||||
|
* different position in the cache".
|
||||||
|
*
|
||||||
|
* Pause/resume contract (the failure mode of the old design):
|
||||||
|
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
|
||||||
|
* instantly and resumes the tail decode in the background.
|
||||||
|
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
|
||||||
|
* return 0 — the renderer HOLDS the last rendered frame rather than
|
||||||
|
* freezing on a clamped buffer or decaying into junk bars.
|
||||||
|
*
|
||||||
|
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
|
||||||
|
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
||||||
|
* the same file is deterministic so abutting segments agree.
|
||||||
|
*
|
||||||
|
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
|
||||||
|
* SLIDING WINDOW around the playback position — the decode pass stops
|
||||||
|
* once it is maxAheadSec ahead of the cursor and segments entirely older
|
||||||
|
* than keepBehindSec behind it are dropped (both re-filled/restarted on
|
||||||
|
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
|
||||||
|
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
|
||||||
|
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
|
||||||
|
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
|
||||||
|
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
|
||||||
|
* config.
|
||||||
|
*
|
||||||
|
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
|
||||||
|
* old reader; local files skip them (ffmpeg rejects http-only options for
|
||||||
|
* file inputs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun";
|
||||||
|
|
||||||
|
/** PCM output format constants */
|
||||||
|
export const PCM_SAMPLE_RATE = 22050;
|
||||||
|
const BYTES_PER_SAMPLE = 2; // s16le
|
||||||
|
|
||||||
|
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
||||||
|
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap (seconds) a running decode pass may close on its own before a restart
|
||||||
|
* at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded
|
||||||
|
* audio closes in ~4s — about the cost of a network reconnect + range
|
||||||
|
* request for a fresh ffmpeg pass. Beyond the gap, restart at the target.
|
||||||
|
*/
|
||||||
|
const CLOSE_IN_PLACE_GAP_SEC = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default decode-head budget: the ffmpeg pass pauses once it is this far
|
||||||
|
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
|
||||||
|
* the network pull — the old cache decoded the whole episode at 4x, so a
|
||||||
|
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
|
||||||
|
* remote file even when only the first 10 minutes were listened to. At 4x
|
||||||
|
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
|
||||||
|
* ~10 min of playback.
|
||||||
|
*/
|
||||||
|
const DEFAULT_DECODE_AHEAD_SEC = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default retention behind the cursor: decoded audio entirely older than
|
||||||
|
* this is dropped. Keeps pause/resume and small backward seeks instant
|
||||||
|
* without letting the window grow with playback time.
|
||||||
|
*/
|
||||||
|
const DEFAULT_KEEP_BEHIND_SEC = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonically increasing generation counter.
|
||||||
|
* Each startDecode() increments this; the read loop checks it to know
|
||||||
|
* if it's been superseded and should bail out.
|
||||||
|
*/
|
||||||
|
let globalGeneration = 0;
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
/** Playback seconds where this segment's first sample sits. */
|
||||||
|
baseSec: number;
|
||||||
|
/** Sample buffer; capacity >= written, doubled on overflow. */
|
||||||
|
samples: Int16Array;
|
||||||
|
/** Samples written so far (== decoded length of the segment). */
|
||||||
|
written: number;
|
||||||
|
/** ffmpeg reached stream EOF while writing this segment — nothing more
|
||||||
|
* will ever arrive after its end. */
|
||||||
|
finished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodePcmCacheOptions {
|
||||||
|
/** Audio URL or file path to decode */
|
||||||
|
url: string;
|
||||||
|
/** Sample rate (default: 22050) */
|
||||||
|
sampleRate?: number;
|
||||||
|
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
|
||||||
|
maxAheadSec?: number;
|
||||||
|
/** Retention in seconds behind the cursor (default: 300). */
|
||||||
|
keepBehindSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EpisodePcmCache {
|
||||||
|
private proc: Subprocess | null = null;
|
||||||
|
private segments: Segment[] = [];
|
||||||
|
private generation = 0;
|
||||||
|
private _decoding = false;
|
||||||
|
/** The running pass's segment (base + frontier); null when idle. */
|
||||||
|
private activeSegment: Segment | null = null;
|
||||||
|
readonly url: string;
|
||||||
|
readonly sampleRate: number;
|
||||||
|
/** Sliding-window budgets (see maintainWindow). */
|
||||||
|
readonly maxAheadSec: number;
|
||||||
|
readonly keepBehindSec: number;
|
||||||
|
|
||||||
|
constructor(options: EpisodePcmCacheOptions) {
|
||||||
|
this.url = options.url;
|
||||||
|
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
|
||||||
|
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
|
||||||
|
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an ffmpeg decode pass is currently running. */
|
||||||
|
get decoding(): boolean {
|
||||||
|
return this._decoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base (playback seconds) of the running decode pass; null when idle. */
|
||||||
|
get activeDecodeBaseSec(): number | null {
|
||||||
|
return this._decoding && this.activeSegment
|
||||||
|
? this.activeSegment.baseSec
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End (playback seconds) of the furthest-decoded segment. */
|
||||||
|
get coverageEndSec(): number {
|
||||||
|
let end = 0;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > end) end = segEnd;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the furthest segment finished at stream EOF. */
|
||||||
|
get decodeFinished(): boolean {
|
||||||
|
let maxEnd = -1;
|
||||||
|
let finished = false;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > maxEnd) {
|
||||||
|
maxEnd = segEnd;
|
||||||
|
finished = seg.finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start decoding at `fromSec` of playback time into a fresh segment.
|
||||||
|
* Kills any in-flight pass first; existing segments stay readable.
|
||||||
|
*/
|
||||||
|
startDecode(fromSec: number): void {
|
||||||
|
this.killProcess();
|
||||||
|
|
||||||
|
if (!Bun.which("ffmpeg")) {
|
||||||
|
throw new Error("ffmpeg not found — required for audio visualization");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
|
const segment: Segment = {
|
||||||
|
baseSec: Math.max(0, fromSec),
|
||||||
|
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
|
||||||
|
written: 0,
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
this.segments.push(segment);
|
||||||
|
|
||||||
|
const args = ["ffmpeg", "-loglevel", "quiet"];
|
||||||
|
|
||||||
|
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
|
||||||
|
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
|
||||||
|
// ENTIRE episode from the network within the first minute of playback
|
||||||
|
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
|
||||||
|
// still fills the cache 4x faster than playback consumes it, lands a
|
||||||
|
// 75-min episode in ~19 min of background work, and the burst makes
|
||||||
|
// the first bars available immediately.
|
||||||
|
args.push("-readrate", "4", "-readrate_initial_burst", "8");
|
||||||
|
|
||||||
|
// `-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 (container-level skip is
|
||||||
|
// near-instant for mp3/aac; no pre-position decode burn).
|
||||||
|
if (fromSec > 0) {
|
||||||
|
args.push("-ss", String(Math.max(0, fromSec)));
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
"-i",
|
||||||
|
this.url,
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
String(this.sampleRate),
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
});
|
||||||
|
this._decoding = true;
|
||||||
|
this.activeSegment = segment;
|
||||||
|
this.readLoop(myGeneration, segment);
|
||||||
|
|
||||||
|
this.proc.exited
|
||||||
|
.then((code) => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
// Exit 0 == decoded to stream EOF.
|
||||||
|
if (code === 0) segment.finished = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `sec` of playback time has decoded PCM on hand.
|
||||||
|
*/
|
||||||
|
covers(sec: number): boolean {
|
||||||
|
const idx = Math.round(sec * this.sampleRate);
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (idx >= base && idx < base + seg.written) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure decode is progressing toward `sec`: no-op while a pass is
|
||||||
|
* running or the episode is fully decoded; otherwise resumes the tail
|
||||||
|
* decode from the frontier (when `sec` is inside coverage) or starts a
|
||||||
|
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||||
|
*/
|
||||||
|
ensureDecodeAround(sec: number): void {
|
||||||
|
// Enforce the sliding-window budget first (head cap, prune, refill)
|
||||||
|
// so a resume or seek never leaves stale segments behind the cursor.
|
||||||
|
this.maintainWindow(sec);
|
||||||
|
|
||||||
|
// Data already on hand: nothing needed here; only keep the tail
|
||||||
|
// filling if the decode is idle, the episode is unfinished, AND the
|
||||||
|
// head is inside its budget. A head-capped cache ("we're maxAheadSec
|
||||||
|
// ahead, enough decoded") is NOT a stalled decode — restarting it
|
||||||
|
// here would fight maintainWindow's cap on every resume call.
|
||||||
|
if (this.covers(sec)) {
|
||||||
|
if (this._decoding || this.decodeFinished) return;
|
||||||
|
const end = this.coverageEndSec;
|
||||||
|
if (end >= sec + this.maxAheadSec) return;
|
||||||
|
this.startDecode(end > sec ? end : sec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._decoding && this.activeSegment !== null) {
|
||||||
|
// A decode pass fills monotonically FORWARD from its base. Targets
|
||||||
|
// behind the base are unreachable — restart at the target.
|
||||||
|
if (sec < this.activeSegment.baseSec) {
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Target past the pass's frontier: a SMALL gap closes on its own
|
||||||
|
// (4x pacing covers 15s in ~4s — about what a cold restart costs
|
||||||
|
// to reconnect + range-request a network stream), but a FAR-FORWARD
|
||||||
|
// seek would otherwise mean minutes of frozen bars while the pass
|
||||||
|
// chews through the skipped region. Restart at the target.
|
||||||
|
const frontier =
|
||||||
|
this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate;
|
||||||
|
if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return;
|
||||||
|
}
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window budget for the in-memory cache, driven by the live
|
||||||
|
* playback position. Runs on every read (the render loop is the only
|
||||||
|
* consumer that knows the cursor continuously) and on resume/seek:
|
||||||
|
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
|
||||||
|
* cursor (pauseDecode keeps the decoded data — a plain startDecode
|
||||||
|
* from the frontier refills it later).
|
||||||
|
* - prune: segments entirely keepBehindSec behind the cursor are
|
||||||
|
* dropped. A backward seek past the window restarts a segment there —
|
||||||
|
* the same mechanism as a seek into an undecoded hole, so no new
|
||||||
|
* failure mode.
|
||||||
|
* - topUp: when the cursor has outrun the head, restart the tail decode
|
||||||
|
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
|
||||||
|
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
|
||||||
|
* regardless of episode length.
|
||||||
|
*/
|
||||||
|
private maintainWindow(atSec: number): void {
|
||||||
|
const pos = Math.max(0, atSec);
|
||||||
|
|
||||||
|
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
|
||||||
|
this.pauseDecode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepFromSec = pos - this.keepBehindSec;
|
||||||
|
if (
|
||||||
|
this.segments.some(
|
||||||
|
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.segments = this.segments.filter(
|
||||||
|
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._decoding && !this.decodeFinished) {
|
||||||
|
const end = this.coverageEndSec;
|
||||||
|
if (end < pos + this.maxAheadSec) {
|
||||||
|
this.startDecode(Math.max(end, pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the PCM window ENDING at `atSec` of playback into `out`
|
||||||
|
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
||||||
|
*
|
||||||
|
* Returns the number of samples written: `out.length` on a full hit, 0
|
||||||
|
* when the window is not (fully) decoded yet — the caller HOLDS the
|
||||||
|
* last rendered frame instead of rendering partial/stale data.
|
||||||
|
*/
|
||||||
|
readWindow(out: Float64Array, atSec: number): number {
|
||||||
|
if (out.length === 0) return 0;
|
||||||
|
this.maintainWindow(atSec);
|
||||||
|
const endIdx = Math.round(atSec * this.sampleRate);
|
||||||
|
const startIdx = endIdx - out.length + 1;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (startIdx < base || endIdx >= base + seg.written) continue;
|
||||||
|
const rel = startIdx - base;
|
||||||
|
const src = seg.samples;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = src[rel + i];
|
||||||
|
}
|
||||||
|
return out.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
|
||||||
|
* Resume later serves bars from the cache instantly.
|
||||||
|
*/
|
||||||
|
pauseDecode(): void {
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
this.killProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the decode pass AND drop all cached audio. */
|
||||||
|
stop(): void {
|
||||||
|
this.pauseDecode();
|
||||||
|
this.segments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the ffmpeg process without touching generation/state. */
|
||||||
|
private killProcess(): void {
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal: continuously reads stdout from ffmpeg and appends samples
|
||||||
|
* to the segment at their absolute playback-time offsets. */
|
||||||
|
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
|
||||||
|
const stdout = this.proc?.stdout;
|
||||||
|
if (!stdout || typeof stdout === "number") return;
|
||||||
|
|
||||||
|
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
|
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
|
||||||
|
// trailing byte into the next chunk (dropping it would byte-flip
|
||||||
|
// every sample that follows).
|
||||||
|
let carry: number | null = null;
|
||||||
|
try {
|
||||||
|
while (this.generation === myGeneration) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || this.generation !== myGeneration) break;
|
||||||
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
|
let view: Uint8Array = value;
|
||||||
|
if (carry !== null) {
|
||||||
|
const merged = new Uint8Array(1 + value.byteLength);
|
||||||
|
merged[0] = carry;
|
||||||
|
merged.set(value, 1);
|
||||||
|
view = merged;
|
||||||
|
carry = null;
|
||||||
|
}
|
||||||
|
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
|
||||||
|
carry = view[view.byteLength - 1];
|
||||||
|
view = view.subarray(0, view.byteLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
|
||||||
|
if (sampleCount === 0) continue;
|
||||||
|
|
||||||
|
if (segment.written + sampleCount > segment.samples.length) {
|
||||||
|
const grown = new Int16Array(
|
||||||
|
Math.max(
|
||||||
|
segment.samples.length * 2,
|
||||||
|
segment.written + sampleCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
grown.set(segment.samples.subarray(0, segment.written));
|
||||||
|
segment.samples = grown;
|
||||||
|
}
|
||||||
|
// Int16Array view over the byte buffer: s16le is the platform's
|
||||||
|
// native endianness on every supported target (arm64/x64 are LE).
|
||||||
|
const src = new Int16Array(
|
||||||
|
view.buffer,
|
||||||
|
view.byteOffset,
|
||||||
|
sampleCount,
|
||||||
|
);
|
||||||
|
segment.samples.set(src, segment.written);
|
||||||
|
segment.written += sampleCount;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended or process killed — expected during stop()
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,35 @@
|
|||||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||||
* surfaces "No audio player found" honestly rather than degrading through
|
* surfaces "No audio player found" honestly rather than degrading through
|
||||||
* players that can't change speed/volume without restarting.
|
* players that can't change speed/volume without restarting.
|
||||||
|
*
|
||||||
|
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
|
||||||
|
* for the app's lifetime instead of spawning a fresh player per episode:
|
||||||
|
*
|
||||||
|
* - Play/pause/seek are IPC commands on a persistent Unix-socket
|
||||||
|
* connection — no process spawn, no socket connect/disconnect churn per
|
||||||
|
* poll, no `waitForSocket` on the play path. Measured command latency is
|
||||||
|
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
|
||||||
|
* network stream.
|
||||||
|
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
|
||||||
|
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
|
||||||
|
* `getPauseState()` read a cache instead of round-tripping the socket on
|
||||||
|
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
|
||||||
|
* Now Playing center) arrive as pause property events with zero polling.
|
||||||
|
* - A restored session can PRELOAD: the episode is loaded paused so mpv
|
||||||
|
* fills its demuxer cache ahead of time; the first real play just flips
|
||||||
|
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||||
|
* user's first Play.
|
||||||
|
* - Crash/kill recovery: a dead daemon (process exit or broken IPC socket)
|
||||||
|
* is detected on the next command; play() respawns a fresh daemon and
|
||||||
|
* reloads. resume() cannot unpause a freshly-idle daemon — it throws
|
||||||
|
* PlayerRestartedError so the caller reloads the episode via play().
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, unlinkSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import type { Socket, Subprocess } from "bun";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -30,6 +53,17 @@ export interface AudioState {
|
|||||||
export interface AudioBackend {
|
export interface AudioBackend {
|
||||||
readonly name: BackendName;
|
readonly name: BackendName;
|
||||||
play(url: string, opts?: PlayOptions): Promise<void>;
|
play(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Load the URL paused WITHOUT starting playback, so the player buffers
|
||||||
|
* ahead of the user's first Play (used for boot session restore).
|
||||||
|
* A subsequent play() of the SAME url flips pause off — near-instant.
|
||||||
|
*/
|
||||||
|
preload(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Attach a cover-art image to the currently-loaded file at runtime
|
||||||
|
* (mpv `video-add`). Lets play() start without waiting on art; the
|
||||||
|
* Now Playing artwork pops in when the download lands.
|
||||||
|
*/
|
||||||
pause(): Promise<void>;
|
pause(): Promise<void>;
|
||||||
resume(): Promise<void>;
|
resume(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
@@ -39,6 +73,15 @@ export interface AudioBackend {
|
|||||||
getPosition(): Promise<number>;
|
getPosition(): Promise<number>;
|
||||||
getDuration(): Promise<number>;
|
getDuration(): Promise<number>;
|
||||||
isPlaying(): boolean;
|
isPlaying(): boolean;
|
||||||
|
/** Live pause state: `true` paused, `false` playing, `undefined` when
|
||||||
|
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
|
||||||
|
* which reflects only commands PodTUI sent — this reflects the player's
|
||||||
|
* real state, including pauses initiated OUTSIDE PodTUI (system
|
||||||
|
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
|
||||||
|
* Playing center). */
|
||||||
|
getPauseState(): Promise<boolean | undefined>;
|
||||||
|
/** True while the player process is running (regardless of pause). */
|
||||||
|
isAlive(): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +89,8 @@ export interface PlayOptions {
|
|||||||
startPosition?: number;
|
startPosition?: number;
|
||||||
volume?: number;
|
volume?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
|
mediaTitle?: string;
|
||||||
|
coverArtPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Utilities ────────────────────────────────────────────────────────
|
// ── Utilities ────────────────────────────────────────────────────────
|
||||||
@@ -68,278 +113,576 @@ function which(cmd: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mpvInstance = 0;
|
||||||
function mpvSocketPath(): string {
|
function mpvSocketPath(): string {
|
||||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
// Per-instance, not just per-pid: tests (and backend switching) create
|
||||||
|
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
||||||
|
// every daemon bind the same socket, so later daemons unlink the path
|
||||||
|
// out from under each other.
|
||||||
|
return join(
|
||||||
|
tmpdir(),
|
||||||
|
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
||||||
|
// from mpv are either command responses (`request_id` present — correlated
|
||||||
|
// to the pending promise) or unsolicited traffic (property-change events
|
||||||
|
// from `observe_property`, end-file, ...), dispatched to the event handler.
|
||||||
|
|
||||||
|
interface MpvResponse {
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
request_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MpvEvent {
|
||||||
|
event: string;
|
||||||
|
/** Observation id for property-change events. */
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
data?: unknown;
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MpvEventHandler = (msg: MpvEvent) => void;
|
||||||
|
|
||||||
|
class MpvConnection {
|
||||||
|
private sock: Socket | null = null;
|
||||||
|
private buf = "";
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = new Map<number, (msg: MpvResponse) => void>();
|
||||||
|
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
|
||||||
|
onEvent: MpvEventHandler = () => {};
|
||||||
|
|
||||||
|
async connect(path: string): Promise<void> {
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
|
let settled = false;
|
||||||
|
Bun.connect({
|
||||||
|
unix: path,
|
||||||
|
socket: {
|
||||||
|
open: (socket) => {
|
||||||
|
this.sock = socket;
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: (_socket, data) => this.onData(data),
|
||||||
|
error: (_socket, err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
|
},
|
||||||
|
close: () => this.handleTeardown(),
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onData(data: Uint8Array): void {
|
||||||
|
this.buf += Buffer.from(data).toString();
|
||||||
|
let nl = this.buf.indexOf("\n");
|
||||||
|
while (nl !== -1) {
|
||||||
|
const line = this.buf.slice(0, nl);
|
||||||
|
this.buf = this.buf.slice(nl + 1);
|
||||||
|
nl = this.buf.indexOf("\n");
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue; // skip malformed lines
|
||||||
|
}
|
||||||
|
if (msg.request_id !== undefined) {
|
||||||
|
const resolve = this.pending.get(msg.request_id as number);
|
||||||
|
if (resolve) {
|
||||||
|
this.pending.delete(msg.request_id as number);
|
||||||
|
resolve(msg as MpvResponse);
|
||||||
|
}
|
||||||
|
} else if (typeof msg.event === "string") {
|
||||||
|
const event = msg as unknown as MpvEvent;
|
||||||
|
this.onEvent(event);
|
||||||
|
const waiters = this.eventWaiters.get(event.event);
|
||||||
|
if (waiters) {
|
||||||
|
this.eventWaiters.delete(event.event);
|
||||||
|
for (const w of waiters) w(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Socket died / daemon gone: fail all pending commands so no caller
|
||||||
|
* hangs on a dead connection. */
|
||||||
|
private handleTeardown(): void {
|
||||||
|
for (const resolve of this.pending.values()) {
|
||||||
|
resolve({ error: "connection-lost" });
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.sock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a command and await mpv's response (correlated by request_id).
|
||||||
|
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
|
||||||
|
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
|
||||||
|
const sock = this.sock;
|
||||||
|
if (!sock) return Promise.resolve({ error: "not-connected" });
|
||||||
|
const id = this.nextId++;
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.pending.delete(id)) resolve({ error: "timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(id, (msg) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(msg);
|
||||||
|
});
|
||||||
|
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wait for an mpv event by name. Register BEFORE the command
|
||||||
|
* that triggers it. Resolves null on timeout instead of hanging. */
|
||||||
|
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
|
||||||
|
const list = this.eventWaiters.get(name) ?? [];
|
||||||
|
list.push(resolve);
|
||||||
|
this.eventWaiters.set(name, list);
|
||||||
|
setTimeout(() => {
|
||||||
|
const current = this.eventWaiters.get(name);
|
||||||
|
if (current) {
|
||||||
|
this.eventWaiters.set(
|
||||||
|
name,
|
||||||
|
current.filter((w) => w !== resolve),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.sock?.end();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the Unix socket is open — a live, reachable daemon. */
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.sock !== null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
// One resident daemon for the app's lifetime, controlled over a single
|
||||||
|
// persistent JSON IPC connection with property observation.
|
||||||
|
|
||||||
|
/** Thrown by resume() when the daemon restarted (killed/crashed) and the
|
||||||
|
* previously-loaded file is gone — the fresh daemon is idle, so the
|
||||||
|
* caller must reload the episode via the full play path instead of
|
||||||
|
* unpausing (which would silently do nothing). */
|
||||||
|
export class PlayerRestartedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("mpv restarted; episode must be reloaded");
|
||||||
|
this.name = "PlayerRestartedError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Property observation ids (correlate property-change events). */
|
||||||
|
const OBS_TIME_POS = 1;
|
||||||
|
const OBS_PAUSE = 2;
|
||||||
|
const OBS_DURATION = 3;
|
||||||
|
const OBS_EOF = 4;
|
||||||
|
|
||||||
export class MpvBackend implements AudioBackend {
|
export class MpvBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "mpv";
|
readonly name: BackendName = "mpv";
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
private proc: Subprocess | null = null;
|
||||||
private socketPath = mpvSocketPath();
|
private socketPath = mpvSocketPath();
|
||||||
private _playing = false;
|
private conn: MpvConnection | null = null;
|
||||||
|
/** Guarantee daemon startup runs once (concurrent play/preload). */
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Command intent: what PodTUI asked the player to do.
|
||||||
|
private _intentPlaying = false;
|
||||||
|
/** The file currently loaded via loadfile (null = idle). */
|
||||||
|
private _loadedUrl: string | null = null;
|
||||||
|
/** The current file was loadfile'd paused (preload) and not yet played. */
|
||||||
|
private _loadedPaused = false;
|
||||||
|
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
|
||||||
|
private _ended = false;
|
||||||
|
|
||||||
|
// Observed (player-reported) state, pushed by mpv property-change events.
|
||||||
private _position = 0;
|
private _position = 0;
|
||||||
private _duration = 0;
|
private _duration = 0;
|
||||||
|
/** null until the first pause observation arrives. */
|
||||||
|
private _paused: boolean | null = null;
|
||||||
|
|
||||||
private _volume = 100;
|
private _volume = 100;
|
||||||
private _speed = 1;
|
private _speed = 1;
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
private _exited = false;
|
||||||
|
/** Last playback error reported via end-file reason "error". */
|
||||||
|
private _playbackError: string | null = null;
|
||||||
|
|
||||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||||
await this.stop();
|
|
||||||
|
|
||||||
|
private async ensureDaemon(): Promise<void> {
|
||||||
|
// Healthy = process alive AND its IPC socket open. A socket teardown
|
||||||
|
// with a living process (rare) is just as unusable as a dead one —
|
||||||
|
// every command would fail "not-connected" forever.
|
||||||
|
if (this.proc && !this._exited && this.conn?.isConnected()) return;
|
||||||
|
if (this.startPromise) return this.startPromise;
|
||||||
|
this.startPromise = this.recoverDaemon().finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bring up a usable daemon. If the old process still lives with a dead
|
||||||
|
* IPC connection, kill it so the fresh spawn owns the socket path and
|
||||||
|
* no orphan lingers — and await its exit so its exit handler can't run
|
||||||
|
* after spawnDaemon() and clobber the new daemon's `_exited` flag. */
|
||||||
|
private async recoverDaemon(): Promise<void> {
|
||||||
|
const stale = this.proc;
|
||||||
|
if (stale && !this._exited) {
|
||||||
|
try {
|
||||||
|
stale.kill();
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stale) await stale.exited.catch(() => {});
|
||||||
|
this.conn = null;
|
||||||
|
await this.spawnDaemon();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnDaemon(): Promise<void> {
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
try {
|
try {
|
||||||
if (existsSync(this.socketPath)) {
|
unlinkSync(this.socketPath);
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
this.proc = Bun.spawn(
|
||||||
"mpv",
|
[
|
||||||
"--no-video",
|
"mpv",
|
||||||
"--no-terminal",
|
"--no-video",
|
||||||
"--really-quiet",
|
"--no-terminal",
|
||||||
`--input-ipc-server=${this.socketPath}`,
|
"--really-quiet",
|
||||||
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
||||||
`--speed=${opts?.speed ?? 1}`,
|
// for its whole session and switches episodes via loadfile.
|
||||||
];
|
"--idle=yes",
|
||||||
|
"--keep-open=yes",
|
||||||
if (opts?.startPosition && opts.startPosition > 0) {
|
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
|
||||||
args.push(`--start=${opts.startPosition}`);
|
// to fill while a preload sits paused — measured 45MB pulled
|
||||||
}
|
// within 12s of a boot-restore preload, saturating the link
|
||||||
|
// exactly when everything else is starting up. ~90s forward
|
||||||
args.push(url);
|
// target / 40MiB hard cap is a few MB at podcast bitrates:
|
||||||
|
// plenty for instant resume + stall resilience.
|
||||||
this.proc = Bun.spawn(args, {
|
"--cache-secs=90",
|
||||||
stdout: "ignore",
|
"--demuxer-max-bytes=40MiB",
|
||||||
stderr: "ignore",
|
"--demuxer-max-back-bytes=20MiB",
|
||||||
stdin: "ignore",
|
`--input-ipc-server=${this.socketPath}`,
|
||||||
});
|
],
|
||||||
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
this._playing = true;
|
);
|
||||||
this._position = opts?.startPosition ?? 0;
|
this._exited = false;
|
||||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
|
||||||
this._speed = opts?.speed ?? 1;
|
|
||||||
|
|
||||||
// Wait for socket to appear (mpv creates it async)
|
|
||||||
await this.waitForSocket(2000);
|
|
||||||
|
|
||||||
// Start polling position
|
|
||||||
this.startPolling();
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._playing = false;
|
// Daemon died (crash or external kill): every per-file state
|
||||||
this.stopPolling();
|
// is gone with it. _loadedUrl null forces the next play()
|
||||||
|
// down the full reload path; _position/_volume/_speed are
|
||||||
|
// kept so a recovery reload can carry them over.
|
||||||
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
|
this._paused = null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForSocket(timeoutMs: number): Promise<void> {
|
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < 3000) {
|
||||||
if (existsSync(this.socketPath)) return;
|
if (this._exited) break;
|
||||||
|
if (existsSync(this.socketPath)) break;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conn = new MpvConnection();
|
||||||
|
conn.onEvent = (msg) => this.handleEvent(msg);
|
||||||
|
await conn.connect(this.socketPath);
|
||||||
|
this.conn = conn;
|
||||||
|
|
||||||
|
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
|
||||||
|
// while playing and broadcasts external changes (AirPods pull, OS
|
||||||
|
// media keys) with zero polling from our side.
|
||||||
|
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
|
||||||
|
await this.send(["observe_property", OBS_PAUSE, "pause"]);
|
||||||
|
await this.send(["observe_property", OBS_DURATION, "duration"]);
|
||||||
|
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
|
||||||
|
// sets eof-reached=true (and pauses at the last frame) instead. That
|
||||||
|
// property is the track-end signal; end-file only covers unload/error.
|
||||||
|
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ipc(command: unknown[]): Promise<unknown> {
|
private async send(
|
||||||
try {
|
command: unknown[],
|
||||||
const socket = await Bun.connect({
|
): Promise<MpvResponse> {
|
||||||
unix: this.socketPath,
|
if (!this.conn) return { error: "not-connected" };
|
||||||
socket: {
|
return this.conn.send(command);
|
||||||
data(_socket, data) {
|
}
|
||||||
// Response handling is done by reading below
|
|
||||||
},
|
|
||||||
error(_socket, err) {},
|
|
||||||
close() {},
|
|
||||||
open() {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = JSON.stringify({ command }) + "\n";
|
private handleEvent(msg: MpvEvent): void {
|
||||||
socket.write(payload);
|
if (msg.event === "property-change") {
|
||||||
|
if (msg.id === OBS_TIME_POS) {
|
||||||
// Read response with timeout
|
// `data` is number while playing; unavailable → undefined while
|
||||||
const response = await new Promise<string>((resolve) => {
|
// idle. Keep last known on transient gaps, reset on idle.
|
||||||
let buf = "";
|
if (typeof msg.data === "number") this._position = msg.data;
|
||||||
const reader = setInterval(() => {
|
} else if (msg.id === OBS_PAUSE) {
|
||||||
// Check if we got a response already
|
if (typeof msg.data === "boolean") this._paused = msg.data;
|
||||||
if (buf.includes("\n")) {
|
} else if (msg.id === OBS_DURATION) {
|
||||||
clearInterval(reader);
|
if (typeof msg.data === "number" && msg.data > 0) {
|
||||||
resolve(buf);
|
this._duration = msg.data;
|
||||||
}
|
|
||||||
}, 10);
|
|
||||||
setTimeout(() => {
|
|
||||||
clearInterval(reader);
|
|
||||||
resolve(buf);
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.end();
|
|
||||||
if (response) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(response.split("\n")[0]);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
} else if (msg.id === OBS_EOF) {
|
||||||
|
// Natural end-of-file (or a brand-new load reporting false).
|
||||||
|
this._ended = msg.data === true;
|
||||||
|
if (this._ended) this._intentPlaying = false;
|
||||||
}
|
}
|
||||||
return null;
|
return;
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Send a command over mpv's IPC and get the parsed response data. */
|
if (msg.event === "end-file") {
|
||||||
private async ipcCommand(command: unknown[]): Promise<unknown> {
|
if (msg.reason === "eof") {
|
||||||
try {
|
this._ended = true;
|
||||||
const conn = await Bun.connect({
|
this._intentPlaying = false;
|
||||||
unix: this.socketPath,
|
} else if (msg.reason === "error") {
|
||||||
socket: {
|
this._ended = true;
|
||||||
data() {},
|
this._intentPlaying = false;
|
||||||
error() {},
|
this._playbackError = msg.error ?? "mpv failed to play the stream";
|
||||||
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 {
|
|
||||||
const conn = await Bun.connect({
|
|
||||||
unix: this.socketPath,
|
|
||||||
socket: {
|
|
||||||
data() {},
|
|
||||||
error() {},
|
|
||||||
close() {},
|
|
||||||
open() {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
conn.write(JSON.stringify({ command }) + "\n");
|
|
||||||
// Don't wait, just schedule a close
|
|
||||||
setTimeout(() => {
|
|
||||||
try {
|
|
||||||
conn.end();
|
|
||||||
} catch {}
|
|
||||||
}, 50);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a property value from mpv via IPC */
|
|
||||||
private async getProperty(name: string): Promise<number> {
|
|
||||||
try {
|
|
||||||
return await new Promise<number>((resolve) => {
|
|
||||||
let result = 0;
|
|
||||||
const timeout = setTimeout(() => resolve(result), 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) {
|
|
||||||
socket.write(
|
|
||||||
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}).catch(() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} 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);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.event === "file-loaded") {
|
||||||
|
this._ended = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopPolling(): void {
|
// ── File presentation options ────────────────────────────────────
|
||||||
if (this.pollTimer) {
|
//
|
||||||
clearInterval(this.pollTimer);
|
// force-media-title and cover-art-files are set as global properties
|
||||||
this.pollTimer = null;
|
// BEFORE loadfile (verified: runtime-settable; values containing commas
|
||||||
|
// would corrupt the per-file options string). Numbers (volume, speed,
|
||||||
|
// start, pause) ride as per-file options on loadfile itself so each
|
||||||
|
// loadfile is self-contained.
|
||||||
|
|
||||||
|
private async applyPresentation(opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"force-media-title",
|
||||||
|
opts?.mediaTitle ?? "",
|
||||||
|
]);
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"cover-art-files",
|
||||||
|
opts?.coverArtPath ?? "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
|
||||||
|
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
|
||||||
|
if (opts?.startPosition && opts.startPosition > 0) {
|
||||||
|
parts.push(`start=${Math.max(0, opts.startPosition)}`);
|
||||||
}
|
}
|
||||||
|
const vol = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
|
||||||
|
const speed = opts?.speed ?? 1;
|
||||||
|
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
|
||||||
|
return parts.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
|
||||||
|
* fires the boot preload unawaited, so without serialization a user
|
||||||
|
* pressing Play mid-preload would send loadfile(no-pause) followed by the
|
||||||
|
* in-flight preload's loadfile(pause=yes) — and the stale preload would
|
||||||
|
* pause the file the user just started. The mutex also prevents
|
||||||
|
* presentation options (title/cover) of one episode from interleaving
|
||||||
|
* with the loadfile of another.
|
||||||
|
*/
|
||||||
|
private loadMutex: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.loadMutex.then(fn);
|
||||||
|
this.loadMutex = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFileLocked(
|
||||||
|
url: string,
|
||||||
|
opts: PlayOptions | undefined,
|
||||||
|
paused: boolean,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.applyPresentation(opts);
|
||||||
|
// Paused preload of a mid-episode restore: pass NO start= option and
|
||||||
|
// seek while paused instead. mpv defers --start stream work (open,
|
||||||
|
// header probe, demuxer seek) until playback begins — measured: the
|
||||||
|
// demuxer cache stays EMPTY during the whole preload and the eventual
|
||||||
|
// unpause pays 4.3s. A time-pos seek while paused executes at once,
|
||||||
|
// so the stream opens and buffers during the preload, and the first
|
||||||
|
// real Play is a sub-second unpause.
|
||||||
|
const pausedSeek =
|
||||||
|
paused && opts?.startPosition && opts.startPosition > 0
|
||||||
|
? opts.startPosition
|
||||||
|
: null;
|
||||||
|
const loadOpts =
|
||||||
|
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
|
||||||
|
// Register the file-loaded waiter BEFORE loadfile: the event can
|
||||||
|
// arrive between the command response and listener setup otherwise.
|
||||||
|
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
|
||||||
|
const resp = await this.send([
|
||||||
|
"loadfile",
|
||||||
|
url,
|
||||||
|
"replace",
|
||||||
|
-1,
|
||||||
|
this.loadfileOptions(loadOpts, paused),
|
||||||
|
]);
|
||||||
|
if (resp.error && resp.error !== "success") {
|
||||||
|
throw new Error(`mpv loadfile failed: ${resp.error}`);
|
||||||
|
}
|
||||||
|
if (pausedSeek) {
|
||||||
|
// time-pos sent before file-loaded is silently dropped by mpv
|
||||||
|
// (no file yet) — the preload then parked at 0 and the restore
|
||||||
|
// position was lost. Wait for the open, then seek.
|
||||||
|
await fileLoaded;
|
||||||
|
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||||
|
this._position = pausedSeek;
|
||||||
|
}
|
||||||
|
this._loadedUrl = url;
|
||||||
|
this._loadedPaused = paused;
|
||||||
|
this._ended = false;
|
||||||
|
this._playbackError = null;
|
||||||
|
this._position = opts?.startPosition ?? 0;
|
||||||
|
this._duration = 0;
|
||||||
|
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
this._speed = opts?.speed ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AudioBackend ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
// Mark intent before the mutex: a boot preload queued behind this
|
||||||
|
// play checks it and skips its own stale paused-load.
|
||||||
|
this._intentPlaying = true;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
||||||
|
// mpv has been buffering it since boot, so flipping pause off starts
|
||||||
|
// audio ~instantly. Re-acquire the start position only when it
|
||||||
|
// moved meaningfully since the preload (progress saved meanwhile).
|
||||||
|
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
||||||
|
const target = opts?.startPosition ?? this._position;
|
||||||
|
if (Math.abs(target - this._position) > 2) {
|
||||||
|
await this.send(["set_property", "time-pos", target]);
|
||||||
|
this._position = target;
|
||||||
|
}
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"volume",
|
||||||
|
Math.round((opts?.volume ?? 1) * 100),
|
||||||
|
]);
|
||||||
|
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
||||||
|
if (opts?.mediaTitle) {
|
||||||
|
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
||||||
|
}
|
||||||
|
await this.send(["set_property", "pause", false]);
|
||||||
|
this._loadedPaused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadFileLocked(url, opts, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async preload(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Already loaded (paused park, or actively playing because the
|
||||||
|
// user pressed Play while this preload was queued — either way
|
||||||
|
// the file is in the player and must not be clobbered).
|
||||||
|
if (this._loadedUrl === url) return;
|
||||||
|
await this.loadFileLocked(url, opts, true);
|
||||||
|
this._intentPlaying = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.send(["set_property", "pause", true]);
|
await this.send(["set_property", "pause", true]);
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
await this.send(["set_property", "pause", false]);
|
// The daemon may have died while we were paused (crash/kill): bring
|
||||||
this._playing = true;
|
// a fresh one up. It starts idle — no file to unpause — so throw
|
||||||
|
// PlayerRestartedError and let the caller reload the episode.
|
||||||
|
await this.ensureDaemon();
|
||||||
|
if (!this._loadedUrl) {
|
||||||
|
throw new PlayerRestartedError();
|
||||||
|
}
|
||||||
|
if (this._ended && this._loadedUrl) {
|
||||||
|
// Play pressed on a finished episode: replay from the top.
|
||||||
|
this._ended = false;
|
||||||
|
const url = this._loadedUrl;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
await this.loadFileLocked(
|
||||||
|
url,
|
||||||
|
{ volume: this._volume / 100, speed: this._speed },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this._intentPlaying = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._loadedPaused && this._loadedUrl) {
|
||||||
|
// Deferred first play of a preloaded file.
|
||||||
|
this._loadedPaused = false;
|
||||||
|
}
|
||||||
|
this._ended = false;
|
||||||
|
const resp = await this.send(["set_property", "pause", false]);
|
||||||
|
// Never claim success when the unpause didn't land: a dead/restarted
|
||||||
|
// daemon would otherwise leave the UI "playing" with no audio.
|
||||||
|
if (resp.error && resp.error !== "success") {
|
||||||
|
throw new Error(`mpv resume failed: ${resp.error}`);
|
||||||
|
}
|
||||||
|
this._intentPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
this.stopPolling();
|
if (this.conn && this._loadedUrl) {
|
||||||
if (this.proc) {
|
await this.send(["stop"]);
|
||||||
try {
|
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
}
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
this._position = 0;
|
this._position = 0;
|
||||||
|
this._duration = 0;
|
||||||
// Clean up socket
|
await this.send(["set_property", "cover-art-files", ""]);
|
||||||
try {
|
|
||||||
if (existsSync(this.socketPath)) {
|
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async seek(seconds: number): Promise<void> {
|
async seek(seconds: number): Promise<void> {
|
||||||
@@ -359,22 +702,55 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
|
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
|
||||||
return this._position;
|
return this._position;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDuration(): Promise<number> {
|
async getDuration(): Promise<number> {
|
||||||
if (this._duration <= 0) {
|
|
||||||
this._duration = await this.getProperty("duration");
|
|
||||||
}
|
|
||||||
return this._duration;
|
return this._duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlaying(): boolean {
|
isPlaying(): boolean {
|
||||||
return this._playing;
|
return this._intentPlaying && this.isAlive() && !this._ended;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPauseState(): Promise<boolean | undefined> {
|
||||||
|
if (!this.isAlive() || this._paused === null) return undefined;
|
||||||
|
return this._paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAlive(): boolean {
|
||||||
|
return this.proc !== null && !this._exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last mpv playback failure (end-file reason "error"), if any. */
|
||||||
|
getPlaybackError(): string | null {
|
||||||
|
return this._playbackError;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop();
|
const conn = this.conn;
|
||||||
|
this.conn = null;
|
||||||
|
if (conn) {
|
||||||
|
// Ask nicely, then force: dispose runs inside process-exit
|
||||||
|
// handlers where awaiting is not guaranteed to complete.
|
||||||
|
conn.send(["quit"], 500).catch(() => {});
|
||||||
|
}
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
try {
|
||||||
|
unlinkSync(this.socketPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +759,7 @@ export class MpvBackend implements AudioBackend {
|
|||||||
class NoopBackend implements AudioBackend {
|
class NoopBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "none";
|
readonly name: BackendName = "none";
|
||||||
async play(): Promise<void> {}
|
async play(): Promise<void> {}
|
||||||
|
async preload(): Promise<void> {}
|
||||||
async pause(): Promise<void> {}
|
async pause(): Promise<void> {}
|
||||||
async resume(): Promise<void> {}
|
async resume(): Promise<void> {}
|
||||||
async stop(): Promise<void> {}
|
async stop(): Promise<void> {}
|
||||||
@@ -398,6 +775,13 @@ class NoopBackend implements AudioBackend {
|
|||||||
isPlaying(): boolean {
|
isPlaying(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
async getPauseState(): Promise<boolean | undefined> {
|
||||||
|
// Nothing plays on the no-op backend — never externally paused.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
isAlive(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
src/utils/audio-signals.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* audio-signals — module-level playback state shared by useAudio and
|
||||||
|
* non-component consumers.
|
||||||
|
*
|
||||||
|
* useAudio's playback state is a module-level singleton (signals live at
|
||||||
|
* module scope, every `useAudio()` call shares them). Those signals are
|
||||||
|
* declared here so components that must react to playback WITHOUT mounting
|
||||||
|
* a `useAudio()` owner — the visualizer store — can subscribe directly via
|
||||||
|
* `audioPlaybackSignals` (or the individual accessors/setters), instead of
|
||||||
|
* going through the hook. `useAudio()` re-exports nothing from this module
|
||||||
|
* for callers; it imports the accessors and setters for its own use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
|
import type { BackendName, DetectedPlayer } from "./audio-player";
|
||||||
|
|
||||||
|
export const [isPlaying, setIsPlaying] = createSignal(false);
|
||||||
|
export const [position, setPosition] = createSignal(0);
|
||||||
|
export const [duration, setDuration] = createSignal(0);
|
||||||
|
export const [volume, setVolume] = createSignal(1);
|
||||||
|
export const [speed, setSpeed] = createSignal(1);
|
||||||
|
export const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||||
|
export const [error, setError] = createSignal<string | null>(null);
|
||||||
|
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
export const [availablePlayers, setAvailablePlayers] = createSignal<
|
||||||
|
DetectedPlayer[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playback signals the visualizer pipeline reacts to. `useAudio()`
|
||||||
|
* itself remains the component-facing surface; this is for module-level
|
||||||
|
* consumers that must track playback without a component owner.
|
||||||
|
*/
|
||||||
|
export const audioPlaybackSignals = {
|
||||||
|
isPlaying,
|
||||||
|
position,
|
||||||
|
speed,
|
||||||
|
currentEpisode,
|
||||||
|
} as const;
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-time audio stream reader for visualization.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* This is independent from the actual playback backend — it's a
|
|
||||||
* read-only "tap" on the audio for FFT analysis purposes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** PCM output format constants */
|
|
||||||
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;
|
|
||||||
|
|
||||||
export interface AudioStreamReaderOptions {
|
|
||||||
/** Audio URL or file path to decode */
|
|
||||||
url: string;
|
|
||||||
/** Sample rate (default: 44100) */
|
|
||||||
sampleRate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monotonically increasing generation counter.
|
|
||||||
* Each start() increments this; the read loop checks it to know
|
|
||||||
* if it's been superseded and should bail out.
|
|
||||||
*/
|
|
||||||
let globalGeneration = 0;
|
|
||||||
|
|
||||||
export class AudioStreamReader {
|
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
||||||
private ringBuffer: Float64Array;
|
|
||||||
private writePos = 0;
|
|
||||||
private totalSamplesWritten = 0;
|
|
||||||
private _running = false;
|
|
||||||
private generation = 0;
|
|
||||||
readonly url: string;
|
|
||||||
private sampleRate: number;
|
|
||||||
|
|
||||||
constructor(options: AudioStreamReaderOptions) {
|
|
||||||
this.url = options.url;
|
|
||||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
|
||||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the reader is actively reading samples. */
|
|
||||||
get running(): boolean {
|
|
||||||
return this._running;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total number of samples written since start(). */
|
|
||||||
get samplesWritten(): number {
|
|
||||||
return this.totalSamplesWritten;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the ffmpeg decode process and begin reading PCM data.
|
|
||||||
*
|
|
||||||
* If already running, the previous process is killed first.
|
|
||||||
* Uses a generation counter to guarantee that only one read loop
|
|
||||||
* is ever active — stale loops from killed processes bail out
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
start(startPosition = 0, speed = 1): void {
|
|
||||||
// Always kill the previous process first — no early return on _running
|
|
||||||
this.killProcess();
|
|
||||||
|
|
||||||
if (!Bun.which("ffmpeg")) {
|
|
||||||
throw new Error("ffmpeg not found — required for audio visualization");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment generation so any lingering read loop from a previous
|
|
||||||
// start() will see a mismatch and exit.
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
|
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Seek before input for network efficiency
|
|
||||||
if (startPosition > 0) {
|
|
||||||
args.push("-ss", String(startPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(
|
|
||||||
"-ac",
|
|
||||||
String(CHANNELS),
|
|
||||||
"-ar",
|
|
||||||
String(this.sampleRate),
|
|
||||||
"-f",
|
|
||||||
"s16le",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-",
|
|
||||||
);
|
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "ignore",
|
|
||||||
stdin: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._running = true;
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
|
|
||||||
const myGeneration = this.generation;
|
|
||||||
|
|
||||||
this.readLoop(myGeneration);
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
|
||||||
.then(() => {
|
|
||||||
// Only clear _running if this is still the current generation
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read available samples into the provided buffer.
|
|
||||||
* Returns the number of samples actually copied.
|
|
||||||
*
|
|
||||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
|
||||||
* @returns Number of samples written to `out`.
|
|
||||||
*/
|
|
||||||
read(out: Float64Array): number {
|
|
||||||
const available = Math.min(
|
|
||||||
out.length,
|
|
||||||
this.totalSamplesWritten,
|
|
||||||
this.ringBuffer.length,
|
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the ffmpeg process and clean up.
|
|
||||||
* Safe to call multiple times. Guarantees the read loop exits.
|
|
||||||
*/
|
|
||||||
stop(): void {
|
|
||||||
// Bump generation to invalidate any running read loop
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
this._running = false;
|
|
||||||
this.killProcess();
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restart the reader at a new position and/or speed.
|
|
||||||
*/
|
|
||||||
restart(startPosition = 0, speed = 1): void {
|
|
||||||
this.start(startPosition, speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kill the ffmpeg process without touching generation/state. */
|
|
||||||
private killProcess(): void {
|
|
||||||
if (this.proc) {
|
|
||||||
try {
|
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
|
||||||
private async readLoop(myGeneration: number): Promise<void> {
|
|
||||||
const stdout = this.proc?.stdout;
|
|
||||||
if (!stdout || typeof stdout === "number") return;
|
|
||||||
|
|
||||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
|
||||||
try {
|
|
||||||
while (this.generation === myGeneration) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done || this.generation !== myGeneration) break;
|
|
||||||
if (!value || value.byteLength === 0) continue;
|
|
||||||
|
|
||||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
|
||||||
if (sampleCount === 0) continue;
|
|
||||||
|
|
||||||
const int16View = new Int16Array(
|
|
||||||
value.buffer,
|
|
||||||
value.byteOffset,
|
|
||||||
sampleCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
|
||||||
this.ringBuffer[this.writePos] = int16View[i];
|
|
||||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
|
||||||
this.totalSamplesWritten++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Stream ended or process killed — expected during stop()
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
|||||||
scalingMode: 0,
|
scalingMode: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type CavaLib = {
|
type CavaLib = {
|
||||||
symbols: Record<string, (...args: any[]) => any>;
|
symbols: Record<string, (...args: any[]) => any>;
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -102,7 +101,6 @@ export class CavaCore {
|
|||||||
this.lib = lib;
|
this.lib = lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of frequency bars configured. */
|
|
||||||
get bars(): number {
|
get bars(): number {
|
||||||
return this._bars;
|
return this._bars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* always overwrite — no backup files are created.
|
* always overwrite — no backup files are created.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdir } from "fs/promises";
|
||||||
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -58,21 +59,47 @@ let writeChain: Promise<void> = Promise.resolve();
|
|||||||
|
|
||||||
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||||
|
// Capture the target path AND the patch data eagerly, at call time:
|
||||||
|
// the write chain defers execution, and both the config dir (tests
|
||||||
|
// re-point XDG_CONFIG_HOME between ops) and the state object (stores
|
||||||
|
// mutate in place) move under a pending write. Without the capture, a
|
||||||
|
// queued save writes the LATEST state into whatever directory is
|
||||||
|
// current when the chain drains — a cross-directory misdelivery that
|
||||||
|
// was the source of a flaky "enabled:false survives reload" test.
|
||||||
|
const configPath = getConfigFilePath(CONFIG_FILE);
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(patch)) as Partial<PodTuiConfig>;
|
||||||
writeChain = writeChain.then(async () => {
|
writeChain = writeChain.then(async () => {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await migrateOnce();
|
||||||
const current = await loadConfig();
|
await mkdir(configDir, { recursive: true });
|
||||||
const next = { ...current, ...patch };
|
let current: PodTuiConfig = {};
|
||||||
await Bun.write(
|
try {
|
||||||
getConfigFilePath(CONFIG_FILE),
|
const file = Bun.file(configPath);
|
||||||
JSON.stringify(next, null, 2),
|
if (await file.exists()) {
|
||||||
);
|
const raw = await file.json();
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
current = raw as PodTuiConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* unreadable existing config — treat as empty */
|
||||||
|
}
|
||||||
|
const next = { ...current, ...snapshot };
|
||||||
|
await Bun.write(configPath, JSON.stringify(next, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Fire-and-forget persistence — silently ignore write errors.
|
// Fire-and-forget persistence — silently ignore write errors.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve once every queued config write has flushed. Tests await this to
|
||||||
|
* observe the serialized result of pending saveFeedsToFile/updateConfig
|
||||||
|
* calls before asserting on config.json. */
|
||||||
|
export function whenConfigIdle(): Promise<void> {
|
||||||
|
return writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
/** Guards so migration runs exactly once per process. */
|
/** Guards so migration runs exactly once per process. */
|
||||||
let migrationDone = false;
|
let migrationDone = false;
|
||||||
let migrationPromise: Promise<void> | null = null;
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|||||||
141
src/utils/cover-art.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* 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 artwork from `--cover-art-files` (loads
|
||||||
|
* the file as an albumart video track), so the podcast cover must exist on
|
||||||
|
* disk before (cover-art-files) or right after (video-add) playback starts.
|
||||||
|
*
|
||||||
|
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
|
||||||
|
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
|
||||||
|
* download happens ONCE per feed — subsequent plays (including the
|
||||||
|
* boot-restored episode) hit the disk cache and never wait on the network.
|
||||||
|
* The play path must never block on art: `cachedCoverPath` is the sync
|
||||||
|
* fast path; `fetchCoverArt` is awaited only by flows where latency does
|
||||||
|
* not matter (CLI play) or fired in the background with the result
|
||||||
|
* applied to a live mpv via `video-add`.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
/** Resolved once per process; null when no home directory is detectable. */
|
||||||
|
let cacheDir: string | null | undefined;
|
||||||
|
|
||||||
|
function coversDir(): string | null {
|
||||||
|
if (cacheDir !== undefined) return cacheDir;
|
||||||
|
let dir: string | null = null;
|
||||||
|
try {
|
||||||
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||||
|
if (home) {
|
||||||
|
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dir = null;
|
||||||
|
}
|
||||||
|
cacheDir = dir;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePathFor(url: string): string | null {
|
||||||
|
const dir = coversDir();
|
||||||
|
if (!dir) return null;
|
||||||
|
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync fast path: the cached cover file for `url`, or null when it has not
|
||||||
|
* been downloaded yet. This is what keeps cover art off the play() critical
|
||||||
|
* path — a cache hit costs one stat() and a miss simply plays without art
|
||||||
|
* (or applies it late via video-add).
|
||||||
|
*/
|
||||||
|
export function cachedCoverPath(url: string): string | null {
|
||||||
|
const path = cachePathFor(url);
|
||||||
|
if (!path) return null;
|
||||||
|
try {
|
||||||
|
return existsSync(path) && statSync(path).size > 0 ? path : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-flight downloads keyed by URL — a burst of plays of the same show
|
||||||
|
* shares one curl instead of racing ephemeral files. */
|
||||||
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the cover for `url`, returns its cache path. Cache hits return
|
||||||
|
* immediately. Downloads are single-flight per URL and time-bounded (8s);
|
||||||
|
* failure resolves null and retries on the next call. The file is written
|
||||||
|
* to a temp name and renamed into place so a killed process can never
|
||||||
|
* poison the cache with a truncated file.
|
||||||
|
*/
|
||||||
|
export function fetchCoverArt(url: string): Promise<string | null> {
|
||||||
|
const cached = cachedCoverPath(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
const dest = cachePathFor(url);
|
||||||
|
if (!dest) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const pending = inflight.get(url);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string | null> => {
|
||||||
|
const staging = `${dest}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<string | null>();
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
|
"curl",
|
||||||
|
"-sS",
|
||||||
|
"--fail",
|
||||||
|
"-m",
|
||||||
|
"8",
|
||||||
|
"--max-filesize",
|
||||||
|
"2097152",
|
||||||
|
"-o",
|
||||||
|
staging,
|
||||||
|
url,
|
||||||
|
],
|
||||||
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
|
);
|
||||||
|
proc.exited
|
||||||
|
.then((code) => {
|
||||||
|
if (code !== 0) return resolve(null);
|
||||||
|
try {
|
||||||
|
if (statSync(staging).size <= 0) return resolve(null);
|
||||||
|
renameSync(staging, dest);
|
||||||
|
resolve(dest);
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => resolve(null));
|
||||||
|
setTimeout(() => resolve(null), 8000);
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(url);
|
||||||
|
// Best-effort staging cleanup (no-op after a successful rename).
|
||||||
|
try {
|
||||||
|
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(url, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget warm-up used by the boot/restore path. */
|
||||||
|
export function prefetchCoverArt(url: string): void {
|
||||||
|
fetchCoverArt(url).catch(() => {});
|
||||||
|
}
|
||||||