Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0bfa41028 | |||
| 25307f83e9 | |||
| db285530b6 | |||
| e1cdd6b2a5 | |||
| 2abdbaa4e9 | |||
| 1d06156b8b | |||
| c63e9e1b9c | |||
| 0facfff51b | |||
| 3ef19f80b8 | |||
| 13a31aabdc | |||
| 8dbdebfd30 | |||
| 529817323d | |||
| ace883b505 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -95,4 +95,6 @@ jobs:
|
|||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
files: artifacts/**/*.tar.gz
|
files: |
|
||||||
|
artifacts/**/*.tar.gz
|
||||||
|
LICENSE
|
||||||
|
|||||||
@@ -132,8 +132,12 @@ Releases are built and published from **tags**
|
|||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|
||||||
1. Bump `VERSION` in `src/index.tsx` (e.g. `0.1.0` → `0.2.0`). Commit and push.
|
1. Run `scripts/release-tag.sh` (interactive: pick major/minor/patch/custom,
|
||||||
2. Tag and push:
|
confirms the plan, bumps `VERSION` in `src/index.tsx`, commits, tags
|
||||||
|
`vX.Y.Z`, and pushes branch + tag to every remote). If the version bump is
|
||||||
|
already committed but the tag is missing, it offers a tag-only path.
|
||||||
|
`--dry-run` prints the plan without doing anything.
|
||||||
|
2. Equivalent manual commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
|
git tag -a v0.2.0 -m 'PodTUI v0.2.0' && git push gh v0.2.0
|
||||||
@@ -155,18 +159,25 @@ Releases are built and published from **tags**
|
|||||||
|
|
||||||
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
4. A release is auto-created with all 4 tarballs attached. `brew` never
|
||||||
sees the new version: the **tap self-updates**: the
|
sees the new version: the **tap self-updates**: the
|
||||||
`mikefreno/homebrew-podtui` repo has a scheduled workflow (hourly) that
|
`mikefreno/homebrew-tap` repo has a scheduled workflow (hourly) that
|
||||||
polls GitHub releases, and when a new tag appears, rewrites
|
polls GitHub releases, and when a new tag appears, rewrites
|
||||||
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
`Formula/podtui.rb` (URLs + arm64/x64 `sha256`) and pushes it — no
|
||||||
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
secrets. See `scripts/sync-formula.sh` in that repo for the logic. Local
|
||||||
test: `brew install mikefreno/podtui/podtui`.
|
test: `brew install mikefreno/tap/podtui`.
|
||||||
|
5. **AUR packaging** (`packaging/aur/PKGBUILD`): the `podtui-bin` package is
|
||||||
|
staged, not yet published (AUR account registrations are closed; see the
|
||||||
|
README note in section 3). On each release, keep the AUR sources in sync
|
||||||
|
with the new tag: bump `pkgver`, recompute the two tarball `sha256sums`
|
||||||
|
entries, keep the `LICENSE` asset source (the workflow above uploads
|
||||||
|
`LICENSE` to every release), and regenerate `packaging/aur/.SRCINFO` with
|
||||||
|
`bash packaging/aur/gen-srcinfo.sh`.
|
||||||
|
|
||||||
### Manual fallback
|
### Manual fallback
|
||||||
|
|
||||||
If you ever need to sync the tap by hand (or before the hourly job runs):
|
If you ever need to sync the tap by hand (or before the hourly job runs):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd <clone of mikefreno/homebrew-podtui>
|
cd <clone of mikefreno/homebrew-tap>
|
||||||
./scripts/sync-formula.sh 0.2.0
|
./scripts/sync-formula.sh 0.2.0
|
||||||
git commit -am 'podtui 0.2.0' && git push
|
git commit -am 'podtui 0.2.0' && git push
|
||||||
```
|
```
|
||||||
|
|||||||
30
LICENSE
Normal file
30
LICENSE
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Michael Freno
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
This project bundles third-party components under their own licenses:
|
||||||
|
|
||||||
|
- **cava** (karlstav/cava, vendored under `cava/`) — MIT,
|
||||||
|
Copyright (c) 2015 Karl Stavestrand. See `cava/LICENSE-cava.txt`.
|
||||||
|
- **Bun runtime** (embedded in the standalone binary) — MIT.
|
||||||
|
- **OpenTUI** (`@opentui/core`) — MIT.
|
||||||
32
README.md
32
README.md
@@ -47,7 +47,7 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
|||||||
### 1. Homebrew (macOS)
|
### 1. Homebrew (macOS)
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
brew install mikefreno/podtui/podtui # requires mpv: brew install mpv
|
brew install mikefreno/tap/podtui # requires mpv: brew install mpv
|
||||||
```
|
```
|
||||||
|
|
||||||
> The formula installs the standalone binary plus its two native libraries
|
> The formula installs the standalone binary plus its two native libraries
|
||||||
@@ -61,10 +61,11 @@ Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
|||||||
put `podtui` on your `PATH`:
|
put `podtui` on your `PATH`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL -o podtui.tar.gz \
|
curl -sS -o /tmp/podtui.tar.gz \
|
||||||
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
https://github.com/mikefreno/podtui/releases/latest/download/podtui-linux-x64.tar.gz
|
||||||
tar -xzf podtui.tar.gz
|
sudo mkdir -p /opt/podtui
|
||||||
sudo install -m755 podtui /usr/local/bin/podtui
|
sudo tar -xzf /tmp/podtui.tar.gz -C /opt/podtui --strip-components=1
|
||||||
|
sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
> The tarball contains `podtui` plus `libopentui.<ext>` and
|
||||||
@@ -79,11 +80,26 @@ sudo install -m755 podtui /usr/local/bin/podtui
|
|||||||
### 3. Arch Linux (AUR)
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S podtui
|
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
||||||
|
yay -S podtui-bin # once published
|
||||||
```
|
```
|
||||||
|
|
||||||
or build from the PKGBUILD (`podtui-bin`). The package installs the released
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
||||||
binary and its sibling libraries.
|
package (PKGBUILD lives in `packaging/aur/`) installs the released binary and
|
||||||
|
its two FFI sibling libraries into `/usr/lib/podtui/` with a `/usr/bin/podtui`
|
||||||
|
symlink, and pulls in `mpv` (the sole audio backend) as a dependency.
|
||||||
|
|
||||||
|
> **Not yet on the AUR.** The `podtui-bin` PKGBUILD and `.SRCINFO` are ready
|
||||||
|
> in `packaging/aur/` and can be built locally today:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> cd packaging/aur && makepkg -si
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Publishing is on hold until [AUR account registrations](https://aur.archlinux.org)
|
||||||
|
> reopen (suspended while the AUR team works on suspicious-package
|
||||||
|
> moderation). Once a key can be registered, push `PKGBUILD` + `.SRCINFO`
|
||||||
|
> with `git push ssh://aur@aur.archlinux.org/podtui-bin` and update this note.
|
||||||
|
|
||||||
### 4. From source
|
### 4. From source
|
||||||
|
|
||||||
@@ -214,7 +230,7 @@ is no cross-compilation.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TBD — choose and document a license before first release.
|
MIT. See [LICENSE](LICENSE).
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 3,
|
||||||
"podcasts": [
|
"podcasts": [
|
||||||
{
|
{
|
||||||
"id": "discover-daily",
|
"id": "discover-daily",
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
"description": "This is how the news should sound. Twenty minutes a day, five days a week, hosted by Michael Barbaro and Sabrina Tavernise. Powered by New York Times journalism.",
|
||||||
"feedUrl": "http://rss.art19.com/the-daily",
|
"feedUrl": "http://rss.art19.com/the-daily",
|
||||||
"author": "The New York Times",
|
"author": "The New York Times",
|
||||||
"categories": ["News", "Politics"]
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-up-first",
|
"id": "discover-up-first",
|
||||||
@@ -15,7 +17,9 @@
|
|||||||
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
"description": "NPR's Up First covers the three biggest stories of the day, with reporting and analysis from NPR News — in 10 minutes.",
|
||||||
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510318/podcast.xml",
|
||||||
"author": "NPR",
|
"author": "NPR",
|
||||||
"categories": ["News"]
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-npr-politics",
|
"id": "discover-npr-politics",
|
||||||
@@ -23,23 +27,29 @@
|
|||||||
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
"description": "Where everyone gathers for the political conversation of the day. NPR's political reporters talk through the biggest news of the week.",
|
||||||
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510310/podcast.xml",
|
||||||
"author": "NPR",
|
"author": "NPR",
|
||||||
"categories": ["News", "Politics"]
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-code-switch",
|
"id": "discover-ben-shapiro",
|
||||||
"title": "Code Switch",
|
"title": "The Ben Shapiro Show",
|
||||||
"description": "Race. In your face. A podcast from NPR that fearlessly explores how race impacts every part of society — from politics to pop culture.",
|
"description": "Ben Shapiro delivers unapologetically conservative commentary on the biggest news stories of the day, blending sharp analysis with his trademark fact-based approach.",
|
||||||
"feedUrl": "https://feeds.npr.org/510312/podcast.xml",
|
"feedUrl": "https://feeds.megaphone.fm/benshow",
|
||||||
"author": "NPR",
|
"author": "The Daily Wire",
|
||||||
"categories": ["News", "Culture", "Politics"]
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-rough-translation",
|
"id": "discover-advisory-opinions",
|
||||||
"title": "Rough Translation",
|
"title": "Advisory Opinions",
|
||||||
"description": "How are the things we're talking about covered in the rest of the world? NPR's Rough Translation takes you to far-off places and shows you the unexpected.",
|
"description": "Host Sarah Isgur and permanent guest David French have twice-weekly conversations about the law, the courts, their collision with politics, and why it all matters — from The Dispatch.",
|
||||||
"feedUrl": "https://feeds.npr.org/510324/podcast.xml",
|
"feedUrl": "https://feeds.megaphone.fm/DISPME4573820108",
|
||||||
"author": "NPR",
|
"author": "The Dispatch",
|
||||||
"categories": ["News", "Culture"]
|
"categories": [
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-crime-junkie",
|
"id": "discover-crime-junkie",
|
||||||
@@ -47,7 +57,9 @@
|
|||||||
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
"description": "Crime Junkie satisfies true crime cravings with host Ashley Flowers' obsessed yet accessible approach to real-life mysteries — from unsolved murders to missing persons.",
|
||||||
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
"feedUrl": "https://feeds.simplecast.com/qm_9xx0g",
|
||||||
"author": "audiochuck",
|
"author": "audiochuck",
|
||||||
"categories": ["True Crime"]
|
"categories": [
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-serial",
|
"id": "discover-serial",
|
||||||
@@ -55,7 +67,10 @@
|
|||||||
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
"description": "Serial Productions makes narrative podcasts that have transformed the medium. From the team that brought you the original Serial, one of the most influential podcasts of all time.",
|
||||||
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
"feedUrl": "https://feeds.simplecast.com/PpzWFGhg",
|
||||||
"author": "Serial Productions & The New York Times",
|
"author": "Serial Productions & The New York Times",
|
||||||
"categories": ["True Crime", "Storytelling"]
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-intelligence-matters",
|
"id": "discover-intelligence-matters",
|
||||||
@@ -63,7 +78,10 @@
|
|||||||
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
"description": "A deep dive into national security, intelligence, and foreign policy with top former officials and experts hosted by CBS News senior correspondent.",
|
||||||
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
"feedUrl": "https://rss.art19.com/intelligence-matters",
|
||||||
"author": "CBS News",
|
"author": "CBS News",
|
||||||
"categories": ["True Crime", "Politics", "News"]
|
"categories": [
|
||||||
|
"True Crime",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-smartless",
|
"id": "discover-smartless",
|
||||||
@@ -71,7 +89,10 @@
|
|||||||
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
"description": "Jason Bateman, Sean Hayes, and Will Arnett bring you unscripted conversations with surprise celebrity guests — each episode one host reveals the guest to the others.",
|
||||||
"feedUrl": "https://rss.art19.com/smartless",
|
"feedUrl": "https://rss.art19.com/smartless",
|
||||||
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
"author": "Jason Bateman, Sean Hayes, Will Arnett",
|
||||||
"categories": ["Comedy", "Entertainment"]
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-this-past-weekend",
|
"id": "discover-this-past-weekend",
|
||||||
@@ -79,7 +100,9 @@
|
|||||||
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
"description": "Comedian Theo Von's uniquely southern perspective blends heartfelt vulnerability and offbeat humor in conversations ranging from celebrity interviews to solo musings.",
|
||||||
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
"feedUrl": "https://feeds.megaphone.fm/thispastweekend",
|
||||||
"author": "Theo Von",
|
"author": "Theo Von",
|
||||||
"categories": ["Comedy"]
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-joe-rogan",
|
"id": "discover-joe-rogan",
|
||||||
@@ -87,7 +110,10 @@
|
|||||||
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
"description": "The official podcast of comedian Joe Rogan. Long-form conversations with guests from every corner of culture, science, comedy, and beyond.",
|
||||||
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
"feedUrl": "https://feeds.megaphone.fm/GLT1412515089",
|
||||||
"author": "Joe Rogan",
|
"author": "Joe Rogan",
|
||||||
"categories": ["Comedy", "Entertainment"]
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-comedy-bang-bang",
|
"id": "discover-comedy-bang-bang",
|
||||||
@@ -95,7 +121,9 @@
|
|||||||
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
"description": "A weekly comedy podcast hosted by Scott Aukerman featuring improv, games, and hilarious conversations with celebrities and the world's best comedians.",
|
||||||
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
"feedUrl": "https://rss.art19.com/comedy-bang-bang",
|
||||||
"author": "Earwolf",
|
"author": "Earwolf",
|
||||||
"categories": ["Comedy"]
|
"categories": [
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-office-ladies",
|
"id": "discover-office-ladies",
|
||||||
@@ -103,7 +131,10 @@
|
|||||||
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
"description": "The Office stars Jenna Fischer and Angela Kinsey break down each episode of The Office with behind-the-scenes stories, fun facts, and fan Q&A.",
|
||||||
"feedUrl": "https://rss.art19.com/office-ladies",
|
"feedUrl": "https://rss.art19.com/office-ladies",
|
||||||
"author": "Earwolf",
|
"author": "Earwolf",
|
||||||
"categories": ["Comedy", "Entertainment"]
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-how-did-this-get-made",
|
"id": "discover-how-did-this-get-made",
|
||||||
@@ -111,7 +142,10 @@
|
|||||||
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
"description": "Comedians Paul Scheer, June Diane Raphael, and Jason Mantzoukas break down the very best of the worst films ever made — blockbuster flops, cult classics, and Nic Cage movies.",
|
||||||
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
"feedUrl": "https://rss.art19.com/how-did-this-get-made",
|
||||||
"author": "Earwolf",
|
"author": "Earwolf",
|
||||||
"categories": ["Comedy", "Film"]
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"Film"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-wait-wait",
|
"id": "discover-wait-wait",
|
||||||
@@ -119,7 +153,10 @@
|
|||||||
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
"description": "NPR's weekly news quiz show. Test your knowledge against the week's biggest news, with panelists and celebrity guests competing in hilarious trivia.",
|
||||||
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/344098539/podcast.xml",
|
||||||
"author": "NPR",
|
"author": "NPR",
|
||||||
"categories": ["Comedy", "News"]
|
"categories": [
|
||||||
|
"Comedy",
|
||||||
|
"News & Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-new-heights",
|
"id": "discover-new-heights",
|
||||||
@@ -127,7 +164,10 @@
|
|||||||
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
"description": "Football's funniest family duo — Super Bowl champions Jason and Travis Kelce — drop weekly insights about the NFL and share inside perspectives on sports headlines.",
|
||||||
"feedUrl": "https://rss.art19.com/new-heights",
|
"feedUrl": "https://rss.art19.com/new-heights",
|
||||||
"author": "Jason & Travis Kelce",
|
"author": "Jason & Travis Kelce",
|
||||||
"categories": ["Sports", "Comedy"]
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-bill-simmons",
|
"id": "discover-bill-simmons",
|
||||||
@@ -135,7 +175,10 @@
|
|||||||
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
"description": "Bill Simmons and his cadre of opinionated guests discuss sports, pop culture, and everything in between on The Ringer's flagship podcast.",
|
||||||
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
"feedUrl": "https://rss.art19.com/the-bill-simmons-podcast",
|
||||||
"author": "The Ringer",
|
"author": "The Ringer",
|
||||||
"categories": ["Sports", "Entertainment"]
|
"categories": [
|
||||||
|
"Sports",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-acquired",
|
"id": "discover-acquired",
|
||||||
@@ -143,7 +186,10 @@
|
|||||||
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
"description": "Acquired tells the stories and strategies of the world's greatest companies. Each episode is a deep dive into a single company's history and the playbooks behind its success.",
|
||||||
"feedUrl": "https://feeds.transistor.fm/acquired",
|
"feedUrl": "https://feeds.transistor.fm/acquired",
|
||||||
"author": "Ben Gilbert & David Rosenthal",
|
"author": "Ben Gilbert & David Rosenthal",
|
||||||
"categories": ["Business", "Technology"]
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-all-in",
|
"id": "discover-all-in",
|
||||||
@@ -151,7 +197,11 @@
|
|||||||
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
"description": "Four tech industry veterans share their unfiltered perspectives on technology, economics, politics, and culture. Insightful, opinionated, and occasionally controversial.",
|
||||||
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
"feedUrl": "https://allinchamathjason.libsyn.com/rss",
|
||||||
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
"author": "Chamath Palihapitiya, Jason Calacanis, David Sacks & David Friedberg",
|
||||||
"categories": ["Business", "Technology", "Politics"]
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-planet-money",
|
"id": "discover-planet-money",
|
||||||
@@ -159,7 +209,10 @@
|
|||||||
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
"description": "The economy explained. NPR's Planet Money breaks down the economy with creative storytelling that makes sense of a complicated, ever-changing world.",
|
||||||
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510289/podcast.xml",
|
||||||
"author": "NPR",
|
"author": "NPR",
|
||||||
"categories": ["Business", "Economics"]
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-how-i-built-this",
|
"id": "discover-how-i-built-this",
|
||||||
@@ -167,7 +220,10 @@
|
|||||||
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
"description": "Guy Raz interviews the world's best-known entrepreneurs to learn how they built their iconic brands. A master-class on innovation, creativity, and leadership.",
|
||||||
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510313/podcast.xml",
|
||||||
"author": "NPR / Wondery",
|
"author": "NPR / Wondery",
|
||||||
"categories": ["Business", "Technology"]
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-freakonomics",
|
"id": "discover-freakonomics",
|
||||||
@@ -175,7 +231,11 @@
|
|||||||
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
"description": "Discover the hidden side of everything with Stephen Dubner. Each episode explores the riddles of everyday life using the tools of economics.",
|
||||||
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
"feedUrl": "https://feeds.feedburner.com/freakonomicsradio",
|
||||||
"author": "Stephen J. Dubner",
|
"author": "Stephen J. Dubner",
|
||||||
"categories": ["Business", "Economics", "Society"]
|
"categories": [
|
||||||
|
"Business",
|
||||||
|
"Economics",
|
||||||
|
"Society"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-darknet-diaries",
|
"id": "discover-darknet-diaries",
|
||||||
@@ -183,7 +243,10 @@
|
|||||||
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
"description": "True stories from the dark side of the Internet. Host Jack Rhysider investigates hacks, data breaches, cybercrime, and digital espionage with rigorous journalism and captivating storytelling.",
|
||||||
"feedUrl": "https://podcast.darknetdiaries.com/",
|
"feedUrl": "https://podcast.darknetdiaries.com/",
|
||||||
"author": "Jack Rhysider",
|
"author": "Jack Rhysider",
|
||||||
"categories": ["Technology", "True Crime"]
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"True Crime"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-changelog",
|
"id": "discover-changelog",
|
||||||
@@ -191,7 +254,10 @@
|
|||||||
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
"description": "Software's best weekly news brief, deep technical interviews, and talk show. Conversations with the hackers, leaders, and innovators of the open source and software world.",
|
||||||
"feedUrl": "https://changelog.fm/rss",
|
"feedUrl": "https://changelog.fm/rss",
|
||||||
"author": "Changelog Media",
|
"author": "Changelog Media",
|
||||||
"categories": ["Technology", "Software Engineering"]
|
"categories": [
|
||||||
|
"Technology",
|
||||||
|
"Software Engineering"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-twit",
|
"id": "discover-twit",
|
||||||
@@ -199,7 +265,9 @@
|
|||||||
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
"description": "Your first podcast of the week, the last word in tech. Leo Laporte and a rotating panel of tech experts discuss the week's biggest tech news.",
|
||||||
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
"feedUrl": "https://feeds.twit.tv/twit.xml",
|
||||||
"author": "TWiT",
|
"author": "TWiT",
|
||||||
"categories": ["Technology"]
|
"categories": [
|
||||||
|
"Technology"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-radiolab",
|
"id": "discover-radiolab",
|
||||||
@@ -207,7 +275,10 @@
|
|||||||
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
"description": "Radiolab is on a curiosity bender. Each episode weaves together science, legal history, and deeply human stories with innovative sound design. Hosted by Lulu Miller and Latif Nasser.",
|
||||||
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
"feedUrl": "http://feeds.wnyc.org/radiolab",
|
||||||
"author": "WNYC Studios",
|
"author": "WNYC Studios",
|
||||||
"categories": ["Science", "Storytelling"]
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-huberman-lab",
|
"id": "discover-huberman-lab",
|
||||||
@@ -215,7 +286,10 @@
|
|||||||
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
"description": "Regularly ranked as the #1 health podcast in the world. Dr. Andrew Huberman discusses science and science-based tools for everyday life: sleep, focus, fitness, and performance.",
|
||||||
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
"feedUrl": "https://feeds.megaphone.fm/hubermanlab",
|
||||||
"author": "Scicomm Media",
|
"author": "Scicomm Media",
|
||||||
"categories": ["Health", "Science"]
|
"categories": [
|
||||||
|
"Health",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-skeptics-guide",
|
"id": "discover-skeptics-guide",
|
||||||
@@ -223,7 +297,10 @@
|
|||||||
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
"description": "Your guide to reality. A weekly science and critical thinking podcast that explores myths, conspiracies, pseudoscience, and the latest scientific discoveries — with a skeptical eye.",
|
||||||
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
"feedUrl": "https://feeds.feedburner.com/TheSkepticsGuideToTheUniverse",
|
||||||
"author": "Steven Novella",
|
"author": "Steven Novella",
|
||||||
"categories": ["Science", "Philosophy"]
|
"categories": [
|
||||||
|
"Science",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-throughline",
|
"id": "discover-throughline",
|
||||||
@@ -231,7 +308,10 @@
|
|||||||
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
"description": "The past is never past. NPR's Throughline travels beyond the headlines to answer the question 'How did we get here?' Each episode brings history to life from ancient civilizations to forgotten figures.",
|
||||||
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510333/podcast.xml",
|
||||||
"author": "NPR",
|
"author": "NPR",
|
||||||
"categories": ["History", "Politics"]
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Politics"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-hardcore-history",
|
"id": "discover-hardcore-history",
|
||||||
@@ -239,7 +319,9 @@
|
|||||||
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
"description": "In Hardcore History, journalist and broadcaster Dan Carlin applies his unorthodox, 'Martian' way of thinking to the past. Multi-hour deep dives into pivotal events that blend high drama with masterful narration.",
|
||||||
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
"feedUrl": "https://feeds.feedburner.com/dancarlin/history",
|
||||||
"author": "Dan Carlin",
|
"author": "Dan Carlin",
|
||||||
"categories": ["History"]
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-history-of-rome",
|
"id": "discover-history-of-rome",
|
||||||
@@ -247,7 +329,9 @@
|
|||||||
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
"description": "A weekly chronological podcast tracing the entire history of Rome, from its mythical founding to the fall of the Western Empire. A masterclass in narrative history.",
|
||||||
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
"feedUrl": "https://feeds.feedburner.com/TheHistoryOfRome",
|
||||||
"author": "Mike Duncan",
|
"author": "Mike Duncan",
|
||||||
"categories": ["History"]
|
"categories": [
|
||||||
|
"History"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-philosophize-this",
|
"id": "discover-philosophize-this",
|
||||||
@@ -255,7 +339,10 @@
|
|||||||
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
"description": "Stephen West walks through the entire history of philosophy chronologically, from the pre-Socratics to contemporary thinkers. Making profound ideas accessible without dumbing them down.",
|
||||||
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
"feedUrl": "https://philosophizethis.libsyn.com/rss",
|
||||||
"author": "Stephen West",
|
"author": "Stephen West",
|
||||||
"categories": ["Philosophy", "Education"]
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Education"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-very-bad-wizards",
|
"id": "discover-very-bad-wizards",
|
||||||
@@ -263,7 +350,10 @@
|
|||||||
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
"description": "A philosopher (Tamler Sommers) and a psychologist (David Pizarro) discuss human nature, ethics, free will, and whatever movie they just watched. Irreverent, insightful, and intellectually honest.",
|
||||||
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
"feedUrl": "https://feeds.libsyn.com/474285/rss",
|
||||||
"author": "Tamler Sommers & David Pizarro",
|
"author": "Tamler Sommers & David Pizarro",
|
||||||
"categories": ["Philosophy", "Science"]
|
"categories": [
|
||||||
|
"Philosophy",
|
||||||
|
"Science"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-big-picture",
|
"id": "discover-big-picture",
|
||||||
@@ -271,7 +361,10 @@
|
|||||||
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
"description": "The Ringer's Sean Fennessey and Amanda Dobbins discuss the week in movies, TV, and streaming — from box office analysis to what's worth your time.",
|
||||||
"feedUrl": "https://rss.art19.com/the-big-picture",
|
"feedUrl": "https://rss.art19.com/the-big-picture",
|
||||||
"author": "The Ringer",
|
"author": "The Ringer",
|
||||||
"categories": ["Film", "Entertainment"]
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Entertainment"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-all-songs-considered",
|
"id": "discover-all-songs-considered",
|
||||||
@@ -279,7 +372,9 @@
|
|||||||
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
"description": "NPR's flagship music discovery podcast, delivering the best new releases every week across indie rock, jazz, electronic, and everything in between. Discover music you wouldn't stumble across on your own.",
|
||||||
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
"feedUrl": "https://feeds.npr.org/510019/podcast.xml",
|
||||||
"author": "NPR Music",
|
"author": "NPR Music",
|
||||||
"categories": ["Music"]
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-switched-on-pop",
|
"id": "discover-switched-on-pop",
|
||||||
@@ -287,7 +382,9 @@
|
|||||||
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
"description": "Musicologist Nate Sloan and songwriter Charlie Harding explain why pop music sounds the way it does — pulling apart chord progressions, production tricks, and cultural trends with zero snobbery.",
|
||||||
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
"feedUrl": "https://feeds.megaphone.fm/switchedonpop",
|
||||||
"author": "Vox Media / Panoply",
|
"author": "Vox Media / Panoply",
|
||||||
"categories": ["Music"]
|
"categories": [
|
||||||
|
"Music"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-hit-parade",
|
"id": "discover-hit-parade",
|
||||||
@@ -295,7 +392,10 @@
|
|||||||
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
"description": "Slate's Chris Molanphy traces how songs and genres conquered the Billboard charts, weaving chart history, cultural context, and pure trivia into each episode.",
|
||||||
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
"feedUrl": "https://feeds.megaphone.fm/hitparade",
|
||||||
"author": "Slate",
|
"author": "Slate",
|
||||||
"categories": ["Music", "History"]
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-song-exploder",
|
"id": "discover-song-exploder",
|
||||||
@@ -303,7 +403,10 @@
|
|||||||
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
"description": "Musicians take apart their songs, piece by piece, and tell the story of how they were made. Past guests include Billie Eilish, Fleetwood Mac, and Lin-Manuel Miranda.",
|
||||||
"feedUrl": "https://songexploder.net/rss",
|
"feedUrl": "https://songexploder.net/rss",
|
||||||
"author": "Hrishikesh Hirway",
|
"author": "Hrishikesh Hirway",
|
||||||
"categories": ["Music", "Arts"]
|
"categories": [
|
||||||
|
"Music",
|
||||||
|
"Arts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-blank-check",
|
"id": "discover-blank-check",
|
||||||
@@ -311,7 +414,10 @@
|
|||||||
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
"description": "Reviews of directors' complete filmographies, episode by episode. Specifically, auteurs whose early successes afforded them the rare 'blank check' from Hollywood. Painstakingly hilarious detail.",
|
||||||
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
"feedUrl": "https://audioboom.com/channels/4278829.rss",
|
||||||
"author": "Griffin Newman & David Sims",
|
"author": "Griffin Newman & David Sims",
|
||||||
"categories": ["Film", "Comedy"]
|
"categories": [
|
||||||
|
"Film",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-99-invisible",
|
"id": "discover-99-invisible",
|
||||||
@@ -319,7 +425,11 @@
|
|||||||
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
"description": "A sound-rich, narrative podcast about all the thought that goes into the things we don't think about — the unnoticed architecture and design that shape our world. Hosted by Roman Mars.",
|
||||||
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
"feedUrl": "https://feeds.simplecast.com/BqbsxVfO",
|
||||||
"author": "Roman Mars",
|
"author": "Roman Mars",
|
||||||
"categories": ["Design", "Arts", "Culture"]
|
"categories": [
|
||||||
|
"Design",
|
||||||
|
"Arts",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-gastropod",
|
"id": "discover-gastropod",
|
||||||
@@ -327,7 +437,11 @@
|
|||||||
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
"description": "Food with a side of science and history. Co-hosts Cynthia Graber and Nicola Twilley explore the hidden history and surprising science behind a different food or farming topic every other week.",
|
||||||
"feedUrl": "https://gastropod.com/feed",
|
"feedUrl": "https://gastropod.com/feed",
|
||||||
"author": "Cynthia Graber & Nicola Twilley",
|
"author": "Cynthia Graber & Nicola Twilley",
|
||||||
"categories": ["Food", "Science", "History"]
|
"categories": [
|
||||||
|
"Food",
|
||||||
|
"Science",
|
||||||
|
"History"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-this-american-life",
|
"id": "discover-this-american-life",
|
||||||
@@ -335,7 +449,10 @@
|
|||||||
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
"description": "Hosted by Ira Glass, each episode weaves together stories around a single theme. Combining investigative reporting with intimate personal narratives, it sets the gold standard for audio storytelling.",
|
||||||
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
"feedUrl": "https://www.thisamericanlife.org/podcast/rss.xml",
|
||||||
"author": "This American Life",
|
"author": "This American Life",
|
||||||
"categories": ["Storytelling", "Culture"]
|
"categories": [
|
||||||
|
"Storytelling",
|
||||||
|
"Culture"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-ted-talks-daily",
|
"id": "discover-ted-talks-daily",
|
||||||
@@ -343,7 +460,10 @@
|
|||||||
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
"description": "Thought-provoking ideas on every subject imaginable from the world's leading thinkers and creators. A new TED Talk every weekday.",
|
||||||
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
"feedUrl": "https://feeds.feedburner.com/TEDTalks_audio",
|
||||||
"author": "TED",
|
"author": "TED",
|
||||||
"categories": ["Education", "Storytelling"]
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Storytelling"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-tim-ferriss",
|
"id": "discover-tim-ferriss",
|
||||||
@@ -351,7 +471,10 @@
|
|||||||
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
"description": "Tim Ferriss deconstructs world-class performers — from billionaires to chess prodigies to athletes — to extract the tools, tactics, and routines you can apply to your own life.",
|
||||||
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
"feedUrl": "https://rss.art19.com/tim-ferriss-show",
|
||||||
"author": "Tim Ferriss",
|
"author": "Tim Ferriss",
|
||||||
"categories": ["Self-Improvement", "Business"]
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-jordan-harbinger",
|
"id": "discover-jordan-harbinger",
|
||||||
@@ -359,7 +482,9 @@
|
|||||||
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
"description": "In-depth conversations with fascinating minds — from Ray Dalio to arms traffickers. Jordan Harbinger unpacks guests' wisdom into practical nuggets for work, life, and relationships.",
|
||||||
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
"feedUrl": "https://rss.art19.com/the-jordan-harbinger-show",
|
||||||
"author": "Jordan Harbinger",
|
"author": "Jordan Harbinger",
|
||||||
"categories": ["Self-Improvement"]
|
"categories": [
|
||||||
|
"Self-Improvement"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-on-purpose",
|
"id": "discover-on-purpose",
|
||||||
@@ -367,7 +492,10 @@
|
|||||||
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
"description": "Jay Shetty hosts conversations and workshops designed to make you happier, healthier, and more healed. Interviews with experts, celebrities, and thought leaders on mindset and habit-building.",
|
||||||
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
"feedUrl": "https://rss.art19.com/on-purpose-with-jay-shetty",
|
||||||
"author": "Jay Shetty",
|
"author": "Jay Shetty",
|
||||||
"categories": ["Self-Improvement", "Health"]
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-10-percent-happier",
|
"id": "discover-10-percent-happier",
|
||||||
@@ -375,7 +503,11 @@
|
|||||||
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
"description": "Self-help for the skeptical. ABC News anchor Dan Harris explores meditation and mindfulness with scientists, monks, and teachers, born from his own panic attack on live TV.",
|
||||||
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
"feedUrl": "https://rss.art19.com/ten-percent-happier",
|
||||||
"author": "Dan Harris",
|
"author": "Dan Harris",
|
||||||
"categories": ["Self-Improvement", "Health", "Philosophy"]
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Health",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-school-of-greatness",
|
"id": "discover-school-of-greatness",
|
||||||
@@ -383,7 +515,10 @@
|
|||||||
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
"description": "Former pro athlete Lewis Howes interviews successful people across business, sports, science, and literature to help you unlock your inner greatness and live your best life.",
|
||||||
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
"feedUrl": "https://rss.art19.com/the-school-of-greatness",
|
||||||
"author": "Lewis Howes",
|
"author": "Lewis Howes",
|
||||||
"categories": ["Self-Improvement", "Business"]
|
"categories": [
|
||||||
|
"Self-Improvement",
|
||||||
|
"Business"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-sysk",
|
"id": "discover-sysk",
|
||||||
@@ -391,7 +526,10 @@
|
|||||||
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
"description": "If you've ever wanted to know about champagne, satanism, the Stonewall Uprising, chaos theory, LSD, El Nino, true crime or Roswell — Josh and Chuck have got you covered.",
|
||||||
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
"feedUrl": "https://www.omnycontent.com/d/playlist/e73c998e-6e60-432f-8610-ae210140c5b1/A91018A4-EA4F-4130-BF55-AE270180C327/44710ECC-10BB-48D1-93C7-AE270180C33E/podcast.rss",
|
||||||
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
"author": "iHeartPodcasts (Josh Clark & Chuck Bryant)",
|
||||||
"categories": ["Education", "Comedy"]
|
"categories": [
|
||||||
|
"Education",
|
||||||
|
"Comedy"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "discover-in-our-time",
|
"id": "discover-in-our-time",
|
||||||
@@ -399,7 +537,11 @@
|
|||||||
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
"description": "Melvyn Bragg and guests on BBC Radio 4 discuss the history of ideas — from the Peloponnesian War to the science of photography. A weekly graduate seminar in audio form since 1998.",
|
||||||
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
"feedUrl": "https://podcasts.files.bbci.co.uk/b006qykl.rss",
|
||||||
"author": "BBC Radio 4",
|
"author": "BBC Radio 4",
|
||||||
"categories": ["History", "Education", "Philosophy"]
|
"categories": [
|
||||||
|
"History",
|
||||||
|
"Education",
|
||||||
|
"Philosophy"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,21 +18,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/uuid": "^11.0.0",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
|
||||||
"@babel/preset-typescript": "^7.28.5",
|
|
||||||
"@opentui/core": "^0.1.77",
|
"@opentui/core": "^0.1.77",
|
||||||
"@opentui/solid": "^0.1.77",
|
"@opentui/solid": "^0.1.77",
|
||||||
"babel-preset-solid": "1.9.9",
|
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"solid-js": "^1.9.9",
|
"solid-js": "^1.9.9"
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"zustand": "^5.0.11"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
packaging/aur/.SRCINFO
Normal file
41
packaging/aur/.SRCINFO
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
pkgbase = podtui-bin
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
|
||||||
|
pkgname = podtui-bin
|
||||||
|
pkgver = 0.2.0
|
||||||
|
pkgrel = 1
|
||||||
|
url = https://github.com/mikefreno/podtui
|
||||||
|
pkgdesc = Terminal podcast and audio player with synchronized audio-waveform visualization
|
||||||
|
arch = x86_64
|
||||||
|
arch = aarch64
|
||||||
|
license = MIT
|
||||||
|
depends = mpv
|
||||||
|
provides = podtui
|
||||||
|
conflicts = podtui
|
||||||
|
options = !strip
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-x64.tar.gz
|
||||||
|
source_x86_64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_x86_64 = 5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc
|
||||||
|
sha256sums_x86_64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/podtui-linux-arm64.tar.gz
|
||||||
|
source_aarch64 = https://github.com/mikefreno/podtui/releases/download/v0.2.0/LICENSE
|
||||||
|
sha256sums_aarch64 = c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5
|
||||||
|
sha256sums_aarch64 = 106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc
|
||||||
55
packaging/aur/PKGBUILD
Normal file
55
packaging/aur/PKGBUILD
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Maintainer: Michael Freno <michael.freno@gmail.com>
|
||||||
|
# Contributor: Michael Freno <michael.freno@gmail.com>
|
||||||
|
# podtui-bin — TUI podcast/audiobook player with synchronized audio-waveform
|
||||||
|
# visualization. Serves the official standalone release binary and its two FFI
|
||||||
|
# sibling libraries (libcavacore.so + libopentui.so) from GitHub Releases.
|
||||||
|
#
|
||||||
|
# The embedded Bun runtime is statically linked into the binary — no Bun, no
|
||||||
|
# fftw needed at runtime (fftw3 is linked statically into libcavacore.so).
|
||||||
|
|
||||||
|
pkgname=podtui-bin
|
||||||
|
_pkgname=podtui
|
||||||
|
pkgver=0.2.0
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc="Terminal podcast and audio player with synchronized audio-waveform visualization"
|
||||||
|
url="https://github.com/mikefreno/podtui"
|
||||||
|
arch=('x86_64' 'aarch64')
|
||||||
|
license=('MIT')
|
||||||
|
depends=('mpv') # sole audio backend; no-op without it
|
||||||
|
provides=("${_pkgname}")
|
||||||
|
conflicts=("${_pkgname}")
|
||||||
|
options=('!strip') # standalone binary, pre-minified
|
||||||
|
source_x86_64=(
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-x64.tar.gz"
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||||
|
)
|
||||||
|
source_aarch64=(
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/podtui-linux-arm64.tar.gz"
|
||||||
|
"https://github.com/mikefreno/podtui/releases/download/v${pkgver}/LICENSE"
|
||||||
|
)
|
||||||
|
sha256sums_x86_64=(
|
||||||
|
'5c2be309341bda9550ad7669b48d3f46341c687234ddfaa0c84a6a9177f049fc'
|
||||||
|
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||||
|
)
|
||||||
|
sha256sums_aarch64=(
|
||||||
|
'c9c22d3a18cd192f89fbd5ff712d3502c4de0cce7550156c6a0d48d76e56e0d5'
|
||||||
|
'106a1290f2b9942a43785938cc8ccb1f72cf360babc58316f8d7feee829e98fc'
|
||||||
|
)
|
||||||
|
|
||||||
|
package() {
|
||||||
|
local libdir
|
||||||
|
|
||||||
|
case "$CARCH" in
|
||||||
|
x86_64) libdir="podtui-linux-x64" ;;
|
||||||
|
aarch64) libdir="podtui-linux-arm64" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Binary + native FFI libs must stay side by side in /usr/lib/podtui/;
|
||||||
|
# a /usr/bin symlink works because the embedded Bun runtime resolves
|
||||||
|
# process.execPath through symlinks (verified against the compiled binary).
|
||||||
|
install -Dm755 "${srcdir}/${libdir}/podtui" "${pkgdir}/usr/lib/podtui/podtui"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/libcavacore.so" "${pkgdir}/usr/lib/podtui/libcavacore.so"
|
||||||
|
install -Dm644 "${srcdir}/${libdir}/libopentui.so" "${pkgdir}/usr/lib/podtui/libopentui.so"
|
||||||
|
ln -s /usr/lib/podtui/podtui "${pkgdir}/usr/bin/podtui"
|
||||||
|
install -Dm644 "${srcdir}/LICENSE" "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
|
}
|
||||||
60
packaging/aur/gen-srcinfo.sh
Normal file
60
packaging/aur/gen-srcinfo.sh
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# gen-srcinfo.sh — emit .SRCINFO for the podtui-bin PKGBUILD without makepkg.
|
||||||
|
# Emits the same field set/ordering makepkg --printsrcinfo produces for this
|
||||||
|
# PKGBUILD shape (single package, per-arch source + sha256sums arrays).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./PKGBUILD
|
||||||
|
|
||||||
|
emit() { printf '\t%s = %s\n' "$1" "$2"; }
|
||||||
|
emit_multi() { # $1 field, rest values
|
||||||
|
local f="$1"
|
||||||
|
shift
|
||||||
|
for v in "$@"; do emit "$f" "$v"; done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section() {
|
||||||
|
echo "pkgbase = ${pkgname}"
|
||||||
|
for f in pkgdesc pkgver pkgrel url; do
|
||||||
|
v="${!f}"
|
||||||
|
[ -n "${v:-}" ] && emit "$f" "$v"
|
||||||
|
done
|
||||||
|
[ -n "${install:-}" ] && emit install "$install"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_arch_arrays() {
|
||||||
|
for a in "${arch[@]}"; do
|
||||||
|
src_name="source_${a}"
|
||||||
|
sha_name="sha256sums_${a}"
|
||||||
|
src_val="${src_name}[@]"
|
||||||
|
sha_val="${sha_name}[@]"
|
||||||
|
[ "${#src_name}" -gt 0 ] && emit_multi "source_${a}" "${!src_val}"
|
||||||
|
emit_multi "sha256sums_${a}" "${!sha_val}"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgbase_section
|
||||||
|
echo ""
|
||||||
|
echo "pkgname = ${pkgname}"
|
||||||
|
for v in pkgver pkgrel url; do
|
||||||
|
val="${!v}"
|
||||||
|
[ -n "${val:-}" ] && emit "$v" "$val"
|
||||||
|
done
|
||||||
|
emit pkgdesc "$pkgdesc"
|
||||||
|
[ "${#arch[@]}" -gt 0 ] && emit_multi arch "${arch[@]}"
|
||||||
|
[ "${#license[@]}" -gt 0 ] && emit_multi license "${license[@]}"
|
||||||
|
[ "${#depends[@]}" -gt 0 ] && emit_multi depends "${depends[@]}"
|
||||||
|
[ "${#provides[@]}" -gt 0 ] && emit_multi provides "${provides[@]}"
|
||||||
|
[ "${#conflicts[@]}" -gt 0 ] && emit_multi conflicts "${conflicts[@]}"
|
||||||
|
[ "${#options[@]}" -gt 0 ] && emit_multi options "${options[@]}"
|
||||||
|
emit_arch_arrays
|
||||||
@@ -132,7 +132,7 @@ for r in $REMOTES; do
|
|||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
echo -e "${YELLOW}Note: pushing the tag to ${BLUE}gh${YELLOW} triggers release.yml CI (4-platform"
|
||||||
echo "binaries + GitHub Release) and the homebrew-podtui tap update.${NC}"
|
echo "binaries + GitHub Release) and the homebrew-tap tap update.${NC}"
|
||||||
echo ""
|
echo ""
|
||||||
read -p "Proceed? (y/n) " -n 1 -r
|
read -p "Proceed? (y/n) " -n 1 -r
|
||||||
echo ""
|
echo ""
|
||||||
@@ -236,5 +236,5 @@ echo ""
|
|||||||
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
echo -e "${BLUE}Next steps (automatic, nothing to do):${NC}"
|
||||||
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
echo " 1. GitHub Action release.yml builds 4 tarballs and attaches them:"
|
||||||
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
echo -e " ${CYAN}gh run watch \$(gh run list --limit 1 --json databaseId -q .[0].databaseId)${NC}"
|
||||||
echo " 2. mikefreno/homebrew-podtui self-updates within the hour (Formula"
|
echo " 2. mikefreno/homebrew-tap self-updates within the hour (Formula"
|
||||||
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
echo " URLs + sha256s); brew upgrade podtui afterwards."
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ function parseFlags(rest: string[]): {
|
|||||||
} else if (a === "--from") {
|
} else if (a === "--from") {
|
||||||
flags.from = rest[++i];
|
flags.from = rest[++i];
|
||||||
} else {
|
} else {
|
||||||
flags[a.slice(2)] = rest[++i] ?? true;
|
throw new Error(`unknown flag: ${a}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
positional.push(a);
|
positional.push(a);
|
||||||
@@ -222,52 +222,68 @@ function parseMods(positional: string[]): Mod[] {
|
|||||||
return mods;
|
return mods;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAction(cmd: string, positional: string[]): Action | null {
|
// Per-command builders. Leading positional tokens that name a modifier
|
||||||
|
// (ctrl/shift/...) are stripped as mods; the rest is the command's data.
|
||||||
|
const modsOrUndefined = (positional: string[]): Mod[] | undefined => {
|
||||||
const mods = parseMods(positional);
|
const mods = parseMods(positional);
|
||||||
const first = positional[0];
|
return mods.length ? mods : undefined;
|
||||||
switch (cmd) {
|
};
|
||||||
case "key":
|
|
||||||
if (!first) throw new Error("key requires a <key> argument");
|
const BUILDERS: Record<string, (positional: string[]) => Action> = {
|
||||||
return { t: "key", k: first, mods: mods.length ? mods : undefined };
|
key: (p) => {
|
||||||
case "arrow":
|
if (!p[0]) throw new Error("key requires a <key> argument");
|
||||||
if (!first || !["up", "down", "left", "right"].includes(first))
|
return { t: "key", k: p[0], mods: modsOrUndefined(p) };
|
||||||
throw new Error("arrow requires up|down|left|right");
|
},
|
||||||
return {
|
arrow: (p) => {
|
||||||
t: "arrow",
|
if (!p[0] || !["up", "down", "left", "right"].includes(p[0]))
|
||||||
d: first as any,
|
throw new Error("arrow requires up|down|left|right");
|
||||||
mods: mods.length ? mods : undefined,
|
return {
|
||||||
};
|
t: "arrow",
|
||||||
case "enter":
|
d: p[0] as "up" | "down" | "left" | "right",
|
||||||
case "escape":
|
mods: modsOrUndefined(p),
|
||||||
case "tab":
|
};
|
||||||
case "space":
|
},
|
||||||
case "backspace":
|
enter: (p) => ({ t: "enter", mods: modsOrUndefined(p) }),
|
||||||
return { t: cmd, mods: mods.length ? mods : undefined };
|
escape: (p) => ({ t: "escape", mods: modsOrUndefined(p) }),
|
||||||
case "type":
|
tab: (p) => ({ t: "tab", mods: modsOrUndefined(p) }),
|
||||||
if (first === undefined) throw new Error("type requires <text>");
|
space: (p) => ({ t: "space", mods: modsOrUndefined(p) }),
|
||||||
// Re-join the rest in case text had spaces; positional[0] already is first token,
|
backspace: (p) => ({ t: "backspace", mods: modsOrUndefined(p) }),
|
||||||
// caller should quote. We join all positional as the text.
|
type: (p) => {
|
||||||
return { t: "type", s: positional.join(" ") };
|
if (p[0] === undefined) throw new Error("type requires <text>");
|
||||||
case "wait":
|
// Re-join the rest in case text had spaces; p[0] already is first token,
|
||||||
if (!first) throw new Error("wait requires <ms>");
|
// caller should quote. We join all positional as the text.
|
||||||
return { t: "wait", ms: parseInt(first, 10) || 0 };
|
return { t: "type", s: p.join(" ") };
|
||||||
case "resize":
|
},
|
||||||
if (!first || !positional[1]) throw new Error("resize requires <w> <h>");
|
wait: (p) => {
|
||||||
return {
|
if (!p[0]) throw new Error("wait requires <ms>");
|
||||||
t: "resize",
|
return { t: "wait", ms: parseInt(p[0], 10) || 0 };
|
||||||
w: parseInt(first, 10) || 100,
|
},
|
||||||
h: parseInt(positional[1], 10) || 30,
|
resize: (p) => {
|
||||||
};
|
if (!p[0] || !p[1]) throw new Error("resize requires <w> <h>");
|
||||||
case "frame":
|
return {
|
||||||
case "state":
|
t: "resize",
|
||||||
case "reset":
|
w: parseInt(p[0], 10) || 100,
|
||||||
case "actions":
|
h: parseInt(p[1], 10) || 30,
|
||||||
case "init":
|
};
|
||||||
case "seed":
|
},
|
||||||
return null;
|
};
|
||||||
default:
|
|
||||||
throw new Error(`unknown command: ${cmd}`);
|
function buildAction(cmd: string, positional: string[]): Action | null {
|
||||||
}
|
const builder = BUILDERS[cmd];
|
||||||
|
if (builder) return builder(positional);
|
||||||
|
// Local-only commands return early in main before this is reached; keep
|
||||||
|
// the null contract so the public behavior is unchanged.
|
||||||
|
if (
|
||||||
|
cmd === "frame" ||
|
||||||
|
cmd === "state" ||
|
||||||
|
cmd === "reset" ||
|
||||||
|
cmd === "actions" ||
|
||||||
|
cmd === "init" ||
|
||||||
|
cmd === "seed"
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
// Single table-miss error for any unknown command.
|
||||||
|
throw new Error(`unknown command: ${cmd}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Execute one action against a mounted setup ──────────────────────────────
|
// ── Execute one action against a mounted setup ──────────────────────────────
|
||||||
@@ -317,26 +333,53 @@ async function execAction(setup: any, a: Action): Promise<void> {
|
|||||||
await new Promise((r) => setTimeout(r, 40));
|
await new Promise((r) => setTimeout(r, 40));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ───────────────────────────────────────────────────────────────────
|
// ── Mount, snapshot & output (extracted from main) ─────────────────────────
|
||||||
async function main() {
|
// A line is "visually empty" if it's either fully blank OR contains only
|
||||||
activateSandbox();
|
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
||||||
captureIssues();
|
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
||||||
|
// 24-row pane costs 1 line, not 18.
|
||||||
|
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
||||||
|
const isVisuallyEmpty = (l: string): boolean =>
|
||||||
|
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
function trimFrame(plainFrame: string): string {
|
||||||
const cmd = argv[0] ?? "frame";
|
const lines = plainFrame
|
||||||
const { flags, positional } = parseFlags(argv.slice(1));
|
.replace(/\n+$/, "")
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.replace(/\s+$/, ""));
|
||||||
|
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
||||||
|
lines.pop();
|
||||||
|
const out: string[] = [];
|
||||||
|
let blank = 0;
|
||||||
|
const flushBlanks = () => {
|
||||||
|
if (blank >= 3) out.push(` …${blank} empty`);
|
||||||
|
else for (let i = 0; i < blank; i++) out.push("");
|
||||||
|
blank = 0;
|
||||||
|
};
|
||||||
|
for (const l of lines) {
|
||||||
|
if (isVisuallyEmpty(l)) {
|
||||||
|
blank++;
|
||||||
|
} else {
|
||||||
|
flushBlanks();
|
||||||
|
out.push(l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushBlanks();
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
// Local-only commands that don't mount.
|
// Local-only commands that don't mount. Returns true if handled (main returns).
|
||||||
|
function runLocal(cmd: string, flags: Record<string, string | boolean>): boolean {
|
||||||
if (cmd === "reset") {
|
if (cmd === "reset") {
|
||||||
saveActions([]);
|
saveActions([]);
|
||||||
console.log("✔ actions log cleared.");
|
console.log("✔ actions log cleared.");
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "actions") {
|
if (cmd === "actions") {
|
||||||
const a = loadActions();
|
const a = loadActions();
|
||||||
console.log(`Action log (${a.length}):`);
|
console.log(`Action log (${a.length}):`);
|
||||||
console.log(JSON.stringify(a, null, 2));
|
console.log(JSON.stringify(a, null, 2));
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
if (cmd === "seed") {
|
if (cmd === "seed") {
|
||||||
const from = String(
|
const from = String(
|
||||||
@@ -349,9 +392,29 @@ async function main() {
|
|||||||
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
const dest = join(process.env.XDG_CONFIG_HOME!, "podtui");
|
||||||
cpSync(from, dest, { recursive: true });
|
cpSync(from, dest, { recursive: true });
|
||||||
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
console.log(`✔ seeded sandbox config from ${from} → ${dest}`);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrameCapture = {
|
||||||
|
lines: { spans: Span[] }[];
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
cursor: [number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mountApp(
|
||||||
|
flags: Record<string, string | boolean>,
|
||||||
|
cmd: string,
|
||||||
|
positional: string[],
|
||||||
|
): Promise<{
|
||||||
|
setup: any;
|
||||||
|
spans: FrameCapture;
|
||||||
|
plainFrame: string;
|
||||||
|
audioControls: any;
|
||||||
|
actions: Action[];
|
||||||
|
}> {
|
||||||
// Size settings.
|
// Size settings.
|
||||||
let width = 100;
|
let width = 100;
|
||||||
let height = 30;
|
let height = 30;
|
||||||
@@ -448,12 +511,7 @@ async function main() {
|
|||||||
// Final settle + capture.
|
// Final settle + capture.
|
||||||
await setup.renderOnce();
|
await setup.renderOnce();
|
||||||
await new Promise((r) => setTimeout(r, 60));
|
await new Promise((r) => setTimeout(r, 60));
|
||||||
const spans = setup.captureSpans() as {
|
const spans = setup.captureSpans() as FrameCapture;
|
||||||
lines: { spans: Span[] }[];
|
|
||||||
cols: number;
|
|
||||||
rows: number;
|
|
||||||
cursor: [number, number];
|
|
||||||
};
|
|
||||||
const plainFrame = setup.captureCharFrame();
|
const plainFrame = setup.captureCharFrame();
|
||||||
|
|
||||||
// Dump structured spans + plain frame.
|
// Dump structured spans + plain frame.
|
||||||
@@ -462,6 +520,10 @@ async function main() {
|
|||||||
writeFileSync(FRAME_TXT, plainFrame);
|
writeFileSync(FRAME_TXT, plainFrame);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
|
return { setup, spans, plainFrame, audioControls, actions };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotState(audioControls: any): Promise<Record<string, unknown>> {
|
||||||
// Store state snapshot.
|
// Store state snapshot.
|
||||||
const state: Record<string, unknown> = {};
|
const state: Record<string, unknown> = {};
|
||||||
try {
|
try {
|
||||||
@@ -514,54 +576,31 @@ async function main() {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(STATE_JSON, JSON.stringify(state));
|
writeFileSync(STATE_JSON, JSON.stringify(state));
|
||||||
} catch {}
|
} catch {}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Output ──────────────────────────────────────────────────────────────
|
function emitOutput(p: {
|
||||||
|
spans: FrameCapture;
|
||||||
|
plainFrame: string;
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
actions: Action[];
|
||||||
|
cmd: string;
|
||||||
|
flags: Record<string, string | boolean>;
|
||||||
|
positional: string[];
|
||||||
|
}): void {
|
||||||
// Compact by default: trimmed frame, one-line state per section, no styles
|
// Compact by default: trimmed frame, one-line state per section, no styles
|
||||||
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
// block, no boilerplate footer. Use --styles / --verbose to opt back in.
|
||||||
const verbose = !!flags.verbose;
|
const verbose = !!p.flags.verbose;
|
||||||
const scope = cmd === "state" ? String(positional[0] || "all") : "all";
|
const scope = p.cmd === "state" ? String(p.positional[0] || "all") : "all";
|
||||||
|
|
||||||
// A line is "visually empty" if it's either fully blank OR contains only
|
|
||||||
// box-drawing chars + whitespace (i.e. empty-pane interior padding like
|
|
||||||
// "│ │"). Runs of these collapse to a single `…N` marker so an empty
|
|
||||||
// 24-row pane costs 1 line, not 18.
|
|
||||||
const BOX_CHARS = "│┌┐└─┤├┬┴┼┐┘┌└┤├┬┴┼┌┐└┘─│┤├┬┴┼";
|
|
||||||
const isVisuallyEmpty = (l: string): boolean =>
|
|
||||||
l === "" || [...l].every((ch) => ch === " " || BOX_CHARS.includes(ch));
|
|
||||||
const frameTrimmed = (() => {
|
|
||||||
const lines = plainFrame
|
|
||||||
.replace(/\n+$/, "")
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.replace(/\s+$/, ""));
|
|
||||||
while (lines.length && isVisuallyEmpty(lines[lines.length - 1]))
|
|
||||||
lines.pop();
|
|
||||||
const out: string[] = [];
|
|
||||||
let blank = 0;
|
|
||||||
const flushBlanks = () => {
|
|
||||||
if (blank >= 3) out.push(` …${blank} empty`);
|
|
||||||
else for (let i = 0; i < blank; i++) out.push("");
|
|
||||||
blank = 0;
|
|
||||||
};
|
|
||||||
for (const l of lines) {
|
|
||||||
if (isVisuallyEmpty(l)) {
|
|
||||||
blank++;
|
|
||||||
} else {
|
|
||||||
flushBlanks();
|
|
||||||
out.push(l);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flushBlanks();
|
|
||||||
return out.join("\n");
|
|
||||||
})();
|
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`FRAME ${spans.cols}x${spans.rows} cur=${spans.cursor[0]},${spans.cursor[1]} acts=${actions.length} ${cmd}`,
|
`FRAME ${p.spans.cols}x${p.spans.rows} cur=${p.spans.cursor[0]},${p.spans.cursor[1]} acts=${p.actions.length} ${p.cmd}`,
|
||||||
);
|
);
|
||||||
console.log(frameTrimmed);
|
console.log(trimFrame(p.plainFrame));
|
||||||
|
|
||||||
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
// ── distinct styles: opt-in only (--styles OR --verbose) ──
|
||||||
if (scope === "all" && (flags.styles || verbose)) {
|
if (scope === "all" && (p.flags.styles || verbose)) {
|
||||||
const styles = distinctStyles(spans);
|
const styles = distinctStyles(p.spans);
|
||||||
if (styles.length) {
|
if (styles.length) {
|
||||||
console.log("-- styles (top 20) --");
|
console.log("-- styles (top 20) --");
|
||||||
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
for (const s of styles) console.log(` ${s.tag} ×${s.n} “${s.sample}”`);
|
||||||
@@ -572,9 +611,9 @@ async function main() {
|
|||||||
const want = (k: string) => scope === "all" || scope === k;
|
const want = (k: string) => scope === "all" || scope === k;
|
||||||
const compact = (obj: unknown): string =>
|
const compact = (obj: unknown): string =>
|
||||||
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
verbose ? JSON.stringify(obj, null, 2) : JSON.stringify(obj);
|
||||||
if (want("nav")) console.log("nav " + compact(state.nav));
|
if (want("nav")) console.log("nav " + compact(p.state.nav));
|
||||||
if (want("audio")) console.log("audio " + compact(state.audio));
|
if (want("audio")) console.log("audio " + compact(p.state.audio));
|
||||||
if (want("feed")) console.log("feed " + compact(state.feed));
|
if (want("feed")) console.log("feed " + compact(p.state.feed));
|
||||||
if (want("app")) console.log("app (not dumped in v1)");
|
if (want("app")) console.log("app (not dumped in v1)");
|
||||||
|
|
||||||
// ── issues: terse ──
|
// ── issues: terse ──
|
||||||
@@ -586,12 +625,14 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Footer is identical every run — only print on init or --verbose.
|
// Footer is identical every run — only print on init or --verbose.
|
||||||
if (cmd === "init" || verbose) {
|
if (p.cmd === "init" || verbose) {
|
||||||
console.log(
|
console.log(
|
||||||
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
`(spans ${FRAME_JSON} | frame ${FRAME_TXT} | state ${STATE_JSON})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardown(setup: any, audioControls: any): Promise<void> {
|
||||||
// Tear down child processes (audio backend) before exit to avoid orphans.
|
// Tear down child processes (audio backend) before exit to avoid orphans.
|
||||||
try {
|
try {
|
||||||
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
if (audioControls?.stop) await audioControls.stop().catch(() => {});
|
||||||
@@ -606,6 +647,32 @@ async function main() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
async function main() {
|
||||||
|
activateSandbox();
|
||||||
|
captureIssues();
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const cmd = argv[0] ?? "frame";
|
||||||
|
const { flags, positional } = parseFlags(argv.slice(1));
|
||||||
|
|
||||||
|
// Local-only commands that don't mount.
|
||||||
|
if (runLocal(cmd, flags)) return;
|
||||||
|
|
||||||
|
const m = await mountApp(flags, cmd, positional);
|
||||||
|
const state = await snapshotState(m.audioControls);
|
||||||
|
emitOutput({
|
||||||
|
spans: m.spans,
|
||||||
|
plainFrame: m.plainFrame,
|
||||||
|
state,
|
||||||
|
actions: m.actions,
|
||||||
|
cmd,
|
||||||
|
flags,
|
||||||
|
positional,
|
||||||
|
});
|
||||||
|
await teardown(m.setup, m.audioControls);
|
||||||
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error("HARNESS FAILED:", err?.stack || err);
|
console.error("HARNESS FAILED:", err?.stack || err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ErrorBoundary } from "solid-js";
|
import { ErrorBoundary } from "solid-js";
|
||||||
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
@@ -19,7 +18,6 @@ const DEBUG = import.meta.env.DEBUG;
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const auth = useAuthStore();
|
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
@@ -52,6 +50,7 @@ export function App() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const backgroundColor = () =>
|
const backgroundColor = () =>
|
||||||
|
themeContext.transparentBackground() ||
|
||||||
themeContext.selected === "system"
|
themeContext.selected === "system"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: themeContext.theme.surface;
|
: themeContext.theme.surface;
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { Episode } from "../types/episode"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import type { PodcastSource } from "../types/source"
|
|
||||||
import { parseRSSFeed } from "@/api/rss-parser"
|
|
||||||
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
|
|
||||||
|
|
||||||
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(feedUrl)
|
|
||||||
if (!response.ok) return []
|
|
||||||
const xml = await response.text()
|
|
||||||
return parseRSSFeed(xml, feedUrl).episodes
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchFeeds = async (
|
|
||||||
sourceIds: string[],
|
|
||||||
sources: PodcastSource[]
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
const active = sources.filter((source) => sourceIds.includes(source.id))
|
|
||||||
const feeds: Feed[] = []
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
active.map(async (source) => {
|
|
||||||
try {
|
|
||||||
if (source.type === "rss") {
|
|
||||||
const rssFeeds = await handleRSSSource(source)
|
|
||||||
feeds.push(...rssFeeds)
|
|
||||||
} else if (source.type === "api") {
|
|
||||||
const apiFeeds = await handleAPISource(source, "")
|
|
||||||
feeds.push(...apiFeeds)
|
|
||||||
} else {
|
|
||||||
const customFeeds = await handleCustomSource(source, "")
|
|
||||||
feeds.push(...customFeeds)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore individual source errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return feeds
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
|
||||||
query: string,
|
|
||||||
sources: PodcastSource[]
|
|
||||||
): Promise<Podcast[]> => {
|
|
||||||
const results: Podcast[] = []
|
|
||||||
await Promise.all(
|
|
||||||
sources.map(async (source) => {
|
|
||||||
try {
|
|
||||||
if (source.type === "rss") {
|
|
||||||
const feeds = await handleRSSSource(source)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
} else if (source.type === "api") {
|
|
||||||
const feeds = await handleAPISource(source, query)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
} else {
|
|
||||||
const feeds = await handleCustomSource(source, query)
|
|
||||||
results.push(...feeds.map((feed: Feed) => feed.podcast))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore errors
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { PodcastSource } from "../types/source"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import { parseRSSFeed } from "./rss-parser"
|
|
||||||
|
|
||||||
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
|
|
||||||
return {
|
|
||||||
id: `${sourceId}-${podcast.id}`,
|
|
||||||
podcast,
|
|
||||||
episodes: [],
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
|
|
||||||
if (!source.baseUrl) return []
|
|
||||||
const response = await fetch(source.baseUrl)
|
|
||||||
if (!response.ok) return []
|
|
||||||
const xml = await response.text()
|
|
||||||
const parsed = parseRSSFeed(xml, source.baseUrl)
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: `${source.id}-${parsed.feedUrl}`,
|
|
||||||
podcast: {
|
|
||||||
id: parsed.id,
|
|
||||||
title: parsed.title,
|
|
||||||
description: parsed.description,
|
|
||||||
feedUrl: parsed.feedUrl,
|
|
||||||
author: parsed.author,
|
|
||||||
categories: parsed.categories,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: parsed.episodes,
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId: source.id,
|
|
||||||
lastUpdated: parsed.lastUpdated,
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleAPISource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
|
|
||||||
url.searchParams.set("term", query || "podcast")
|
|
||||||
url.searchParams.set("media", "podcast")
|
|
||||||
url.searchParams.set("entity", "podcast")
|
|
||||||
url.searchParams.set("country", source.country || "US")
|
|
||||||
url.searchParams.set("lang", source.language || "en_us")
|
|
||||||
|
|
||||||
const response = await fetch(url.toString())
|
|
||||||
if (!response.ok) return []
|
|
||||||
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
|
|
||||||
const results = data.results ?? []
|
|
||||||
|
|
||||||
return results
|
|
||||||
.filter((item) => item.collectionName && item.feedUrl)
|
|
||||||
.map((item) => {
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
|
|
||||||
title: item.collectionName || "Untitled Podcast",
|
|
||||||
description: item.collectionName || "",
|
|
||||||
feedUrl: item.feedUrl || "",
|
|
||||||
author: item.artistName,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return buildFeedFromPodcast(podcast, source.id)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handleCustomSource = async (
|
|
||||||
source: PodcastSource,
|
|
||||||
query: string
|
|
||||||
): Promise<Feed[]> => {
|
|
||||||
if (!query) return []
|
|
||||||
const podcast: Podcast = {
|
|
||||||
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
|
|
||||||
title: `${query} Highlights`,
|
|
||||||
description: `Curated results for ${query}`,
|
|
||||||
feedUrl: source.baseUrl || "",
|
|
||||||
author: source.name,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: false,
|
|
||||||
}
|
|
||||||
return [buildFeedFromPodcast(podcast, source.id)]
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Code validation component for PodTUI
|
|
||||||
* 8-character alphanumeric code input for sync authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { AUTH_CONFIG } from "@/config/auth";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface CodeValidationProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "code" | "submit" | "back";
|
|
||||||
|
|
||||||
export function CodeValidation(props: CodeValidationProps) {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [code, setCode] = createSignal("");
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
|
||||||
const [codeError, setCodeError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["code", "submit", "back"];
|
|
||||||
|
|
||||||
/** Format code as user types (uppercase, alphanumeric only) */
|
|
||||||
const handleCodeInput = (value: string) => {
|
|
||||||
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
||||||
// Limit to max length
|
|
||||||
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
|
|
||||||
setCode(limited);
|
|
||||||
|
|
||||||
// Clear error when typing
|
|
||||||
if (codeError()) {
|
|
||||||
setCodeError(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateCode = (value: string): boolean => {
|
|
||||||
if (!value) {
|
|
||||||
setCodeError("Code is required");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
|
||||||
setCodeError(
|
|
||||||
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
|
|
||||||
setCodeError("Code must contain only letters and numbers");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setCodeError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateCode(code())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await auth.validateCode(code());
|
|
||||||
if (!success && auth.error) {
|
|
||||||
setCodeError(auth.error.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return" || key.name === "tab") {
|
|
||||||
if (focusField() === "submit") {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
} else if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeProgress = () => {
|
|
||||||
const len = code().length;
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
return `${len}/${max}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const codeDisplay = () => {
|
|
||||||
const current = code();
|
|
||||||
const max = AUTH_CONFIG.codeValidation.codeLength;
|
|
||||||
const filled = current.split("");
|
|
||||||
const empty = Array(max - filled.length).fill("_");
|
|
||||||
return [...filled, ...empty].join(" ");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Enter Sync Code</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter your 8-character sync code to link your account.
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Code display */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
Code ({codeProgress()}):
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box border padding={1} borderColor={theme.border}>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{codeDisplay()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Hidden input for actual typing */}
|
|
||||||
<input
|
|
||||||
value={code()}
|
|
||||||
onInput={handleCodeInput}
|
|
||||||
placeholder=""
|
|
||||||
focused={props.focused && focusField() === "code"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{codeError() && <text fg={theme.error}>{codeError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.primary : undefined}>
|
|
||||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { useTheme } from "@/context/ThemeContext";
|
|||||||
|
|
||||||
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
//TODO: Watch for actual loading state (fetching feeds)
|
|
||||||
export function LoadingIndicator() {
|
export function LoadingIndicator() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [index, setIndex] = createSignal(0);
|
const [index, setIndex] = createSignal(0);
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* YaziPaneRow — 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, 3, 3]` contract: three bordered columns
|
* Implements yazi's `mgr.ratio = [1, 2, 2]` contract: three bordered columns
|
||||||
* grow at 1/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
* grow at 1/5 : 2/5 : 2/5 of the row width via Yoga `flexGrow`, so every list
|
||||||
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
* tab renders an identical, layout-stable shell. Columns use `flexBasis={0}`
|
||||||
* so the ratio is exact regardless of content width — a column's content can
|
* so the ratio is exact regardless of content width — a column's content can
|
||||||
* never stretch its slot.
|
* 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/7 slot when blank (never collapses to width 0).
|
* KEEPS its 1/5 slot when blank (never collapses to width 0).
|
||||||
* current — the current-depth list. The only focusable content column; it
|
* current — the current-depth list. The only focusable content column; it
|
||||||
* carries the active-border focus ring when `focused` is truthy.
|
* carries the active-border focus ring when `focused` is truthy.
|
||||||
* preview — detail of the hovered item in `current`; always muted border.
|
* preview — detail of the hovered item in `current`; always muted border.
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
* `focused`, so scroll focus follows the cursor (j/k stay in the current pane).
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* <YaziPaneRow
|
* <PaneRow
|
||||||
* parent={parentList}
|
* parent={parentList}
|
||||||
* current={currentList}
|
* current={currentList}
|
||||||
* preview={detail}
|
* preview={detail}
|
||||||
@@ -41,9 +41,9 @@ import { PANE_RATIO } from "@/utils/navigation";
|
|||||||
type PaneContent = JSX.Element | (() => JSX.Element);
|
type PaneContent = JSX.Element | (() => JSX.Element);
|
||||||
type PaneLabel = string | (() => string);
|
type PaneLabel = string | (() => string);
|
||||||
|
|
||||||
export type YaziPaneRowProps = {
|
export type PaneRowProps = {
|
||||||
/** Parent column content (previous-depth list, or null for a muted
|
/** Parent column content (previous-depth list, or null for a muted
|
||||||
* placeholder — the 1/7 slot is always preserved). */
|
* placeholder — the 1/5 slot is always preserved). */
|
||||||
parent?: PaneContent;
|
parent?: PaneContent;
|
||||||
/** Current column content (the focused list). */
|
/** Current column content (the focused list). */
|
||||||
current?: PaneContent;
|
current?: PaneContent;
|
||||||
@@ -69,16 +69,13 @@ function resolveLabel(v: PaneLabel | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
/** Normalize a PaneContent (static JSX or accessor) into a reactive accessor.
|
||||||
* We deliberately do NOT use Solid's `children()` helper here: that helper
|
* We deliberately avoid Solid's `children()` helper: it flattens accessor
|
||||||
* flattens accessor children into a stable resolved-nodes array and is the
|
* children into a stable resolved-nodes array and won't re-resolve on a
|
||||||
* wrong tool for content whose ROOT swaps at runtime (e.g. the current pane
|
* truthy→truthy root swap (e.g. the current pane switching between a
|
||||||
* switching between a depth-1 list fragment and a depth-2 editor — both
|
* depth-1 list fragment and a depth-2 editor), freezing the previous
|
||||||
* truthy JSX roots). `children()` would not re-resolve on a truthy<@->truthy
|
* subtree. Instead the raw accessor feeds a reactive `{ expr ?? <Placeholder/> }`
|
||||||
* root swap, freezing the previous subtree in place. Instead we hand the
|
* expression — a tracked `insert` effect that disposes the old subtree and
|
||||||
* raw accessor to a reactive `{ expr ?? <Placeholder/> }` expression below,
|
* mounts the new whenever the accessor returns a different element identity. */
|
||||||
* which Solid compiles into a tracked `insert` effect that disposes the old
|
|
||||||
* subtree and mounts the new whenever the accessor returns a different
|
|
||||||
* element identity. */
|
|
||||||
function normalizeContent(
|
function normalizeContent(
|
||||||
v: PaneContent | undefined,
|
v: PaneContent | undefined,
|
||||||
): () => JSX.Element | undefined {
|
): () => JSX.Element | undefined {
|
||||||
@@ -95,14 +92,15 @@ function Placeholder(props: { color: () => RGBA }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Pane column ─────────────────────────────────────────────────────────────
|
// ── Pane column ─────────────────────────────────────────────────────────────
|
||||||
function YaziPane(props: {
|
function Pane(props: {
|
||||||
grow: number;
|
grow: number;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
content: () => JSX.Element | undefined;
|
content: () => JSX.Element | undefined;
|
||||||
borderColor: () => RGBA;
|
borderColor: () => RGBA;
|
||||||
scrollFocused: () => boolean;
|
scrollFocused: () => boolean;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useTheme();
|
const themeContext = useTheme();
|
||||||
|
const theme = themeContext.theme;
|
||||||
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
const muted = () => theme.muted ?? theme.textMuted ?? theme.text;
|
||||||
|
|
||||||
// Memoize accessor results so the prop expressions below stay reactive
|
// Memoize accessor results so the prop expressions below stay reactive
|
||||||
@@ -118,7 +116,15 @@ function YaziPane(props: {
|
|||||||
height="100%"
|
height="100%"
|
||||||
>
|
>
|
||||||
{/* ── slim header label row ─────────────────────────────────────────── */}
|
{/* ── slim header label row ─────────────────────────────────────────── */}
|
||||||
<box height={1} paddingLeft={1} backgroundColor={theme.background}>
|
<box
|
||||||
|
height={1}
|
||||||
|
paddingLeft={1}
|
||||||
|
backgroundColor={
|
||||||
|
themeContext.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: theme.background
|
||||||
|
}
|
||||||
|
>
|
||||||
<text fg={theme.textSecondary}>{props.label()}</text>
|
<text fg={theme.textSecondary}>{props.label()}</text>
|
||||||
</box>
|
</box>
|
||||||
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
{/* ── bordered scrollbox ────────────────────────────────────────────── */}
|
||||||
@@ -127,29 +133,20 @@ function YaziPane(props: {
|
|||||||
focused={scrollFocused()}
|
focused={scrollFocused()}
|
||||||
border
|
border
|
||||||
borderColor={borderColor()}
|
borderColor={borderColor()}
|
||||||
backgroundColor={theme.background}
|
backgroundColor={
|
||||||
|
themeContext.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: theme.background
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/*
|
{props.content() ?? <Placeholder color={muted} />}
|
||||||
* Render the content accessor directly via a reactive expression.
|
|
||||||
* `{ accessor() ?? <Placeholder/> }` compiles to a Solid `insert`
|
|
||||||
* effect that re-runs whenever the accessor's tracked signals
|
|
||||||
* change (e.g. `depth()` swapping the root from a list fragment to
|
|
||||||
* an editor). Solid disposes the previously-rendered subtree and
|
|
||||||
* mounts the new element identity. `null`/`undefined` falls back
|
|
||||||
* to the muted placeholder so the parent pane keeps its 1/7 slot
|
|
||||||
* visibly blank at depth 0. This is the correct tool for root
|
|
||||||
* swapping — unlike Solid's `children()` / `<Show>`-children,
|
|
||||||
* which only react to truthiness flips, not truthy<@->truthy root
|
|
||||||
* identity changes.
|
|
||||||
*/}
|
|
||||||
{props.content() ?? <Placeholder color={muted} />}
|
|
||||||
</scrollbox>
|
</scrollbox>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Row primitive ───────────────────────────────────────────────────────────
|
// ── Row primitive ───────────────────────────────────────────────────────────
|
||||||
export function YaziPaneRow(props: YaziPaneRowProps) {
|
export function PaneRow(props: PaneRowProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
/** true → the current column gets the active-border focus ring. */
|
/** true → the current column gets the active-border focus ring. */
|
||||||
@@ -179,8 +176,8 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
{/* ── parent (1/7) — previous-depth list; always muted ─────────────── */}
|
{/* ── parent (1/5) — previous-depth list; always muted ─────────────── */}
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={parentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
@@ -188,16 +185,16 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
scrollFocused={() => false}
|
scrollFocused={() => false}
|
||||||
/>
|
/>
|
||||||
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
{/* ── current — the focused list; active-border ring when focused ──────────── */}
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={currentLabel}
|
label={currentLabel}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
borderColor={() => (focused() ? theme.borderActive : theme.border)}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (2/5) — hovered-item detail; always muted ────────────── */}
|
||||||
<Show when={panes() === 3}>
|
<Show when={panes() === 3}>
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={PANE_RATIO.preview}
|
grow={PANE_RATIO.preview}
|
||||||
label={previewLabel}
|
label={previewLabel}
|
||||||
content={previewContent}
|
content={previewContent}
|
||||||
@@ -20,7 +20,8 @@ export const SelectableBox: ParentComponent<
|
|||||||
backgroundColor={
|
backgroundColor={
|
||||||
props.selected()
|
props.selected()
|
||||||
? theme.primary
|
? theme.primary
|
||||||
: themeContext.selected === "system"
|
: themeContext.transparentBackground() ||
|
||||||
|
themeContext.selected === "system"
|
||||||
? "transparent"
|
? "transparent"
|
||||||
: themeContext.theme.surface
|
: themeContext.theme.surface
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,20 +12,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For } from "solid-js";
|
import { createSignal, Show, For } from "solid-js";
|
||||||
import { useKeyboard } from "@opentui/solid";
|
import { useKeyboard, useRenderer } 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";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore } from "@/stores/audio-nav";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { emit } from "@/utils/event-bus";
|
import { emit, on } from "@/utils/event-bus";
|
||||||
import { LayerGraph } from "@/utils/layer-graph";
|
import { LayerGraph } from "@/utils/layer-graph";
|
||||||
import { TABS, TabPaneCount } from "@/utils/navigation";
|
import { TABS, TabPaneCount } from "@/utils/navigation";
|
||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
[TABS.FEED]: "Feed",
|
[TABS.FEED]: "Feed",
|
||||||
@@ -42,12 +43,25 @@ export function Shell() {
|
|||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const k = useKeybinds();
|
const k = useKeybinds();
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
|
const renderer = useRenderer();
|
||||||
const audioNav = useAudioNavStore();
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
const [showHelp, setShowHelp] = createSignal(false);
|
const [showHelp, setShowHelp] = createSignal(false);
|
||||||
|
|
||||||
|
// ── Auto jump to Player on podcast start ───────────────────────────────────
|
||||||
|
// Honor the `autoJumpToPlayer` preference: when a NEW episode starts (see
|
||||||
|
// "player.started" — distinct from "player.play", which also fires on
|
||||||
|
// resume), switch to the Player tab and drop into its content pane.
|
||||||
|
on("player.started", () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
if (app.state().preferences.autoJumpToPlayer) {
|
||||||
|
nav.setActiveTab(TABS.PLAYER);
|
||||||
|
nav.enterTabContent(); // PLAYER is a depth-tab — enter its content.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
/** Play the episode adjacent (offset ±1) to the currently-playing one,
|
||||||
* within its feed's episode list. Updates audio-nav context accordingly. */
|
* within its feed's episode list. Updates audio-nav context accordingly. */
|
||||||
function advanceEpisode(offset: number) {
|
function advanceEpisode(offset: number) {
|
||||||
@@ -83,74 +97,60 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Command bar dispatch ────────────────────────────────────────────────────
|
// ── Command bar dispatch ────────────────────────────────────────────────────
|
||||||
|
const COMMANDS: Record<string, (arg: string) => void> = {
|
||||||
|
quit: () => process.exit(0),
|
||||||
|
exit: () => process.exit(0),
|
||||||
|
q: () => process.exit(0),
|
||||||
|
refresh: () =>
|
||||||
|
emit("nav.action", {
|
||||||
|
action: "refresh",
|
||||||
|
tab: nav.activeTab(),
|
||||||
|
pane: nav.activePane(),
|
||||||
|
mode: nav.mode(),
|
||||||
|
}),
|
||||||
|
r: () =>
|
||||||
|
emit("nav.action", {
|
||||||
|
action: "refresh",
|
||||||
|
tab: nav.activeTab(),
|
||||||
|
pane: nav.activePane(),
|
||||||
|
mode: nav.mode(),
|
||||||
|
}),
|
||||||
|
play: () => audio.togglePlayback().catch(() => {}),
|
||||||
|
pause: () => audio.togglePlayback().catch(() => {}),
|
||||||
|
p: () => audio.togglePlayback().catch(() => {}),
|
||||||
|
next: () => advanceEpisode(1),
|
||||||
|
n: () => advanceEpisode(1),
|
||||||
|
prev: () => advanceEpisode(-1),
|
||||||
|
seek: (arg) => {
|
||||||
|
const n = Number(arg) || 0;
|
||||||
|
audio.seek(n).catch(() => {});
|
||||||
|
},
|
||||||
|
feed: () => nav.setActiveTab(TABS.FEED),
|
||||||
|
f: () => nav.setActiveTab(TABS.FEED),
|
||||||
|
shows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||||
|
myshows: () => nav.setActiveTab(TABS.MYSHOWS),
|
||||||
|
discover: () => nav.setActiveTab(TABS.DISCOVER),
|
||||||
|
d: () => nav.setActiveTab(TABS.DISCOVER),
|
||||||
|
search: () => nav.setActiveTab(TABS.SEARCH),
|
||||||
|
player: () => nav.setActiveTab(TABS.PLAYER),
|
||||||
|
settings: () => nav.setActiveTab(TABS.SETTINGS),
|
||||||
|
set: () => nav.setActiveTab(TABS.SETTINGS),
|
||||||
|
help: () => setShowHelp((v) => !v),
|
||||||
|
h: () => setShowHelp((v) => !v),
|
||||||
|
};
|
||||||
|
|
||||||
function runCommand(raw: string) {
|
function runCommand(raw: string) {
|
||||||
const cmd = raw.trim();
|
const cmd = raw.trim();
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
const name = cmd.split(/\s+/)[0].toLowerCase();
|
const name = cmd.split(/\s+/)[0].toLowerCase();
|
||||||
const arg = cmd.slice(name.length).trim();
|
const arg = cmd.slice(name.length).trim();
|
||||||
switch (name) {
|
const unknownCommand = () => {
|
||||||
case "q":
|
nav.setCommandError(`unknown command: ${name}`);
|
||||||
case "quit":
|
// re-enter command mode so the user sees the error + can correct
|
||||||
case "exit":
|
nav.enterCommand();
|
||||||
return process.exit(0);
|
nav.setCommandBuffer(cmd);
|
||||||
case "refresh":
|
};
|
||||||
case "r":
|
(COMMANDS[name] ?? unknownCommand)(arg);
|
||||||
emit("nav.action", {
|
|
||||||
action: "refresh",
|
|
||||||
tab: nav.activeTab(),
|
|
||||||
pane: nav.activePane(),
|
|
||||||
mode: nav.mode(),
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
case "play":
|
|
||||||
case "pause":
|
|
||||||
case "p":
|
|
||||||
audio.togglePlayback().catch(() => {});
|
|
||||||
break;
|
|
||||||
case "next":
|
|
||||||
case "n":
|
|
||||||
advanceEpisode(1);
|
|
||||||
break;
|
|
||||||
case "prev":
|
|
||||||
advanceEpisode(-1);
|
|
||||||
break;
|
|
||||||
case "seek": {
|
|
||||||
const n = Number(arg) || 0;
|
|
||||||
audio.seek(n).catch(() => {});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "feed":
|
|
||||||
case "f":
|
|
||||||
nav.setActiveTab(TABS.FEED);
|
|
||||||
break;
|
|
||||||
case "shows":
|
|
||||||
case "myshows":
|
|
||||||
nav.setActiveTab(TABS.MYSHOWS);
|
|
||||||
break;
|
|
||||||
case "discover":
|
|
||||||
case "d":
|
|
||||||
nav.setActiveTab(TABS.DISCOVER);
|
|
||||||
break;
|
|
||||||
case "search":
|
|
||||||
nav.setActiveTab(TABS.SEARCH);
|
|
||||||
break;
|
|
||||||
case "player":
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
break;
|
|
||||||
case "settings":
|
|
||||||
case "set":
|
|
||||||
nav.setActiveTab(TABS.SETTINGS);
|
|
||||||
break;
|
|
||||||
case "help":
|
|
||||||
case "h":
|
|
||||||
setShowHelp((v) => !v);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nav.setCommandError(`unknown command: ${name}`);
|
|
||||||
// re-enter command mode so the user sees the error + can correct
|
|
||||||
nav.enterCommand();
|
|
||||||
nav.setCommandBuffer(cmd);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command-mode key handling ───────────────────────────────────────────────
|
// ── Command-mode key handling ───────────────────────────────────────────────
|
||||||
@@ -206,6 +206,11 @@ export function Shell() {
|
|||||||
if (evt.name === "escape") {
|
if (evt.name === "escape") {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.setInputFocused(false);
|
nav.setInputFocused(false);
|
||||||
|
// Actually blur the focused renderable too — setting the flag alone
|
||||||
|
// leaves the opentui input owning keys, so nav keys would still be
|
||||||
|
// typed into it. Blurring fires our useInputFocusNav BLURRED handler
|
||||||
|
// (and re-blurs the SearchPage input via its `focused` prop).
|
||||||
|
renderer.currentFocusedRenderable?.blur();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,11 +241,13 @@ export function Shell() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={t.surface}
|
backgroundColor={
|
||||||
>
|
theme.transparentBackground() ? "transparent" : t.surface
|
||||||
|
}
|
||||||
|
>
|
||||||
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
{/* ── Middle row: tab list (pane 0) + active tab content (panes 1..n) ─ */}
|
||||||
<box flexDirection="row" flexGrow={1} width="100%">
|
<box flexDirection="row" flexGrow={1} width="100%">
|
||||||
<Show
|
<Show
|
||||||
@@ -252,7 +259,7 @@ export function Shell() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
{/* app root: the tab list is the CURRENT pane, nothing in UP */}
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={
|
parent={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={t.textMuted}>—</text>
|
<text fg={t.textMuted}>—</text>
|
||||||
@@ -276,7 +283,11 @@ export function Shell() {
|
|||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
height={1}
|
height={1}
|
||||||
width="100%"
|
width="100%"
|
||||||
backgroundColor={t.backgroundPanel ?? t.background}
|
backgroundColor={
|
||||||
|
theme.transparentBackground()
|
||||||
|
? "transparent"
|
||||||
|
: (t.backgroundPanel ?? t.background)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={nav.mode() === NavMode.COMMAND}
|
when={nav.mode() === NavMode.COMMAND}
|
||||||
@@ -458,18 +469,5 @@ function k_match_escape(evt: any): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Exposed so App can route an externally-triggered "play episode" (e.g. from
|
|
||||||
* search) into the player tab. */
|
|
||||||
export function playEpisodeAndSwitch(
|
|
||||||
nav: ReturnType<typeof useNavigation>,
|
|
||||||
audio: ReturnType<typeof useAudio>,
|
|
||||||
episode: import("@/types/episode").Episode,
|
|
||||||
) {
|
|
||||||
audio.play(episode);
|
|
||||||
nav.setActiveTab(TABS.PLAYER);
|
|
||||||
nav.enterTabContent(); // PLAYER is a depth-tab — drop into its content pane.
|
|
||||||
useAudioNavStore().setSource(AudioSource.FEED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-export Episode type for callers building pane trees.
|
// Re-export Episode type for callers building pane trees.
|
||||||
export type { Episode } from "@/types/episode";
|
export type { Episode } from "@/types/episode";
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { For } from "solid-js";
|
|
||||||
import { shortcuts } from "@/config/shortcuts";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
/** Yazi-style keybind reference. The Shell has its own overlay; this component
|
|
||||||
* is kept for embedding inside Settings or other surfaces. */
|
|
||||||
export function ShortcutHelp() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
title="Shortcuts"
|
|
||||||
style={{ flexDirection: "column", padding: 1 }}
|
|
||||||
>
|
|
||||||
<box style={{ flexDirection: "column" }}>
|
|
||||||
<For each={shortcuts}>
|
|
||||||
{(s) => (
|
|
||||||
<box style={{ flexDirection: "row" }} gap={2}>
|
|
||||||
<text fg={theme.accent}>{s.keys}</text>
|
|
||||||
<text fg={theme.text}>{s.action}</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { TABS, TabsCount } from "@/utils/navigation";
|
|
||||||
import { For } from "solid-js";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
import { useNavigation } from "@/context/NavigationContext";
|
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
|
||||||
{ id: TABS.FEED, label: "Feed" },
|
|
||||||
{ id: TABS.MYSHOWS, label: "My Shows" },
|
|
||||||
{ id: TABS.DISCOVER, label: "Discover" },
|
|
||||||
{ id: TABS.SEARCH, label: "Search" },
|
|
||||||
{ id: TABS.PLAYER, label: "Player" },
|
|
||||||
{ id: TABS.SETTINGS, label: "Settings" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TabNavigation() {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const { activeTab, setActiveTab, activeDepth } = useNavigation();
|
|
||||||
return (
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
|
|
||||||
backgroundColor={"transparent"}
|
|
||||||
style={{
|
|
||||||
flexDirection: "column",
|
|
||||||
width: 12,
|
|
||||||
height: TabsCount * 3 + 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<For each={tabs}>
|
|
||||||
{(tab) => (
|
|
||||||
<SelectableBox
|
|
||||||
border
|
|
||||||
height={3}
|
|
||||||
selected={() => tab.id == activeTab()}
|
|
||||||
onMouseDown={() => setActiveTab(tab.id)}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => tab.id == activeTab()}
|
|
||||||
primary
|
|
||||||
alignSelf="center"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</SelectableText>
|
|
||||||
</SelectableBox>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TabDefinition = {
|
|
||||||
id: TABS;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { For } from "solid-js";
|
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 { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
|
|
||||||
const TAB_LABEL: Record<TABS, string> = {
|
const TAB_LABEL: Record<TABS, string> = {
|
||||||
@@ -52,7 +53,11 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (t: TABS) =>
|
const focusFg = (t: TABS) =>
|
||||||
t === cursor() && active() ? theme.surface : theme.text;
|
t === cursor() && active()
|
||||||
|
? theme.surface
|
||||||
|
: t === cursor()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<For each={TAB_ORDER}>
|
<For each={TAB_ORDER}>
|
||||||
@@ -67,13 +72,23 @@ export function TabListPane(props: { muted?: boolean }) {
|
|||||||
: isActive() && !active()
|
: isActive() && !active()
|
||||||
? theme.accent
|
? theme.accent
|
||||||
: theme.text;
|
: theme.text;
|
||||||
|
const ref = useScrollIntoView(isCursor);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
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={() => {
|
||||||
|
// Click = hover + open, the yazi "open" of the row
|
||||||
|
// (switches to the tab and enters its content), the same
|
||||||
|
// as l/Enter. Restores mouse support the tab-strip
|
||||||
|
// refactor dropped.
|
||||||
|
nav.setTabCursor(tab);
|
||||||
|
nav.activateTabCursor();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
{/* ── selection marker (j/k cursor) ─────────────────────────── */}
|
||||||
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
<text fg={focusFg(tab)}>{isCursor() ? "❯" : " "}</text>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication configuration for PodTUI
|
|
||||||
* Authentication is DISABLED by default - users can opt-in
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
|
|
||||||
|
|
||||||
/** Default auth enabled state - DISABLED by default */
|
|
||||||
export const DEFAULT_AUTH_ENABLED = false
|
|
||||||
|
|
||||||
/** Authentication configuration */
|
|
||||||
export const AUTH_CONFIG = {
|
|
||||||
/** Whether auth is enabled by default */
|
|
||||||
defaultEnabled: DEFAULT_AUTH_ENABLED,
|
|
||||||
|
|
||||||
/** Code validation settings */
|
|
||||||
codeValidation: {
|
|
||||||
/** Code length (8 characters) */
|
|
||||||
codeLength: 8,
|
|
||||||
/** Allowed characters (alphanumeric) */
|
|
||||||
allowedChars: /^[A-Z0-9]+$/,
|
|
||||||
/** Code expiration time in minutes */
|
|
||||||
expirationMinutes: 15,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Password requirements */
|
|
||||||
password: {
|
|
||||||
minLength: 8,
|
|
||||||
requireUppercase: false,
|
|
||||||
requireLowercase: false,
|
|
||||||
requireNumber: false,
|
|
||||||
requireSpecial: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Email validation */
|
|
||||||
email: {
|
|
||||||
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Local storage keys */
|
|
||||||
storage: {
|
|
||||||
authState: "podtui_auth_state",
|
|
||||||
user: "podtui_user",
|
|
||||||
lastLogin: "podtui_last_login",
|
|
||||||
},
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** OAuth provider configurations */
|
|
||||||
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
|
|
||||||
{
|
|
||||||
id: OAuthProvider.GOOGLE,
|
|
||||||
name: "Google",
|
|
||||||
enabled: false, // Not feasible in terminal
|
|
||||||
description: "Sign in with Google (requires browser redirect)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OAuthProvider.APPLE,
|
|
||||||
name: "Apple",
|
|
||||||
enabled: false, // Not feasible in terminal
|
|
||||||
description: "Sign in with Apple (requires browser redirect)",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** Terminal OAuth limitation message */
|
|
||||||
export const OAUTH_LIMITATION_MESSAGE = `
|
|
||||||
OAuth authentication (Google, Apple) is not directly available in terminal applications.
|
|
||||||
|
|
||||||
To use OAuth:
|
|
||||||
1. Visit the web portal in your browser
|
|
||||||
2. Sign in with your preferred provider
|
|
||||||
3. Generate a sync code
|
|
||||||
4. Enter the code here to link your account
|
|
||||||
|
|
||||||
Alternatively, use email/password authentication or file-based sync.
|
|
||||||
`.trim()
|
|
||||||
@@ -11,7 +11,8 @@
|
|||||||
//
|
//
|
||||||
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
|
// Yazi heritage: j/k move, h/l swipe between panes, Enter open, Space select,
|
||||||
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
|
// v visual mode, gg/G top/bottom, [ ] switch tabs, 1-6 goto tab,
|
||||||
// : command bar, q quit, ~ help. Audio transport kept on shifted keys / ctrl.
|
// : / q command palette (q + Enter quits there), Q quick quit, ~ help.
|
||||||
|
// Audio transport kept on shifted keys / ctrl.
|
||||||
|
|
||||||
// ── Movement (within a pane) ─────────────────────────────────────────────
|
// ── Movement (within a pane) ─────────────────────────────────────────────
|
||||||
"move-down": ["j", "down"],
|
"move-down": ["j", "down"],
|
||||||
@@ -50,9 +51,11 @@
|
|||||||
"tab-goto-5": ["5"],
|
"tab-goto-5": ["5"],
|
||||||
"tab-goto-6": ["6"],
|
"tab-goto-6": ["6"],
|
||||||
|
|
||||||
// ── Command bar & help & quit ────────────────────────────────────────────
|
// ── Command palette & help & quit ────────────────────────────────────────
|
||||||
"command": [":"],
|
// q opens the command palette (neovim-style: type q + Enter to quit there).
|
||||||
"quit": ["q", "ctrl-c"],
|
// Q (shift+q) is the instant quick quit. ctrl-c also quits.
|
||||||
|
"command": [":", "q"],
|
||||||
|
"quit": ["Q", "ctrl-c"],
|
||||||
"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)
|
||||||
@@ -61,6 +64,7 @@
|
|||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"unsubscribe": ["x"], // unsubscribe focused show in 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
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* Yazi-style keybind reference (mirrors src/config/keybinds.jsonc).
|
|
||||||
* Shown in help overlays; the canonical source remains keybinds.jsonc.
|
|
||||||
* Edit that file (or ~/.config/podtui/keybinds.jsonc) to remap.
|
|
||||||
*/
|
|
||||||
export const shortcuts = [
|
|
||||||
{ keys: "j / k", action: "Move down / up (within pane)" },
|
|
||||||
{ keys: "h / l", action: "Swipe to prev / next pane" },
|
|
||||||
{ keys: "J / K", action: "Jump 5 lines down / up" },
|
|
||||||
{ keys: "ctrl-d / u", action: "Half page down / up" },
|
|
||||||
{ keys: "g g / G", action: "Go to top / bottom of list" },
|
|
||||||
{ keys: "1-6", action: "Go to tab 1-6" },
|
|
||||||
{ keys: "[ / ]", action: "Previous / next tab" },
|
|
||||||
{ keys: "Enter", action: "Open / activate focused item" },
|
|
||||||
{ keys: "Space", action: "Toggle selection on item" },
|
|
||||||
{ keys: "v", action: "Enter visual (range) select mode" },
|
|
||||||
{ keys: "ctrl-a / ctrl-r", action: "Select all / invert selection" },
|
|
||||||
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
|
||||||
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
|
||||||
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
|
||||||
{ keys: ", / .", action: "Sort / toggle hidden" },
|
|
||||||
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
|
||||||
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
|
||||||
{ keys: "~ / F1", action: "Help" },
|
|
||||||
{ keys: "q", action: "Quit" },
|
|
||||||
] as const;
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export const syncFormats = {
|
|
||||||
json: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".json",
|
|
||||||
},
|
|
||||||
xml: {
|
|
||||||
version: "1.0",
|
|
||||||
extension: ".xml",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]
|
|
||||||
@@ -67,24 +67,12 @@ export type KeybindActionName =
|
|||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "unsubscribe"
|
||||||
| "audio-toggle"
|
| "audio-toggle"
|
||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
| "audio-seek-forward"
|
| "audio-seek-forward"
|
||||||
| "audio-seek-backward"
|
| "audio-seek-backward";
|
||||||
// legacy compat (kept so older callers don't crash)
|
|
||||||
| "select"
|
|
||||||
| "leader"
|
|
||||||
| "inverseModifier"
|
|
||||||
| "cycle"
|
|
||||||
| "dive"
|
|
||||||
| "out"
|
|
||||||
| "up"
|
|
||||||
| "down"
|
|
||||||
| "left"
|
|
||||||
| "right"
|
|
||||||
| "audio-pause"
|
|
||||||
| "audio-play";
|
|
||||||
|
|
||||||
/** Resolved config: action -> list of alternative stroke-sequences. */
|
/** Resolved config: action -> list of alternative stroke-sequences. */
|
||||||
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
export type KeybindsResolved = Partial<Record<KeybindActionName, KeybindSpec>>;
|
||||||
@@ -145,7 +133,7 @@ export function parseBindingSpec(spec: KeybindSpec | undefined): Stroke[][] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
/** Build a Stroke from a keyboard event (opentui shape: name + ctrl/shift/meta). */
|
||||||
export function strokeFromEvent(evt: {
|
function strokeFromEvent(evt: {
|
||||||
name: string;
|
name: string;
|
||||||
ctrl?: boolean;
|
ctrl?: boolean;
|
||||||
meta?: boolean;
|
meta?: boolean;
|
||||||
@@ -153,7 +141,7 @@ export function strokeFromEvent(evt: {
|
|||||||
}): Stroke {
|
}): Stroke {
|
||||||
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
// Uppercase letter events from opentui arrive as name="q" + shift; normalize.
|
||||||
return {
|
return {
|
||||||
key: (evt.name ?? "").toLowerCase(),
|
key: evt.name.toLowerCase(),
|
||||||
ctrl: !!evt.ctrl,
|
ctrl: !!evt.ctrl,
|
||||||
shift: !!evt.shift,
|
shift: !!evt.shift,
|
||||||
meta: !!evt.meta,
|
meta: !!evt.meta,
|
||||||
@@ -170,7 +158,7 @@ function strokeEq(a: Stroke, b: Stroke): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A human label for a stroke, for the status bar / help. */
|
/** A human label for a stroke, for the status bar / help. */
|
||||||
export function strokeLabel(s: Stroke): string {
|
function strokeLabel(s: Stroke): string {
|
||||||
let out = "";
|
let out = "";
|
||||||
if (s.ctrl) out += "C-";
|
if (s.ctrl) out += "C-";
|
||||||
if (s.meta) out += "M-";
|
if (s.meta) out += "M-";
|
||||||
@@ -179,7 +167,7 @@ export function strokeLabel(s: Stroke): string {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sequenceLabel(seq: Stroke[]): string {
|
function sequenceLabel(seq: Stroke[]): string {
|
||||||
return seq.map(strokeLabel).join(" ");
|
return seq.map(strokeLabel).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,17 +325,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `isInverting` kept for legacy callers; yazi model has no inverse mod,
|
|
||||||
// so it always reports false. Migrated callers should use tryMatch().
|
|
||||||
function isInverting(_evt: {
|
|
||||||
name: string;
|
|
||||||
ctrl?: boolean;
|
|
||||||
meta?: boolean;
|
|
||||||
shift?: boolean;
|
|
||||||
}): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load().catch(() => {});
|
load().catch(() => {});
|
||||||
});
|
});
|
||||||
@@ -365,7 +342,6 @@ export const { use: useKeybinds, provider: KeybindProvider } =
|
|||||||
pending,
|
pending,
|
||||||
match,
|
match,
|
||||||
tryMatch,
|
tryMatch,
|
||||||
isInverting,
|
|
||||||
print,
|
print,
|
||||||
save,
|
save,
|
||||||
load,
|
load,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
|
||||||
import { createStore, produce } from "solid-js/store";
|
import { createStore, produce } from "solid-js/store";
|
||||||
import { useRenderer } from "@opentui/solid";
|
import { useRenderer } from "@opentui/solid";
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} from "../utils/syntax-highlighter";
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
||||||
|
import { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
setupThemeSignalHandler,
|
setupThemeSignalHandler,
|
||||||
@@ -84,6 +86,8 @@ export type ThemeResolved = {
|
|||||||
muted?: RGBA;
|
muted?: RGBA;
|
||||||
surface?: RGBA;
|
surface?: RGBA;
|
||||||
selectedListItemText?: RGBA;
|
selectedListItemText?: RGBA;
|
||||||
|
/** Theme declares a transparent (terminal-bg-visible) background. */
|
||||||
|
transparent?: boolean;
|
||||||
layerBackgrounds?: {
|
layerBackgrounds?: {
|
||||||
layer0: RGBA;
|
layer0: RGBA;
|
||||||
layer1: RGBA;
|
layer1: RGBA;
|
||||||
@@ -94,6 +98,61 @@ export type ThemeResolved = {
|
|||||||
thinkingOpacity?: number;
|
thinkingOpacity?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TerminalColors with no values — used to keep the "system" theme rendering
|
||||||
|
* with default ANSI colors + the detected dark/light mode when the terminal
|
||||||
|
* cannot answer OSC queries (e.g. inside tmux without OSC forwarding).
|
||||||
|
*/
|
||||||
|
const EMPTY_TERMINAL_COLORS: TerminalColors = {
|
||||||
|
palette: Array.from({ length: 16 }, () => null),
|
||||||
|
defaultForeground: null,
|
||||||
|
defaultBackground: null,
|
||||||
|
cursorColor: null,
|
||||||
|
mouseForeground: null,
|
||||||
|
mouseBackground: null,
|
||||||
|
tekForeground: null,
|
||||||
|
tekBackground: null,
|
||||||
|
highlightBackground: null,
|
||||||
|
highlightForeground: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cached macOS appearance (dark/light), independent of the terminal. */
|
||||||
|
let cachedOsMode: "dark" | "light" | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect the terminal's dark/light mode.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. The terminal's real background color (OSC 11 response) — terminal-specific.
|
||||||
|
* 2. The macOS appearance via `defaults read -g AppleInterfaceStyle` — works
|
||||||
|
* even inside tmux, where OSC queries are usually not forwarded.
|
||||||
|
* An unset value means light mode (macOS defaults to light).
|
||||||
|
* 3. null → keep whatever mode is currently active.
|
||||||
|
*/
|
||||||
|
function detectSystemMode(
|
||||||
|
colors: TerminalColors | null,
|
||||||
|
): "dark" | "light" | null {
|
||||||
|
const fromBg = detectModeFromBackground(colors?.defaultBackground);
|
||||||
|
if (fromBg) return fromBg;
|
||||||
|
|
||||||
|
if (process.platform === "darwin" && cachedOsMode === null) {
|
||||||
|
let style: string | null = null;
|
||||||
|
try {
|
||||||
|
style = execFileSync("defaults", ["read", "-g", "AppleInterfaceStyle"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 2000,
|
||||||
|
})
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
// Unset → light appearance (macOS default).
|
||||||
|
}
|
||||||
|
cachedOsMode = style?.includes("dark") ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedOsMode;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Theme context using the createSimpleContext pattern.
|
* Theme context using the createSimpleContext pattern.
|
||||||
*
|
*
|
||||||
@@ -195,6 +254,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── dark/light mode detection ─────────────────────────────────────────
|
||||||
|
// The provider starts with a hardcoded mode (e.g. "dark"); detect the
|
||||||
|
// real one from the terminal's background color (OSC 11) or, when that
|
||||||
|
// is unavailable (tmux without OSC forwarding), the OS appearance.
|
||||||
|
const detectedMode = detectSystemMode(colors);
|
||||||
|
if (detectedMode && detectedMode !== store.mode) {
|
||||||
|
setStore("mode", detectedMode);
|
||||||
|
emitThemeModeChanged(detectedMode);
|
||||||
|
}
|
||||||
|
|
||||||
const hasPalette = Boolean(
|
const hasPalette = Boolean(
|
||||||
colors?.palette?.some((value) => Boolean(value)),
|
colors?.palette?.some((value) => Boolean(value)),
|
||||||
);
|
);
|
||||||
@@ -203,13 +272,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!hasPalette && !hasDefaultColors) {
|
if (!hasPalette && !hasDefaultColors) {
|
||||||
// No system colors available, fall back to default
|
// No system colors available — the terminal can't answer OSC queries
|
||||||
// This happens when the terminal doesn't support OSC palette queries
|
// (e.g. inside tmux, or unsupported terminals). Keep the "system"
|
||||||
// (e.g., running inside tmux, or on unsupported terminals)
|
// theme anyway: the detected dark/light mode plus default ANSI colors
|
||||||
|
// still produce a usable, mode-correct palette.
|
||||||
if (store.active === "system") {
|
if (store.active === "system") {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
draft.active = "catppuccin";
|
draft.system = colors ?? EMPTY_TERMINAL_COLORS;
|
||||||
draft.ready = true;
|
draft.ready = true;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -293,6 +363,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
mode() {
|
mode() {
|
||||||
return store.mode;
|
return store.mode;
|
||||||
},
|
},
|
||||||
|
/** Whether the app background should be transparent (no solid fill):
|
||||||
|
* either the global preference is on, or the selected theme declares
|
||||||
|
* transparency (e.g. the system theme). */
|
||||||
|
transparentBackground() {
|
||||||
|
return (
|
||||||
|
appStore.state().settings.transparentBackground ||
|
||||||
|
values().transparent === true
|
||||||
|
);
|
||||||
|
},
|
||||||
setMode(mode: "dark" | "light") {
|
setMode(mode: "dark" | "light") {
|
||||||
setStore("mode", mode);
|
setStore("mode", mode);
|
||||||
emitThemeModeChanged(mode);
|
emitThemeModeChanged(mode);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
*
|
*
|
||||||
* parent | current | preview
|
* parent | current | preview
|
||||||
*
|
*
|
||||||
* Layout ratios (1/7 : 3/7 : 3/7 in the final remake) live in
|
* Layout ratios (1/5 : 2/5 : 2/5 in the final remake) 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.
|
||||||
@@ -267,6 +267,9 @@ export function createNavigation() {
|
|||||||
/** The tab the root's cursor is hovering (independent of activeTab). */
|
/** The tab the root's cursor is hovering (independent of activeTab). */
|
||||||
const tabCursor = (): TABS => tabCursorSignal();
|
const tabCursor = (): TABS => tabCursorSignal();
|
||||||
|
|
||||||
|
/** Directly set the root's tab cursor (e.g. a mouse click on a tab row). */
|
||||||
|
const setTabCursorTo = (tab: TABS) => setTabCursor(tab);
|
||||||
|
|
||||||
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
/** Move the root's cursor to the adjacent tab (clamped, no wrap). */
|
||||||
const moveTabCursor = (dir: -1 | 1) => {
|
const moveTabCursor = (dir: -1 | 1) => {
|
||||||
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
setTabCursor((c) => Math.max(1, Math.min(TabsCount, c + dir)) as TABS);
|
||||||
@@ -469,6 +472,7 @@ export function createNavigation() {
|
|||||||
enterTabContent,
|
enterTabContent,
|
||||||
backToTabRoot,
|
backToTabRoot,
|
||||||
tabCursor,
|
tabCursor,
|
||||||
|
setTabCursor: setTabCursorTo,
|
||||||
moveTabCursor,
|
moveTabCursor,
|
||||||
activateTabCursor,
|
activateTabCursor,
|
||||||
// pane focus
|
// pane focus
|
||||||
@@ -488,11 +492,7 @@ export function createNavigation() {
|
|||||||
exitVisual,
|
exitVisual,
|
||||||
// modes
|
// modes
|
||||||
setActiveTabSignal: setActiveTab,
|
setActiveTabSignal: setActiveTab,
|
||||||
setActiveDepth: setPane, // legacy alias
|
|
||||||
activeDepth: activePane, // legacy alias
|
|
||||||
setInputFocused,
|
setInputFocused,
|
||||||
nextPane: () => {}, // legacy noop; swipe() replaces this
|
|
||||||
prevPane: () => {},
|
|
||||||
setMode,
|
setMode,
|
||||||
enterCommand,
|
enterCommand,
|
||||||
enterInput,
|
enterInput,
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ function startPolling(): void {
|
|||||||
const progressStore = useProgressStore();
|
const progressStore = useProgressStore();
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
// Update platform media position
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.setPosition(pos);
|
media.setPosition(pos);
|
||||||
}
|
}
|
||||||
@@ -215,6 +214,9 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
|
|
||||||
startPolling();
|
startPolling();
|
||||||
emit("player.play", { episodeId: episode.id });
|
emit("player.play", { episodeId: episode.id });
|
||||||
|
// Distinct from "player.play" (which also fires on resume): signals a
|
||||||
|
// fresh episode start so Shell can honor the auto-jump-to-player pref.
|
||||||
|
emit("player.started", { episodeId: episode.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Playback failed");
|
setError(err instanceof Error ? err.message : "Playback failed");
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -285,7 +287,6 @@ async function stop(): Promise<void> {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
// Clear platform media controls
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -332,12 +333,8 @@ async function doSetSpeed(spd: number): Promise<void> {
|
|||||||
setSpeed(clamped);
|
setSpeed(clamped);
|
||||||
|
|
||||||
// Sync back to app store
|
// Sync back to app store
|
||||||
try {
|
const appStore = useAppStore();
|
||||||
const appStore = useAppStore();
|
appStore.updateSettings({ playbackSpeed: clamped });
|
||||||
appStore.updateSettings({ playbackSpeed: clamped });
|
|
||||||
} catch {
|
|
||||||
// Store may not be available
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchBackend(name: BackendName): Promise<void> {
|
async function switchBackend(name: BackendName): Promise<void> {
|
||||||
@@ -347,14 +344,12 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
const vol = volume();
|
const vol = volume();
|
||||||
const spd = speed();
|
const spd = speed();
|
||||||
|
|
||||||
// Stop current backend
|
|
||||||
if (backend) {
|
if (backend) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
backend.dispose();
|
backend.dispose();
|
||||||
backend = null;
|
backend = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new backend
|
|
||||||
backend = createAudioBackend(name);
|
backend = createAudioBackend(name);
|
||||||
setBackendName(backend.name);
|
setBackendName(backend.name);
|
||||||
setAvailablePlayers(detectPlayers());
|
setAvailablePlayers(detectPlayers());
|
||||||
@@ -388,14 +383,10 @@ export function useAudio(): AudioControls {
|
|||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed from app store
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
try {
|
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);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Store may not be available yet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { createSignal, onCleanup } from "solid-js"
|
|
||||||
|
|
||||||
type CacheOptions<T> = {
|
|
||||||
fetcher: () => Promise<T>
|
|
||||||
intervalMs?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useCachedData = <T,>(options: CacheOptions<T>) => {
|
|
||||||
const [data, setData] = createSignal<T | null>(null)
|
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const value = await options.fetcher()
|
|
||||||
setData(() => value)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Failed to load data")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh()
|
|
||||||
|
|
||||||
if (options.intervalMs) {
|
|
||||||
const interval = setInterval(refresh, options.intervalMs)
|
|
||||||
onCleanup(() => clearInterval(interval))
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data, loading, error, refresh }
|
|
||||||
}
|
|
||||||
65
src/hooks/useInputFocusNav.ts
Normal file
65
src/hooks/useInputFocusNav.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* useInputFocusNav — returns a `ref` callback for an `<input>` (or any
|
||||||
|
* focusable renderable) that holds the navigation store's `inputFocused`
|
||||||
|
* flag true while the renderable has focus.
|
||||||
|
*
|
||||||
|
* Why: the Shell keyboard router (see `components/Shell.tsx`) yields keys to
|
||||||
|
* whatever is focused only when `nav.inputFocused()` is true; otherwise it
|
||||||
|
* dispatches navigation keybinds (j/k/h/…). Forms rendered inside the
|
||||||
|
* depth-stack (e.g. the Settings "Add Source" RSS form) don't drive that
|
||||||
|
* flag, so typing into them *also* fired the navigation keybinds. Wiring the
|
||||||
|
* flag to each input's real focus/blur state fixes that.
|
||||||
|
*
|
||||||
|
* A module-level counter guards the blur→focus ordering gap that occurs when
|
||||||
|
* tabbing between two inputs in the same form (the old input blurs before the
|
||||||
|
* new one focuses) so the flag never flickers off mid-handoff.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { onCleanup } from "solid-js";
|
||||||
|
import { RenderableEvents } from "@opentui/core";
|
||||||
|
import { useNavigation } from "@/context/NavigationContext";
|
||||||
|
|
||||||
|
// Inputs (managed by this hook) currently holding focus.
|
||||||
|
let focusedCount = 0;
|
||||||
|
|
||||||
|
export function useInputFocusNav() {
|
||||||
|
const nav = useNavigation();
|
||||||
|
let current: any | undefined;
|
||||||
|
|
||||||
|
const onFocused = () => {
|
||||||
|
focusedCount++;
|
||||||
|
nav.setInputFocused(true);
|
||||||
|
};
|
||||||
|
const onBlurred = () => {
|
||||||
|
focusedCount = Math.max(0, focusedCount - 1);
|
||||||
|
if (focusedCount === 0) nav.setInputFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const detach = (el: any) => {
|
||||||
|
el.off(RenderableEvents.FOCUSED, onFocused);
|
||||||
|
el.off(RenderableEvents.BLURRED, onBlurred);
|
||||||
|
// Treat a focused element being torn down as a blur so the counter
|
||||||
|
// doesn't leak and leave inputFocused stuck on.
|
||||||
|
if (el.focused) onBlurred();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ref = (el: any) => {
|
||||||
|
if (current && current !== el) detach(current);
|
||||||
|
current = el;
|
||||||
|
if (el) {
|
||||||
|
el.on(RenderableEvents.FOCUSED, onFocused);
|
||||||
|
el.on(RenderableEvents.BLURRED, onBlurred);
|
||||||
|
// If the renderable is already focused when attached, count it.
|
||||||
|
if (el.focused) onFocused();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (current) {
|
||||||
|
detach(current);
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
@@ -21,20 +21,6 @@ export type MediaKeyAction =
|
|||||||
| "media.seekBackward"
|
| "media.seekBackward"
|
||||||
| "media.speedCycle";
|
| "media.speedCycle";
|
||||||
|
|
||||||
/** Key-to-action mappings for multimedia controls */
|
|
||||||
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
|
|
||||||
// Common terminal media keys — these overlap with Player.tsx local
|
|
||||||
// bindings, but Player guards on `props.focused` so the global
|
|
||||||
// handler fires independently when the player tab is *not* active.
|
|
||||||
//
|
|
||||||
// When Player IS focused both handlers fire, but since the audio
|
|
||||||
// actions are idempotent (toggle = toggle, seek = additive) having
|
|
||||||
// them called twice for the same keypress is avoided by the event
|
|
||||||
// bus approach — the audio hook only processes event-bus events, and
|
|
||||||
// Player.tsx calls audio methods directly. We therefore guard with
|
|
||||||
// a "playerFocused" flag passed via options.
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MultimediaKeysOptions {
|
export interface MultimediaKeysOptions {
|
||||||
/** When true, skip handling (Player.tsx handles keys locally) */
|
/** When true, skip handling (Player.tsx handles keys locally) */
|
||||||
playerFocused?: () => boolean;
|
playerFocused?: () => boolean;
|
||||||
|
|||||||
115
src/hooks/useScrollIntoView.ts
Normal file
115
src/hooks/useScrollIntoView.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* useScrollIntoView — keeps the ref'd row visible inside its enclosing
|
||||||
|
* `<scrollbox>` whenever the focus accessor is true.
|
||||||
|
*
|
||||||
|
* OpenTUI's `ScrollBoxRenderable` has built-in *keyboard* scrolling but does
|
||||||
|
* NOT auto-scroll to follow a programmatically-focused child (the app moves
|
||||||
|
* its own cursor via the yazi nav store, so the scrollbox never sees a key
|
||||||
|
* for row movement). Every scrollable panel therefore drifts out of view the
|
||||||
|
* moment the cursor crosses the viewport edge.
|
||||||
|
*
|
||||||
|
* Attach the returned `ref` callback to the element that represents the
|
||||||
|
* focused row of a scrollable list and call the hook with a `when()` that is
|
||||||
|
* true for exactly that row (e.g. `() => index() === focus()`). Whenever the
|
||||||
|
* accessor flips true, the nearest ScrollBoxRenderable is scrolled just enough
|
||||||
|
* to bring the element back into the viewport — a "nearest-edge" scroll:
|
||||||
|
* • scroll up only if the row's top is clipped above the viewport,
|
||||||
|
* • scroll down only if the row's bottom is clipped below the viewport,
|
||||||
|
* never snapping more than necessary (matches yazi list behaviour).
|
||||||
|
*
|
||||||
|
* Timing: for ordinary cursor movement (j/k) the list layout does not change
|
||||||
|
* — only background colour and the cursor glyph flip — so the focused row's
|
||||||
|
* Yoga-computed position is already valid when this effect fires, and the
|
||||||
|
* scroll is applied synchronously. On first mount / content population the
|
||||||
|
* layout for the new rows has not yet been computed, so the hook polls on a
|
||||||
|
* short timer until layout resolves (bounded so it can never loop forever).
|
||||||
|
*/
|
||||||
|
import { createEffect, onCleanup } from "solid-js";
|
||||||
|
|
||||||
|
/** Walk up the renderable parent chain to the nearest ScrollBoxRenderable,
|
||||||
|
* identified by its `viewport` + `content` + numeric `scrollTop`. */
|
||||||
|
function findScrollBox(node: any): any | null {
|
||||||
|
let p: any = node?.parent;
|
||||||
|
while (p) {
|
||||||
|
if (p.viewport && p.content && typeof p.scrollTop === "number") return p;
|
||||||
|
p = p.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of retries while waiting for Yoga layout to populate the
|
||||||
|
* row/viewport dimensions (handles the first-mount frame). */
|
||||||
|
const MAX_RETRIES = 12;
|
||||||
|
const RETRY_MS = 16;
|
||||||
|
|
||||||
|
export function useScrollIntoView(when: () => boolean) {
|
||||||
|
let el: any = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const ref = (node: any) => {
|
||||||
|
el = node;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTimer = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compute the target scrollTop that brings `el` into the viewport of its
|
||||||
|
* enclosing scrollbox, or `null` if no scroll is possible / needed yet.
|
||||||
|
* Returns the decision so the caller knows whether to poll again. */
|
||||||
|
const compute = (): { scroll: number | null; ready: boolean } => {
|
||||||
|
const node = el;
|
||||||
|
if (!node) return { scroll: null, ready: false };
|
||||||
|
const sb = findScrollBox(node);
|
||||||
|
if (!sb) return { scroll: null, ready: false };
|
||||||
|
const vp = sb.viewport;
|
||||||
|
const top: number = sb.scrollTop ?? 0;
|
||||||
|
const vpH: number = vp?.height ?? 0;
|
||||||
|
// The scrollbar's onChange sets `content.translateY = -scrollTop`, so
|
||||||
|
// the child's cumulative `.y` already includes `-scrollTop`; subtracting
|
||||||
|
// the viewport's stable `.y` and re-adding `scrollTop` recovers the
|
||||||
|
// row's layout-space offset within the content (scroll-independent).
|
||||||
|
const childTop: number = node.y ?? 0;
|
||||||
|
const childH: number = node.height ?? 0;
|
||||||
|
if (!vpH || !childH) return { scroll: null, ready: false };
|
||||||
|
|
||||||
|
const offset = childTop - (vp.y ?? 0) + top;
|
||||||
|
let target = top;
|
||||||
|
if (offset < top) target = offset;
|
||||||
|
else if (offset + childH > top + vpH) target = offset + childH - vpH;
|
||||||
|
const max = Math.max(0, (sb.scrollHeight ?? 0) - vpH);
|
||||||
|
if (target > max) target = max;
|
||||||
|
if (target < 0) target = 0;
|
||||||
|
target = Math.round(target);
|
||||||
|
if (target === Math.round(top)) return { scroll: null, ready: true };
|
||||||
|
return { scroll: target, ready: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryScroll = (retriesLeft: number) => {
|
||||||
|
const { scroll, ready } = compute();
|
||||||
|
if (!ready) {
|
||||||
|
if (retriesLeft > 0)
|
||||||
|
timer = setTimeout(() => tryScroll(retriesLeft - 1), RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scroll != null) {
|
||||||
|
const sb = findScrollBox(el);
|
||||||
|
if (sb) sb.scrollTo(scroll);
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!when()) return;
|
||||||
|
clearTimer();
|
||||||
|
tryScroll(MAX_RETRIES);
|
||||||
|
});
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
clearTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
437
src/index.tsx
437
src/index.tsx
@@ -1,225 +1,254 @@
|
|||||||
const VERSION = "0.2.0";
|
import type { Feed } from "./types/feed"
|
||||||
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
|
const VERSION = "0.3.0";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
query: string | null;
|
query: string | null;
|
||||||
play: string | null;
|
play: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(): CliArgs {
|
function parseArgs(): CliArgs {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const result: CliArgs = {
|
const result: CliArgs = {
|
||||||
version: false,
|
version: false,
|
||||||
query: null,
|
query: null,
|
||||||
play: null,
|
play: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
if (arg === "--version" || arg === "-v") {
|
if (arg === "--version" || arg === "-v") {
|
||||||
result.version = true;
|
result.version = true;
|
||||||
} else if (arg === "--query" || arg === "-q") {
|
} else if (arg === "--query" || arg === "-q") {
|
||||||
result.query = args[i + 1] || "";
|
result.query = args[i + 1] || "";
|
||||||
i++;
|
i++;
|
||||||
} else if (arg === "--play" || arg === "-p") {
|
} else if (arg === "--play" || arg === "-p") {
|
||||||
result.play = args[i + 1] || "";
|
result.play = args[i + 1] || "";
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cliArgs = parseArgs();
|
const cliArgs = parseArgs();
|
||||||
|
|
||||||
if (cliArgs.version) {
|
if (cliArgs.version) {
|
||||||
console.log(`PodTUI version ${VERSION}`);
|
console.log(`PodTUI version ${VERSION}`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Find the most recent episode across all feeds */
|
||||||
|
function findLatestEpisode(
|
||||||
|
feeds: Feed[],
|
||||||
|
): { feed: Feed; episode: Episode } | null {
|
||||||
|
let latest: { feed: Feed; episode: Episode } | null = null
|
||||||
|
let latestDate = 0
|
||||||
|
|
||||||
|
for (const feed of feeds) {
|
||||||
|
if (feed.episodes.length === 0) continue
|
||||||
|
const ep = feed.episodes[0]
|
||||||
|
const epDate =
|
||||||
|
ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate)
|
||||||
|
if (epDate > latestDate) {
|
||||||
|
latestDate = epDate
|
||||||
|
latest = { feed, episode: ep }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search feeds by title and print matching shows */
|
||||||
|
function handleQuery(feeds: Feed[], query: string): void {
|
||||||
|
const normalizedQuery = query.toLowerCase()
|
||||||
|
|
||||||
|
const matches = feeds.filter((feed) => {
|
||||||
|
const title = feed.podcast.title.toLowerCase()
|
||||||
|
return title.includes(normalizedQuery)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matches.length === 0) {
|
||||||
|
console.log(`No shows found matching: ${query}`)
|
||||||
|
if (feeds.length > 0) {
|
||||||
|
console.log("\nAvailable shows:")
|
||||||
|
feeds.slice(0, 5).forEach((feed) => {
|
||||||
|
console.log(` - ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
if (feeds.length > 5) {
|
||||||
|
console.log(` ... and ${feeds.length - 5} more`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matches.length === 1) {
|
||||||
|
const feed = matches[0]
|
||||||
|
console.log(`\n${feed.podcast.title}`)
|
||||||
|
if (feed.podcast.description) {
|
||||||
|
console.log(
|
||||||
|
feed.podcast.description.substring(0, 200) +
|
||||||
|
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
const date =
|
||||||
|
ep.pubDate instanceof Date
|
||||||
|
? ep.pubDate.toLocaleDateString()
|
||||||
|
: String(ep.pubDate)
|
||||||
|
console.log(` ${idx + 1}. ${ep.title} (${date})`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nClosest matches for "${query}":`)
|
||||||
|
matches.slice(0, 5).forEach((feed, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${feed.podcast.title}`)
|
||||||
|
})
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve and play an episode from `arg` (title path or "latest") */
|
||||||
|
async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
||||||
|
const normalizedArg = arg.toLowerCase()
|
||||||
|
|
||||||
|
let feedResult: Feed | null = null
|
||||||
|
let episodeResult: Episode | null = null
|
||||||
|
|
||||||
|
if (normalizedArg === "latest") {
|
||||||
|
const latest = findLatestEpisode(feeds)
|
||||||
|
if (latest) {
|
||||||
|
feedResult = latest.feed
|
||||||
|
episodeResult = latest.episode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = normalizedArg.split("/")
|
||||||
|
const showQuery = parts[0]
|
||||||
|
const episodeQuery = parts[1]
|
||||||
|
|
||||||
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
|
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingFeeds.length === 0) {
|
||||||
|
console.log(`No show found matching: ${showQuery}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const feed = matchingFeeds[0]
|
||||||
|
|
||||||
|
if (!episodeQuery) {
|
||||||
|
if (feed.episodes.length > 0) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
console.log(`No episodes available for: ${feed.podcast.title}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} else if (episodeQuery === "latest") {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = feed.episodes[0]
|
||||||
|
} else {
|
||||||
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
|
ep.title.toLowerCase().includes(episodeQuery),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (matchingEpisode) {
|
||||||
|
feedResult = feed
|
||||||
|
episodeResult = matchingEpisode
|
||||||
|
} else {
|
||||||
|
console.log(`Episode not found: ${episodeQuery}`)
|
||||||
|
console.log(`Available episodes for ${feed.podcast.title}:`)
|
||||||
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
|
console.log(` ${idx + 1}. ${ep.title}`)
|
||||||
|
})
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!feedResult || !episodeResult) {
|
||||||
|
console.log("Could not find episode to play")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nPlaying: ${episodeResult.title}`)
|
||||||
|
console.log(`Show: ${feedResult.podcast.title}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createAudioBackend } = await import("./utils/audio-player")
|
||||||
|
const backend = createAudioBackend()
|
||||||
|
if (episodeResult.audioUrl) {
|
||||||
|
await backend.play(episodeResult.audioUrl)
|
||||||
|
console.log("Playback started (use the UI to control)")
|
||||||
|
} else {
|
||||||
|
console.log("No audio URL available for this episode")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playback error:", err)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliArgs.query !== null || cliArgs.play !== null) {
|
if (cliArgs.query !== null || cliArgs.play !== null) {
|
||||||
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
|
import("./utils/feeds-persistence")
|
||||||
const feeds = await loadFeedsFromFile();
|
.then(async ({ loadFeedsFromFile }) => {
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
|
||||||
if (cliArgs.query !== null) {
|
if (cliArgs.query !== null) {
|
||||||
const query = cliArgs.query;
|
handleQuery(feeds, cliArgs.query)
|
||||||
const normalizedQuery = query.toLowerCase();
|
}
|
||||||
|
|
||||||
const matches = feeds.filter((feed) => {
|
if (cliArgs.play !== null) {
|
||||||
const title = feed.podcast.title.toLowerCase();
|
await handlePlay(feeds, cliArgs.play)
|
||||||
return title.includes(normalizedQuery);
|
}
|
||||||
});
|
})
|
||||||
|
.catch((err) => {
|
||||||
if (matches.length === 0) {
|
console.error("Error:", err);
|
||||||
console.log(`No shows found matching: ${query}`);
|
process.exit(1);
|
||||||
if (feeds.length > 0) {
|
});
|
||||||
console.log("\nAvailable shows:");
|
|
||||||
feeds.slice(0, 5).forEach((feed) => {
|
|
||||||
console.log(` - ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
if (feeds.length > 5) {
|
|
||||||
console.log(` ... and ${feeds.length - 5} more`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length === 1) {
|
|
||||||
const feed = matches[0];
|
|
||||||
console.log(`\n${feed.podcast.title}`);
|
|
||||||
if (feed.podcast.description) {
|
|
||||||
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
|
||||||
}
|
|
||||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
|
|
||||||
console.log(` ${idx + 1}. ${ep.title} (${date})`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nClosest matches for "${query}":`);
|
|
||||||
matches.slice(0, 5).forEach((feed, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
|
||||||
});
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliArgs.play !== null) {
|
|
||||||
const playArg = cliArgs.play;
|
|
||||||
const normalizedArg = playArg.toLowerCase();
|
|
||||||
|
|
||||||
let feedResult: typeof feeds[0] | null = null;
|
|
||||||
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
|
||||||
|
|
||||||
if (normalizedArg === "latest") {
|
|
||||||
let latestFeed: typeof feeds[0] | null = null;
|
|
||||||
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
|
||||||
let latestDate = 0;
|
|
||||||
|
|
||||||
for (const feed of feeds) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
const ep = feed.episodes[0];
|
|
||||||
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
|
||||||
if (epDate > latestDate) {
|
|
||||||
latestDate = epDate;
|
|
||||||
latestFeed = feed;
|
|
||||||
latestEpisode = ep;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
feedResult = latestFeed;
|
|
||||||
episodeResult = latestEpisode;
|
|
||||||
} else {
|
|
||||||
const parts = normalizedArg.split("/");
|
|
||||||
const showQuery = parts[0];
|
|
||||||
const episodeQuery = parts[1];
|
|
||||||
|
|
||||||
const matchingFeeds = feeds.filter((feed) =>
|
|
||||||
feed.podcast.title.toLowerCase().includes(showQuery)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingFeeds.length === 0) {
|
|
||||||
console.log(`No show found matching: ${showQuery}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const feed = matchingFeeds[0];
|
|
||||||
|
|
||||||
if (!episodeQuery) {
|
|
||||||
if (feed.episodes.length > 0) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else if (episodeQuery === "latest") {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = feed.episodes[0];
|
|
||||||
} else {
|
|
||||||
const matchingEpisode = feed.episodes.find((ep) =>
|
|
||||||
ep.title.toLowerCase().includes(episodeQuery)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingEpisode) {
|
|
||||||
feedResult = feed;
|
|
||||||
episodeResult = matchingEpisode;
|
|
||||||
} else {
|
|
||||||
console.log(`Episode not found: ${episodeQuery}`);
|
|
||||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
|
||||||
console.log(` ${idx + 1}. ${ep.title}`);
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!feedResult || !episodeResult) {
|
|
||||||
console.log("Could not find episode to play");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
|
||||||
console.log(`Show: ${feedResult.podcast.title}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { createAudioBackend } = await import("./utils/audio-player");
|
|
||||||
const backend = createAudioBackend();
|
|
||||||
if (episodeResult.audioUrl) {
|
|
||||||
await backend.play(episodeResult.audioUrl);
|
|
||||||
console.log("Playback started (use the UI to control)");
|
|
||||||
} else {
|
|
||||||
console.log("No audio URL available for this episode");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Playback error:", err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
console.error("Error:", err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
import("@opentui/solid").then(async ({ render, useRenderer }) => {
|
||||||
const { App } = await import("./App");
|
const { App } = await import("./App");
|
||||||
const { ThemeProvider } = await import("./context/ThemeContext");
|
const { ThemeProvider } = await import("./context/ThemeContext");
|
||||||
const toast = await import("./ui/toast");
|
const toast = await import("./ui/toast");
|
||||||
const { KeybindProvider } = await import("./context/KeybindContext");
|
const { KeybindProvider } = await import("./context/KeybindContext");
|
||||||
const { NavigationProvider } = await import("./context/NavigationContext");
|
const { NavigationProvider } = await import("./context/NavigationContext");
|
||||||
const { DialogProvider } = await import("./ui/dialog");
|
const { DialogProvider } = await import("./ui/dialog");
|
||||||
const { CommandProvider } = await import("./ui/command");
|
const { CommandProvider } = await import("./ui/command");
|
||||||
|
|
||||||
function RendererSetup(props: { children: unknown }) {
|
function RendererSetup(props: { children: unknown }) {
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
renderer.disableStdoutInterception();
|
renderer.disableStdoutInterception();
|
||||||
return props.children;
|
return props.children;
|
||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<RendererSetup>
|
<RendererSetup>
|
||||||
<toast.ToastProvider>
|
<toast.ToastProvider>
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<KeybindProvider>
|
<KeybindProvider>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<App />
|
<App />
|
||||||
<toast.Toast />
|
<toast.Toast />
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</KeybindProvider>
|
</KeybindProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</toast.ToastProvider>
|
</toast.ToastProvider>
|
||||||
</RendererSetup>
|
</RendererSetup>
|
||||||
),
|
),
|
||||||
{ useThread: false },
|
{ useThread: false },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
* DiscoverPage — yazi depth-stack view of discoverable podcasts.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — category list. Parent pane shows the muted
|
* depth 0 (current) — category list. Parent pane shows the muted
|
||||||
* placeholder (1/7 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
|
* preview — detail of the hovered item (category summary, or
|
||||||
* podcast detail + subscribe action).
|
* podcast detail + subscribe action).
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>`; 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) or subscribes (on a
|
||||||
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
* podcast); `h` pops a depth (noop at 0). j/k move only within the current
|
||||||
* column. Moving through categories at depth 0 updates the store's selected
|
* column. Moving through categories at depth 0 updates the store's selected
|
||||||
@@ -28,8 +28,9 @@ 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 { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const DiscoverPaneCount = 1;
|
export const DiscoverPaneCount = 1;
|
||||||
|
|
||||||
@@ -39,7 +40,6 @@ function DiscoverPage() {
|
|||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -146,7 +146,11 @@ function DiscoverPage() {
|
|||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.surface : theme.text;
|
i === lf && active
|
||||||
|
? theme.surface
|
||||||
|
: i === lf
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
@@ -154,28 +158,32 @@ function DiscoverPage() {
|
|||||||
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
: `${focusedCategory()?.name ?? "Discover"} · ${podcasts().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── 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
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
// mounts/unmounts cleanly on depth change.
|
// mounts/unmounts cleanly on depth change.
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
<Show when={depth() >= 1} fallback={<TabListPane muted />}>
|
||||||
<For each={categories()}>
|
<For each={categories()}>
|
||||||
{(cat, index) => (
|
{(cat, index) => {
|
||||||
<box
|
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(), nav.depthFocus(0), false)}
|
flexDirection="row"
|
||||||
>
|
gap={1}
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
paddingLeft={1}
|
||||||
{index() === nav.depthFocus(0) ? "❯" : " "}
|
paddingRight={1}
|
||||||
</text>
|
backgroundColor={focusBg(index(), lf(), false)}
|
||||||
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
>
|
||||||
{cat.name}
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
</text>
|
{index() === nav.depthFocus(0) ? "❯" : " "}
|
||||||
</box>
|
</text>
|
||||||
)}
|
<text fg={focusFg(index(), nav.depthFocus(0), false)}>
|
||||||
|
{cat.name}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
);
|
);
|
||||||
@@ -188,9 +196,10 @@ function DiscoverPage() {
|
|||||||
<For each={categories()}>
|
<For each={categories()}>
|
||||||
{(cat, index) => {
|
{(cat, index) => {
|
||||||
const lf = () => focusedCatIdx();
|
const lf = () => focusedCatIdx();
|
||||||
const selected = () => cat.id === discoverStore.selectedCategory();
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -206,11 +215,6 @@ function DiscoverPage() {
|
|||||||
{index() === lf() ? "❯" : " "}
|
{index() === lf() ? "❯" : " "}
|
||||||
</text>
|
</text>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
<text fg={focusFg(index(), lf(), isActive())}>{cat.name}</text>
|
||||||
<Show when={selected()}>
|
|
||||||
<text fg={index() === lf() ? theme.surface : theme.accent}>
|
|
||||||
*
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -229,8 +233,10 @@ function DiscoverPage() {
|
|||||||
<For each={podcasts()}>
|
<For each={podcasts()}>
|
||||||
{(podcast, index) => {
|
{(podcast, index) => {
|
||||||
const lf = () => focusedPodIdx();
|
const lf = () => focusedPodIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -276,7 +282,7 @@ function DiscoverPage() {
|
|||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered category
|
// depth 0 preview: shows for the hovered category
|
||||||
<Show
|
<Show
|
||||||
when={focusedCategory()}
|
when={focusedCategory()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -286,16 +292,35 @@ function DiscoverPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(cat) => (
|
{(cat) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<box flexDirection="column" gap={0} padding={1}>
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
<strong>{cat().name}</strong>
|
<strong>{cat().name}</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg={theme.textSecondary}>
|
<Show when={(cat() as any).description}>
|
||||||
{(cat() as any).description ??
|
<text fg={theme.textSecondary}>{(cat() as any).description}</text>
|
||||||
`Browse top podcasts in ${cat().name}.`}
|
</Show>
|
||||||
</text>
|
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
<Show
|
||||||
|
when={podcasts().length > 0}
|
||||||
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
No shows in this category yet. :refresh
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={podcasts()}>
|
||||||
|
{(pod) => (
|
||||||
|
<box flexDirection="column" gap={0}>
|
||||||
|
<text fg={theme.text}>{pod.title}</text>
|
||||||
|
<Show when={pod.author}>
|
||||||
|
<text fg={muted()} paddingLeft={2}>
|
||||||
|
by {pod.author}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -347,7 +372,7 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
/**
|
|
||||||
* PodcastCard component - Reusable card for displaying podcast info
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Show, For } from "solid-js";
|
|
||||||
import type { Podcast } from "@/types/podcast";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
type PodcastCardProps = {
|
|
||||||
podcast: Podcast;
|
|
||||||
selected: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
onSelect?: () => void;
|
|
||||||
onSubscribe?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function PodcastCard(props: PodcastCardProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const handleSubscribeClick = () => {
|
|
||||||
props.onSubscribe?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.selected}
|
|
||||||
flexDirection="column"
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={props.onSelect}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<SelectableText selected={() => props.selected} primary>
|
|
||||||
<strong>{props.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
|
|
||||||
<Show when={props.podcast.isSubscribed}>
|
|
||||||
<text fg={theme.success}>[+]</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Author */}
|
|
||||||
<Show when={props.podcast.author && !props.compact}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
by {props.podcast.author}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Show when={props.podcast.description && !props.compact}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{props.podcast.description!.length > 80
|
|
||||||
? props.podcast.description!.slice(0, 80) + "..."
|
|
||||||
: props.podcast.description}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/**<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
marginTop={props.compact ? 0 : 1}
|
|
||||||
/>**/}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
|
||||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
|
||||||
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
|
|
||||||
</For>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={props.selected}>
|
|
||||||
<box onMouseDown={handleSubscribeClick}>
|
|
||||||
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
|
|
||||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed detail view component for PodTUI
|
|
||||||
* Shows podcast info and episode list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import type { Feed } from "@/types/feed";
|
|
||||||
import type { Episode } from "@/types/episode";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
interface FeedDetailProps {
|
|
||||||
feed: Feed;
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
onPlayEpisode?: (episode: Episode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedDetail(props: FeedDetailProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
const [showInfo, setShowInfo] = createSignal(true);
|
|
||||||
|
|
||||||
const episodes = () => {
|
|
||||||
// Sort episodes by publication date (newest first)
|
|
||||||
return [...props.feed.episodes].sort(
|
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
if (hrs > 0) {
|
|
||||||
return `${hrs}h ${mins % 60}m`;
|
|
||||||
}
|
|
||||||
return `${mins}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return format(date, "MMM d, yyyy");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
|
||||||
const eps = episodes();
|
|
||||||
|
|
||||||
if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "i") {
|
|
||||||
setShowInfo((v) => !v);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "v") {
|
|
||||||
props.feed.podcast.onToggleVisibility?.(props.feed.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
|
||||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
const episode = eps[selectedIndex()];
|
|
||||||
if (episode && props.onPlayEpisode) {
|
|
||||||
props.onPlayEpisode(episode);
|
|
||||||
}
|
|
||||||
} else if (key.name === "home" || key.name === "g") {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.name === "end") {
|
|
||||||
setSelectedIndex(eps.length - 1);
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 10));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
handleKeyPress(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
{/* Header with back button */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Podcast info section */}
|
|
||||||
<Show when={showInfo()}>
|
|
||||||
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
|
||||||
<SelectableText selected={() => false} primary>
|
|
||||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
{props.feed.podcast.author && (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>by</SelectableText>
|
|
||||||
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
<box height={1} />
|
|
||||||
<SelectableText selected={() => false} tertiary>
|
|
||||||
{props.feed.podcast.description?.slice(0, 200)}
|
|
||||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
|
||||||
</SelectableText>
|
|
||||||
<box height={1} />
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
|
|
||||||
</box>
|
|
||||||
<SelectableText selected={() => false} tertiary>
|
|
||||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
|
||||||
</SelectableText>
|
|
||||||
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Episodes header */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<SelectableText selected={() => false} primary>
|
|
||||||
<strong>Episodes</strong>
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Episode list */}
|
|
||||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
|
||||||
<For each={episodes()}>
|
|
||||||
{(episode, index) => (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={() => {
|
|
||||||
setSelectedIndex(index());
|
|
||||||
if (props.onPlayEpisode) {
|
|
||||||
props.onPlayEpisode(episode);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{index() === selectedIndex() ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => index() === selectedIndex()}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
|
||||||
{episode.title}
|
|
||||||
</SelectableText>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
|
|
||||||
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
|
|
||||||
</box>
|
|
||||||
</SelectableBox>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
|
|
||||||
{/* Help text */}
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed filter component for PodTUI
|
|
||||||
* Toggle and filter options for feed list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
|
||||||
import type { FeedFilter } from "@/types/feed";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface FeedFilterProps {
|
|
||||||
filter: FeedFilter;
|
|
||||||
focused?: boolean;
|
|
||||||
onFilterChange: (filter: FeedFilter) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
|
|
||||||
|
|
||||||
export function FeedFilterComponent(props: FeedFilterProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
|
|
||||||
const [searchValue, setSearchValue] = createSignal(
|
|
||||||
props.filter.searchQuery || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
if (focusField() === "visibility") {
|
|
||||||
cycleVisibility();
|
|
||||||
} else if (focusField() === "sort") {
|
|
||||||
cycleSort();
|
|
||||||
} else if (focusField() === "pinned") {
|
|
||||||
togglePinned();
|
|
||||||
} else if (focusField() === "private") {
|
|
||||||
togglePrivate();
|
|
||||||
}
|
|
||||||
} else if (key.name === "space") {
|
|
||||||
if (focusField() === "pinned") {
|
|
||||||
togglePinned();
|
|
||||||
} else if (focusField() === "private") {
|
|
||||||
togglePrivate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleVisibility = () => {
|
|
||||||
const current = props.filter.visibility;
|
|
||||||
let next: FeedVisibility | "all";
|
|
||||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
|
||||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
|
||||||
else next = "all";
|
|
||||||
props.onFilterChange({ ...props.filter, visibility: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleSort = () => {
|
|
||||||
const sortOptions: FeedSortField[] = [
|
|
||||||
FeedSortField.UPDATED,
|
|
||||||
FeedSortField.TITLE,
|
|
||||||
FeedSortField.EPISODE_COUNT,
|
|
||||||
FeedSortField.LATEST_EPISODE,
|
|
||||||
];
|
|
||||||
const currentIndex = sortOptions.indexOf(
|
|
||||||
props.filter.sortBy as FeedSortField,
|
|
||||||
);
|
|
||||||
const nextIndex = (currentIndex + 1) % sortOptions.length;
|
|
||||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePinned = () => {
|
|
||||||
props.onFilterChange({
|
|
||||||
...props.filter,
|
|
||||||
pinnedOnly: !props.filter.pinnedOnly,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePrivate = () => {
|
|
||||||
props.onFilterChange({
|
|
||||||
...props.filter,
|
|
||||||
showPrivate: !props.filter.showPrivate,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchInput = (value: string) => {
|
|
||||||
setSearchValue(value);
|
|
||||||
props.onFilterChange({ ...props.filter, searchQuery: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityLabel = () => {
|
|
||||||
const vis = props.filter.visibility;
|
|
||||||
if (vis === "all") return "All";
|
|
||||||
if (vis === "public") return "Public";
|
|
||||||
return "Private";
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityColor = () => {
|
|
||||||
const vis = props.filter.visibility;
|
|
||||||
if (vis === "public") return theme.success;
|
|
||||||
if (vis === "private") return theme.warning;
|
|
||||||
return theme.text;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortLabel = () => {
|
|
||||||
const sort = props.filter.sortBy;
|
|
||||||
switch (sort) {
|
|
||||||
case "title":
|
|
||||||
return "Title";
|
|
||||||
case "episodeCount":
|
|
||||||
return "Episodes";
|
|
||||||
case "latestEpisode":
|
|
||||||
return "Latest";
|
|
||||||
case "updated":
|
|
||||||
default:
|
|
||||||
return "Updated";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Filter Feeds</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
|
||||||
{/* Visibility filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
|
|
||||||
borderColor={theme.border}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
|
|
||||||
Show:
|
|
||||||
</text>
|
|
||||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Sort filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
|
|
||||||
<text fg={theme.text}>{sortLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Pinned filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
|
|
||||||
Pinned:
|
|
||||||
</text>
|
|
||||||
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
|
|
||||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Private filter */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
|
|
||||||
Private:
|
|
||||||
</text>
|
|
||||||
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
|
|
||||||
{props.filter.showPrivate ? "Yes" : "No"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Search box */}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
|
|
||||||
<input
|
|
||||||
value={searchValue()}
|
|
||||||
onInput={handleSearchInput}
|
|
||||||
placeholder="Filter by name..."
|
|
||||||
focused={props.focused && focusField() === "search"}
|
|
||||||
width={25}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed item component for PodTUI
|
|
||||||
* Displays a single feed/podcast in the list
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Feed, FeedVisibility } from "@/types/feed";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
interface FeedItemProps {
|
|
||||||
feed: Feed;
|
|
||||||
isSelected: boolean;
|
|
||||||
showEpisodeCount?: boolean;
|
|
||||||
showLastUpdated?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedItem(props: FeedItemProps) {
|
|
||||||
const formatDate = (date: Date): string => {
|
|
||||||
return format(date, "MMM d");
|
|
||||||
};
|
|
||||||
|
|
||||||
const episodeCount = () => props.feed.episodes.length;
|
|
||||||
const unplayedCount = () => {
|
|
||||||
// This would be calculated based on episode status
|
|
||||||
return props.feed.episodes.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityIcon = () => {
|
|
||||||
return props.feed.visibility === "public" ? "[P]" : "[*]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityColor = () => {
|
|
||||||
return props.feed.visibility === "public" ? theme.success : theme.warning;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pinnedIndicator = () => {
|
|
||||||
return props.feed.isPinned ? "*" : " ";
|
|
||||||
};
|
|
||||||
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
if (props.compact) {
|
|
||||||
// Compact single-line view
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
flexDirection="row"
|
|
||||||
gap={1}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={() => {}}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{visibilityIcon()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.feed.customName || props.feed.podcast.title}
|
|
||||||
</SelectableText>
|
|
||||||
{props.showEpisodeCount && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
({episodeCount()})
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full view with details
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
flexDirection="column"
|
|
||||||
gap={0}
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={() => {}}
|
|
||||||
>
|
|
||||||
{/* Title row */}
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{props.isSelected ? ">" : " "}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{visibilityIcon()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
secondary
|
|
||||||
>
|
|
||||||
{pinnedIndicator()}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
|
||||||
{props.showEpisodeCount && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{episodeCount()} episodes ({unplayedCount()} new)
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
{props.showLastUpdated && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
Updated: {formatDate(props.feed.lastUpdated)}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{props.feed.podcast.description && (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.isSelected}
|
|
||||||
paddingLeft={4}
|
|
||||||
paddingTop={0}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{props.feed.podcast.description.slice(0, 60)}
|
|
||||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
/**
|
|
||||||
* Feed list component for PodTUI
|
|
||||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal, For, Show } from "solid-js";
|
|
||||||
import { useKeyboard } from "@opentui/solid";
|
|
||||||
import { FeedItem } from "./FeedItem";
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
|
||||||
import { FeedVisibility, FeedSortField } from "@/types/feed";
|
|
||||||
import type { Feed } from "@/types/feed";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface FeedListProps {
|
|
||||||
focused?: boolean;
|
|
||||||
compact?: boolean;
|
|
||||||
showEpisodeCount?: boolean;
|
|
||||||
showLastUpdated?: boolean;
|
|
||||||
onSelectFeed?: (feed: Feed) => void;
|
|
||||||
onOpenFeed?: (feed: Feed) => void;
|
|
||||||
onFocusChange?: (focused: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FeedList(props: FeedListProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const feedStore = useFeedStore();
|
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0);
|
|
||||||
|
|
||||||
const filteredFeeds = () => feedStore.getFilteredFeeds();
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string }) => {
|
|
||||||
if (key.name === "escape") {
|
|
||||||
props.onFocusChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const feeds = filteredFeeds();
|
|
||||||
|
|
||||||
if (key.name === "up" || key.name === "k") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 1));
|
|
||||||
} else if (key.name === "down" || key.name === "j") {
|
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed && props.onOpenFeed) {
|
|
||||||
props.onOpenFeed(feed);
|
|
||||||
}
|
|
||||||
} else if (key.name === "home" || key.name === "g") {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
} else if (key.name === "end") {
|
|
||||||
setSelectedIndex(feeds.length - 1);
|
|
||||||
} else if (key.name === "pageup") {
|
|
||||||
setSelectedIndex((i) => Math.max(0, i - 5));
|
|
||||||
} else if (key.name === "pagedown") {
|
|
||||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
|
|
||||||
} else if (key.name === "p") {
|
|
||||||
// Toggle pin on selected feed
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed) {
|
|
||||||
feedStore.togglePinned(feed.id);
|
|
||||||
}
|
|
||||||
} else if (key.name === "v") {
|
|
||||||
// Toggle visibility on selected feed
|
|
||||||
const feed = feeds[selectedIndex()];
|
|
||||||
if (feed) {
|
|
||||||
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
|
|
||||||
feedStore.updateFeed(feed.id, { visibility: newVisibility });
|
|
||||||
}
|
|
||||||
} else if (key.name === "f") {
|
|
||||||
// Cycle visibility filter
|
|
||||||
cycleVisibilityFilter();
|
|
||||||
} else if (key.name === "s") {
|
|
||||||
// Cycle sort
|
|
||||||
cycleSortField();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify selection change
|
|
||||||
const selectedFeed = feeds[selectedIndex()];
|
|
||||||
if (selectedFeed && props.onSelectFeed) {
|
|
||||||
props.onSelectFeed(selectedFeed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useKeyboard((key) => {
|
|
||||||
if (!props.focused) return;
|
|
||||||
handleKeyPress(key);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cycleVisibilityFilter = () => {
|
|
||||||
const current = feedStore.filter().visibility;
|
|
||||||
let next: FeedVisibility | "all";
|
|
||||||
if (current === "all") next = FeedVisibility.PUBLIC;
|
|
||||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
|
|
||||||
else next = "all";
|
|
||||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleSortField = () => {
|
|
||||||
const sortOptions: FeedSortField[] = [
|
|
||||||
FeedSortField.UPDATED,
|
|
||||||
FeedSortField.TITLE,
|
|
||||||
FeedSortField.EPISODE_COUNT,
|
|
||||||
FeedSortField.LATEST_EPISODE,
|
|
||||||
];
|
|
||||||
const current = feedStore.filter().sortBy as FeedSortField;
|
|
||||||
const idx = sortOptions.indexOf(current);
|
|
||||||
const next = sortOptions[(idx + 1) % sortOptions.length];
|
|
||||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibilityLabel = () => {
|
|
||||||
const vis = feedStore.filter().visibility;
|
|
||||||
if (vis === "all") return "All";
|
|
||||||
if (vis === "public") return "Public";
|
|
||||||
return "Private";
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortLabel = () => {
|
|
||||||
const sort = feedStore.filter().sortBy;
|
|
||||||
switch (sort) {
|
|
||||||
case "title":
|
|
||||||
return "Title";
|
|
||||||
case "episodeCount":
|
|
||||||
return "Episodes";
|
|
||||||
case "latestEpisode":
|
|
||||||
return "Latest";
|
|
||||||
default:
|
|
||||||
return "Updated";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeedClick = (feed: Feed, index: number) => {
|
|
||||||
setSelectedIndex(index);
|
|
||||||
if (props.onSelectFeed) {
|
|
||||||
props.onSelectFeed(feed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFeedDoubleClick = (feed: Feed) => {
|
|
||||||
if (props.onOpenFeed) {
|
|
||||||
props.onOpenFeed(feed);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
{/* Header with filter controls */}
|
|
||||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>My Feeds</strong>
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
|
|
||||||
</box>
|
|
||||||
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>[s] {sortLabel()}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Feed list in scrollbox */}
|
|
||||||
<Show
|
|
||||||
when={filteredFeeds().length > 0}
|
|
||||||
fallback={
|
|
||||||
<box border padding={2} borderColor={theme.border}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<scrollbox height={15} focused={props.focused}>
|
|
||||||
<For each={filteredFeeds()}>
|
|
||||||
{(feed, index) => (
|
|
||||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
|
||||||
<FeedItem
|
|
||||||
feed={feed}
|
|
||||||
isSelected={index() === selectedIndex()}
|
|
||||||
compact={props.compact}
|
|
||||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
|
||||||
showLastUpdated={props.showLastUpdated ?? true}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
{/* Navigation help */}
|
|
||||||
<box paddingTop={0}>
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
* duplicated My Shows (shows → episodes). Per design, the Feed tab now just
|
||||||
* shows the full flat episodes list immediately.
|
* shows the full flat episodes list immediately.
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>` (the shared parent|current|preview
|
* Renders entirely through `<PaneRow>` (the shared parent|current|preview
|
||||||
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
|
* primitive). `l`/Enter plays the focused episode; `h` pops back to the tab
|
||||||
* root. j/k move only within the current column. The Shell router drives
|
* root. j/k move only within the current column. The Shell router drives
|
||||||
* everything over `nav.action`; this page only handles list/preview data.
|
* everything over `nav.action`; this page only handles list/preview data.
|
||||||
@@ -35,8 +35,9 @@ 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 { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
|
|
||||||
@@ -167,7 +168,11 @@ function FeedPage() {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||||
i === listFocus && active ? theme.surface : theme.text;
|
i === listFocus && active
|
||||||
|
? theme.surface
|
||||||
|
: i === listFocus
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
|
||||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -187,8 +192,10 @@ function FeedPage() {
|
|||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(item, index) => {
|
{(item, index) => {
|
||||||
const fi = () => focusedEpIdx();
|
const fi = () => focusedEpIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -290,7 +297,7 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
* MyShowsPage — yazi depth-stack view of subscribed shows.
|
||||||
*
|
*
|
||||||
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
* depth 0 (current) — subscribed shows. Parent pane shows the muted
|
||||||
* placeholder (1/7 slot kept).
|
* placeholder (1/5 slot kept).
|
||||||
* 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.
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>`; 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.
|
||||||
*/
|
*/
|
||||||
@@ -31,8 +31,9 @@ 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 { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
@@ -164,6 +165,16 @@ export function MyShowsPage() {
|
|||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
if (show) feedStore.refreshFeed(show.id).catch(() => {});
|
||||||
},
|
},
|
||||||
|
unsubscribe: () => {
|
||||||
|
if (depth() !== 0) return;
|
||||||
|
const show = selectedShow();
|
||||||
|
if (show) {
|
||||||
|
// unsubscribe = remove feed + purge its downloaded files
|
||||||
|
feedStore.removeFeed(show.id);
|
||||||
|
downloadStore.removeDownloadsForFeed(show.id).catch(() => {});
|
||||||
|
ensureFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
nav.move(delta, curLen());
|
nav.move(delta, curLen());
|
||||||
@@ -188,7 +199,11 @@ export function MyShowsPage() {
|
|||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
const focusBg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
const focusFg = (i: number, lf: number, active: boolean) =>
|
||||||
i === lf && active ? theme.surface : theme.text;
|
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 = () =>
|
||||||
@@ -197,7 +212,6 @@ export function MyShowsPage() {
|
|||||||
: `${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) ─────────────
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ──────────
|
|
||||||
// Stable <Show> gate (not a ternary root swap) so the parent list
|
// Stable <Show> gate (not a ternary root swap) so the parent list
|
||||||
// mounts/unmounts cleanly on depth change.
|
// mounts/unmounts cleanly on depth change.
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
@@ -205,8 +219,10 @@ export function MyShowsPage() {
|
|||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -243,8 +259,10 @@ export function MyShowsPage() {
|
|||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => focusedShowIdx();
|
const lf = () => focusedShowIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -283,8 +301,10 @@ export function MyShowsPage() {
|
|||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(ep, index) => {
|
{(ep, index) => {
|
||||||
const lf = () => focusedEpIdx();
|
const lf = () => focusedEpIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -361,7 +381,7 @@ export function MyShowsPage() {
|
|||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
</text>
|
</text>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter/l: open · h: back</text>
|
<text fg={muted()}>enter/l: open · h: back · x: unsubscribe</text>
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -408,7 +428,7 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* depth 0 (parent) — tab list (muted, read-only).
|
* depth 0 (parent) — tab list (muted, read-only).
|
||||||
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
* depth 0 (current) — the single now-playing pane (rich view + controls).
|
||||||
*
|
*
|
||||||
* No preview pane (YaziPaneRow `panes={2}`). Audio transport (play/pause,
|
* No preview pane (PaneRow `panes={2}`). Audio transport (play/pause,
|
||||||
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
* next/prev, seek) is handled globally by the Shell router (P/N/B/</>); this
|
||||||
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
* page only renders the now-playing surface. `h` at depth 0 returns to the
|
||||||
* tab root.
|
* tab root.
|
||||||
@@ -17,7 +17,7 @@ import { useAudio } from "@/hooks/useAudio";
|
|||||||
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";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
|
||||||
export const PlayerPaneCount = 1;
|
export const PlayerPaneCount = 1;
|
||||||
@@ -116,7 +116,7 @@ export function PlayerPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
parentLabel="Up"
|
parentLabel="Up"
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}
|
}
|
||||||
reader.start(position, speed);
|
reader.start(position, speed);
|
||||||
|
|
||||||
// Start render loop
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -140,11 +139,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const renderFrame = () => {
|
const renderFrame = () => {
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
||||||
|
|
||||||
// Read available PCM samples from the stream
|
|
||||||
const count = reader.read(sampleBuffer);
|
const count = reader.read(sampleBuffer);
|
||||||
if (count === 0) return;
|
if (count === 0) return;
|
||||||
|
|
||||||
// Feed samples to cavacore → get frequency bars
|
|
||||||
const input =
|
const input =
|
||||||
count < sampleBuffer.length
|
count < sampleBuffer.length
|
||||||
? sampleBuffer.subarray(0, count)
|
? sampleBuffer.subarray(0, count)
|
||||||
@@ -198,7 +195,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
if (reader) {
|
if (reader) {
|
||||||
@@ -222,7 +218,6 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const bars = barData();
|
const bars = barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
|
|
||||||
// If no data yet, show empty placeholder
|
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { Show } from "solid-js";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { SourceBadge } from "./SourceBadge";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable";
|
|
||||||
|
|
||||||
type ResultCardProps = {
|
|
||||||
result: SearchResult;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onSubscribe?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResultCard(props: ResultCardProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const podcast = () => props.result.podcast;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={() => props.selected}
|
|
||||||
flexDirection="column"
|
|
||||||
padding={1}
|
|
||||||
onMouseDown={props.onSelect}
|
|
||||||
>
|
|
||||||
<box
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={2} alignItems="center">
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
<strong>{podcast().title}</strong>
|
|
||||||
</SelectableText>
|
|
||||||
<SourceBadge
|
|
||||||
sourceId={props.result.sourceId}
|
|
||||||
sourceName={props.result.sourceName}
|
|
||||||
sourceType={props.result.sourceType}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
<Show when={podcast().isSubscribed}>
|
|
||||||
<text fg={theme.success}>[Subscribed]</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show when={podcast().author}>
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
by {podcast().author}
|
|
||||||
</SelectableText>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={podcast().description}>
|
|
||||||
{(description) => (
|
|
||||||
<SelectableText
|
|
||||||
selected={() => props.selected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{description().length > 120
|
|
||||||
? description().slice(0, 120) + "..."
|
|
||||||
: description()}
|
|
||||||
</SelectableText>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={(podcast().categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
|
||||||
<text fg={theme.warning}>[{category}]</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={!podcast().isSubscribed}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
width={18}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.stopPropagation?.();
|
|
||||||
props.onSubscribe?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</SelectableBox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { Show } from "solid-js";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { SourceBadge } from "./SourceBadge";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
type ResultDetailProps = {
|
|
||||||
result?: SearchResult;
|
|
||||||
onSubscribe?: (result: SearchResult) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ResultDetail(props: ResultDetailProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
|
|
||||||
<Show
|
|
||||||
when={props.result}
|
|
||||||
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
|
|
||||||
>
|
|
||||||
{(result) => (
|
|
||||||
<>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>{result().podcast.title}</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<SourceBadge
|
|
||||||
sourceId={result().sourceId}
|
|
||||||
sourceName={result().sourceName}
|
|
||||||
sourceType={result().sourceType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Show when={result().podcast.author}>
|
|
||||||
<text fg={theme.textMuted}>by {result().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={result().podcast.description}>
|
|
||||||
<text fg={theme.textMuted}>{result().podcast.description}</text>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={(result().podcast.categories ?? []).length > 0}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
{(result().podcast.categories ?? []).map((category) => (
|
|
||||||
<text fg={theme.warning}>[{category}]</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<Show when={!result().podcast.isSubscribed}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
width={18}
|
|
||||||
onMouseDown={() => props.onSubscribe?.(result())}
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>[+] Add to Feeds</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={result().podcast.isSubscribed}>
|
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
|
||||||
</Show>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchHistory component for displaying and managing search history
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js"
|
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
|
||||||
import { SelectableBox, SelectableText } from "@/components/Selectable"
|
|
||||||
|
|
||||||
type SearchHistoryProps = {
|
|
||||||
history: string[]
|
|
||||||
focused: boolean
|
|
||||||
selectedIndex: number
|
|
||||||
onSelect?: (query: string) => void
|
|
||||||
onRemove?: (query: string) => void
|
|
||||||
onClear?: () => void
|
|
||||||
onChange?: (index: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchHistory(props: SearchHistoryProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const handleSearchClick = (index: number, query: string) => {
|
|
||||||
props.onChange?.(index)
|
|
||||||
props.onSelect?.(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveClick = (query: string) => {
|
|
||||||
props.onRemove?.(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<box flexDirection="row" justifyContent="space-between">
|
|
||||||
<text fg={theme.textMuted}>Recent Searches</text>
|
|
||||||
<Show when={props.history.length > 0}>
|
|
||||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
|
||||||
<text fg={theme.error}>[Clear All]</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<Show
|
|
||||||
when={props.history.length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg={theme.textMuted}>No recent searches</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<scrollbox height={10}>
|
|
||||||
<box flexDirection="column">
|
|
||||||
<For each={props.history}>
|
|
||||||
{(query, index) => {
|
|
||||||
const isSelected = () => index() === props.selectedIndex && props.focused
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SelectableBox
|
|
||||||
selected={isSelected}
|
|
||||||
flexDirection="row"
|
|
||||||
justifyContent="space-between"
|
|
||||||
padding={0}
|
|
||||||
paddingLeft={1}
|
|
||||||
paddingRight={1}
|
|
||||||
onMouseDown={() => handleSearchClick(index(), query)}
|
|
||||||
>
|
|
||||||
<SelectableText
|
|
||||||
selected={isSelected}
|
|
||||||
tertiary
|
|
||||||
>
|
|
||||||
{">"}
|
|
||||||
</SelectableText>
|
|
||||||
<SelectableText
|
|
||||||
selected={isSelected}
|
|
||||||
primary
|
|
||||||
>
|
|
||||||
{query}
|
|
||||||
</SelectableText>
|
|
||||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
|
||||||
<text fg={theme.error}>[x]</text>
|
|
||||||
</box>
|
|
||||||
</SelectableBox>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -38,8 +38,9 @@ import {
|
|||||||
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 } from "@/types/source";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const SearchPaneCount = 1;
|
export const SearchPaneCount = 1;
|
||||||
|
|
||||||
@@ -204,7 +205,11 @@ function SearchPage() {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
||||||
i === listFocus && active ? theme.surface : theme.text;
|
i === listFocus && active
|
||||||
|
? theme.surface
|
||||||
|
: i === listFocus
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
|
||||||
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
// ── parent pane: previous-depth content (tab list at depth 0) ──────────────
|
||||||
const parentContent = () => (
|
const parentContent = () => (
|
||||||
@@ -256,8 +261,10 @@ function SearchPage() {
|
|||||||
<For each={recents()}>
|
<For each={recents()}>
|
||||||
{(query, index) => {
|
{(query, index) => {
|
||||||
const lf = () => focus(0);
|
const lf = () => focus(0);
|
||||||
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -302,8 +309,10 @@ function SearchPage() {
|
|||||||
<For each={results()}>
|
<For each={results()}>
|
||||||
{(result, index) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
gap={0}
|
gap={0}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -380,8 +389,7 @@ function SearchPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<Show when={result().podcast.description}>
|
<Show when={result().podcast.description}>
|
||||||
<text fg={theme.textSecondary}>
|
<text fg={theme.textSecondary}>
|
||||||
{result().podcast.description!.slice(0, 400) ??
|
{result().podcast.description!.slice(0, 400)}
|
||||||
"No description available."}
|
|
||||||
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
{(result().podcast.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -419,7 +427,7 @@ function SearchPage() {
|
|||||||
: `Results · ${results().length}`;
|
: `Results · ${results().length}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* SearchResults component for displaying podcast search results
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { For, Show } from "solid-js";
|
|
||||||
import type { SearchResult } from "@/types/source";
|
|
||||||
import { ResultCard } from "./ResultCard";
|
|
||||||
import { ResultDetail } from "./ResultDetail";
|
|
||||||
|
|
||||||
type SearchResultsProps = {
|
|
||||||
results: SearchResult[];
|
|
||||||
selectedIndex: number;
|
|
||||||
focused: boolean;
|
|
||||||
onSelect?: (result: SearchResult) => void;
|
|
||||||
onChange?: (index: number) => void;
|
|
||||||
isSearching?: boolean;
|
|
||||||
error?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SearchResults(props: SearchResultsProps) {
|
|
||||||
const handleSelect = (index: number) => {
|
|
||||||
props.onChange?.(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Show
|
|
||||||
when={!props.isSearching}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="yellow">Searching...</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={!props.error}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="red">{props.error}</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={props.results.length > 0}
|
|
||||||
fallback={
|
|
||||||
<box padding={1}>
|
|
||||||
<text fg="gray">
|
|
||||||
No results found. Try a different search term.
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<box flexDirection="row" gap={1} height="100%">
|
|
||||||
<box flexDirection="column" flexGrow={1}>
|
|
||||||
<scrollbox height="100%">
|
|
||||||
<box flexDirection="column" gap={1}>
|
|
||||||
<For each={props.results}>
|
|
||||||
{(result, index) => (
|
|
||||||
<ResultCard
|
|
||||||
result={result}
|
|
||||||
selected={index() === props.selectedIndex}
|
|
||||||
onSelect={() => handleSelect(index())}
|
|
||||||
onSubscribe={() => props.onSelect?.(result)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</box>
|
|
||||||
</scrollbox>
|
|
||||||
</box>
|
|
||||||
<box width={36}>
|
|
||||||
<ResultDetail
|
|
||||||
result={props.results[props.selectedIndex]}
|
|
||||||
onSubscribe={(result) => props.onSelect?.(result)}
|
|
||||||
/>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
</Show>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { SourceType } from "@/types/source";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
type SourceBadgeProps = {
|
|
||||||
sourceId: string;
|
|
||||||
sourceName?: string;
|
|
||||||
sourceType?: SourceType;
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeLabel = (sourceType?: SourceType) => {
|
|
||||||
if (sourceType === SourceType.API) return "API";
|
|
||||||
if (sourceType === SourceType.RSS) return "RSS";
|
|
||||||
if (sourceType === SourceType.CUSTOM) return "Custom";
|
|
||||||
return "Source";
|
|
||||||
};
|
|
||||||
|
|
||||||
// No module-level typeColor here — it needs the theme from the component.
|
|
||||||
// The correct definition lives inside SourceBadge below.
|
|
||||||
export function SourceBadge(props: SourceBadgeProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const label = () => props.sourceName || props.sourceId;
|
|
||||||
|
|
||||||
const typeColor = (sourceType?: SourceType) => {
|
|
||||||
if (sourceType === SourceType.API) return theme.primary;
|
|
||||||
if (sourceType === SourceType.RSS) return theme.success;
|
|
||||||
if (sourceType === SourceType.CUSTOM) return theme.warning;
|
|
||||||
return theme.textMuted;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="row" gap={1} padding={0}>
|
|
||||||
<text fg={typeColor(props.sourceType)}>
|
|
||||||
[{typeLabel(props.sourceType)}]
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>{label()}</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
124
src/pages/Settings/DownloadManager.tsx
Normal file
124
src/pages/Settings/DownloadManager.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||||
|
*
|
||||||
|
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||||
|
* • one item per show — action item; Enter deletes all that show's
|
||||||
|
* downloads (file + metadata, aborts in-flight).
|
||||||
|
* • one item per episode — action item; Enter deletes a single download.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* nav.action — no own useKeyboard (matches the other panels).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
|
import type { DownloadedEpisode } from "@/types/episode";
|
||||||
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
|
/** Format a byte count as a compact human string. */
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||||
|
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
|
||||||
|
return `${n} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short status badge for an episode download. */
|
||||||
|
function statusLabel(s: DownloadStatus): string {
|
||||||
|
switch (s) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "queued";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return "downloading";
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "done";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "failed";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode title for a download, resolved from the feed store (reactive). */
|
||||||
|
function episodeTitle(
|
||||||
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
|
d: DownloadedEpisode,
|
||||||
|
): string {
|
||||||
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
|
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||||
|
return ep?.title ?? d.episodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Show title for a download's feed id. */
|
||||||
|
function feedTitle(
|
||||||
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
|
feedId: string,
|
||||||
|
): string {
|
||||||
|
const feed = feedStore.getFeed(feedId);
|
||||||
|
return feed ? feed.customName || feed.podcast.title : feedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownloadItems(): SettingItem[] {
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
|
||||||
|
const downloads = () => downloadStore.getAllDownloads();
|
||||||
|
|
||||||
|
const items: SettingItem[] = [
|
||||||
|
{
|
||||||
|
id: "clear-all",
|
||||||
|
label: "Delete All Downloads",
|
||||||
|
kind: "action",
|
||||||
|
display: () => `${downloads().length} files`,
|
||||||
|
help: () =>
|
||||||
|
`Delete every downloaded episode (files + metadata) and clear the\nqueue. Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
for (const d of downloads()) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group downloads by feed so each show gets a delete-by-show item.
|
||||||
|
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||||
|
for (const d of downloads()) {
|
||||||
|
const arr = byFeed.get(d.feedId) ?? [];
|
||||||
|
arr.push(d);
|
||||||
|
byFeed.set(d.feedId, arr);
|
||||||
|
}
|
||||||
|
for (const [feedId, eps] of byFeed) {
|
||||||
|
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||||
|
items.push({
|
||||||
|
id: `feed:${feedId}`,
|
||||||
|
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
||||||
|
kind: "action",
|
||||||
|
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||||
|
help: () =>
|
||||||
|
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One item per individual episode download.
|
||||||
|
for (const d of downloads()) {
|
||||||
|
items.push({
|
||||||
|
id: `ep:${d.episodeId}`,
|
||||||
|
label: episodeTitle(feedStore, d),
|
||||||
|
kind: "action",
|
||||||
|
display: () =>
|
||||||
|
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
|
help: () =>
|
||||||
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -1,38 +1,55 @@
|
|||||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||||
let current = value
|
let current = value;
|
||||||
return [() => current, (next) => {
|
return [
|
||||||
current = next
|
() => current,
|
||||||
}]
|
(next) => {
|
||||||
}
|
current = next;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
import { SyncStatus } from "./SyncStatus"
|
import { SyncStatus } from "./SyncStatus";
|
||||||
import { useTheme } from "@/context/ThemeContext"
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
export function ExportDialog() {
|
export function ExportDialog() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const filename = createSignal("podcast-sync.json")
|
const filename = createSignal("podcast-sync.json");
|
||||||
const format = createSignal<"json" | "xml">("json")
|
const format = createSignal<"json" | "xml">("json");
|
||||||
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const filenameRef = useInputFocusNav();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
<box
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
border
|
||||||
<text fg={theme.text}>File:</text>
|
title="Export"
|
||||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
style={{ padding: 1, flexDirection: "column", gap: 1 }}
|
||||||
</box>
|
>
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<text fg={theme.text}>Format:</text>
|
<text fg={theme.text}>File:</text>
|
||||||
<tab_select
|
<input
|
||||||
options={[
|
ref={filenameRef}
|
||||||
{ name: "JSON", description: "Portable" },
|
value={filename[0]()}
|
||||||
{ name: "XML", description: "Structured" },
|
onInput={filename[1]}
|
||||||
]}
|
style={{ width: 30 }}
|
||||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
/>
|
||||||
/>
|
</box>
|
||||||
</box>
|
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||||
<box border borderColor={theme.border}>
|
<text fg={theme.text}>Format:</text>
|
||||||
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
|
<tab_select
|
||||||
</box>
|
options={[
|
||||||
<SyncStatus />
|
{ name: "JSON", description: "Portable" },
|
||||||
</box>
|
{ name: "XML", description: "Structured" },
|
||||||
)
|
]}
|
||||||
|
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
<box border borderColor={theme.border}>
|
||||||
|
<text fg={theme.text}>
|
||||||
|
Export {format[0]()} to {filename[0]()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<SyncStatus />
|
||||||
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
import { detectFormat } from "@/utils/file-detector";
|
import { detectFormat } from "@/utils/file-detector";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useInputFocusNav } from "@/hooks/useInputFocusNav";
|
||||||
|
|
||||||
type FilePickerProps = {
|
type FilePickerProps = {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilePicker(props: FilePickerProps) {
|
export function FilePicker(props: FilePickerProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const format = detectFormat(props.value);
|
// Yield navigation keybinds to the Shell router while the input is focused.
|
||||||
|
const inputRef = useInputFocusNav();
|
||||||
|
const format = detectFormat(props.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||||
<input
|
<input
|
||||||
value={props.value}
|
ref={inputRef}
|
||||||
onInput={props.onChange}
|
value={props.value}
|
||||||
placeholder="/path/to/sync-file.json"
|
onInput={props.onChange}
|
||||||
style={{ width: 40 }}
|
placeholder="/path/to/sync-file.json"
|
||||||
/>
|
style={{ width: 40 }}
|
||||||
<text fg={theme.text}>Format: {format}</text>
|
/>
|
||||||
</box>
|
<text fg={theme.text}>Format: {format}</text>
|
||||||
);
|
</box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
/**
|
|
||||||
* Login screen component for PodTUI
|
|
||||||
* Email/password login with links to code validation and OAuth
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
import { AUTH_CONFIG } from "@/config/auth";
|
|
||||||
|
|
||||||
interface LoginScreenProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onNavigateToCode?: () => void;
|
|
||||||
onNavigateToOAuth?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "email" | "password" | "submit" | "code" | "oauth";
|
|
||||||
|
|
||||||
export function LoginScreen(props: LoginScreenProps) {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [email, setEmail] = createSignal("");
|
|
||||||
const [password, setPassword] = createSignal("");
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("email");
|
|
||||||
const [emailError, setEmailError] = createSignal<string | null>(null);
|
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null);
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
|
|
||||||
|
|
||||||
const validateEmail = (value: string): boolean => {
|
|
||||||
if (!value) {
|
|
||||||
setEmailError("Email is required");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!AUTH_CONFIG.email.pattern.test(value)) {
|
|
||||||
setEmailError("Invalid email format");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setEmailError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validatePassword = (value: string): boolean => {
|
|
||||||
if (!value) {
|
|
||||||
setPasswordError("Password is required");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (value.length < AUTH_CONFIG.password.minLength) {
|
|
||||||
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setPasswordError(null);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const isEmailValid = validateEmail(email());
|
|
||||||
const isPasswordValid = validatePassword(password());
|
|
||||||
|
|
||||||
if (!isEmailValid || !isPasswordValid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await auth.login({ email: email(), password: password() });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
if (focusField() === "submit") {
|
|
||||||
handleSubmit();
|
|
||||||
} else if (focusField() === "code" && props.onNavigateToCode) {
|
|
||||||
props.onNavigateToCode();
|
|
||||||
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
|
|
||||||
props.onNavigateToOAuth();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Sign In</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Email field */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
|
|
||||||
Email:
|
|
||||||
</text>
|
|
||||||
<input
|
|
||||||
value={email()}
|
|
||||||
onInput={setEmail}
|
|
||||||
placeholder="your@email.com"
|
|
||||||
focused={props.focused && focusField() === "email"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
{emailError() && <text fg={theme.error}>{emailError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Password field */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
|
|
||||||
Password:
|
|
||||||
</text>
|
|
||||||
<input
|
|
||||||
value={password()}
|
|
||||||
onInput={setPassword}
|
|
||||||
placeholder="********"
|
|
||||||
focused={props.focused && focusField() === "password"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Submit button */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={
|
|
||||||
focusField() === "submit" ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.text : undefined}>
|
|
||||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Alternative auth options */}
|
|
||||||
<text fg={theme.textMuted}>Or authenticate with:</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "code" ? theme.primary : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
|
|
||||||
[C] Sync Code
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
|
|
||||||
[O] OAuth Info
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
/**
|
|
||||||
* OAuth placeholder component for PodTUI
|
|
||||||
* Displays OAuth limitations and alternative authentication methods
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface OAuthPlaceholderProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onBack?: () => void;
|
|
||||||
onNavigateToCode?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "code" | "back";
|
|
||||||
|
|
||||||
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("code");
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["code", "back"];
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
if (focusField() === "code" && props.onNavigateToCode) {
|
|
||||||
props.onNavigateToCode();
|
|
||||||
} else if (focusField() === "back" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
} else if (key.name === "escape" && props.onBack) {
|
|
||||||
props.onBack();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>OAuth Authentication</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* OAuth providers list */}
|
|
||||||
<text fg={theme.primary}>Available OAuth Providers:</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
|
||||||
{OAUTH_PROVIDERS.map((provider) => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={provider.enabled ? theme.success : theme.textMuted}>
|
|
||||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>- {provider.description}</text>
|
|
||||||
</box>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Limitation message */}
|
|
||||||
<box border padding={1} borderColor={theme.warning}>
|
|
||||||
<text fg={theme.warning}>Terminal Limitations</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
|
||||||
<text fg={theme.textMuted}>{line}</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Alternative options */}
|
|
||||||
<text fg={theme.primary}>Recommended Alternatives:</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.success}>[1]</text>
|
|
||||||
<text fg={theme.text}>Use a sync code from the web portal</text>
|
|
||||||
<text fg={theme.success}>[2]</text>
|
|
||||||
<text fg={theme.text}>Use email/password authentication</text>
|
|
||||||
<text fg={theme.success}>[3]</text>
|
|
||||||
<text fg={theme.text}>Use file-based sync (no account needed)</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
[C] Enter Sync Code
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -39,6 +39,19 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
app.setTheme(THEME_LABELS[next].value);
|
app.setTheme(THEME_LABELS[next].value);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "transparentBackground",
|
||||||
|
label: "Transparent Background",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () =>
|
||||||
|
settings().transparentBackground ? "On" : "Off",
|
||||||
|
help: () =>
|
||||||
|
`Let the terminal's own background show through (no app background fill).\nType: toggle\nDefault: false\nCurrent: ${settings().transparentBackground ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updateSettings({
|
||||||
|
transparentBackground: !settings().transparentBackground,
|
||||||
|
}),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fontSize",
|
id: "fontSize",
|
||||||
label: "Font Size",
|
label: "Font Size",
|
||||||
@@ -90,5 +103,17 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoDownload: !prefs().autoDownload,
|
autoDownload: !prefs().autoDownload,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "autoJumpToPlayer",
|
||||||
|
label: "Auto Jump to Player",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (prefs().autoJumpToPlayer ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Jump to the Player view automatically when a podcast starts.\nType: toggle\nDefault: true\nCurrent: ${prefs().autoJumpToPlayer ? "On" : "Off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () =>
|
||||||
|
app.updatePreferences({
|
||||||
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
|
}),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
* depth 1 — the focused section's items as a navigable list
|
* depth 1 — the focused section's items as a navigable list
|
||||||
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
* depth 2 — per-item editor (for editor-kind items) or value adjuster
|
||||||
*
|
*
|
||||||
* Renders entirely through `<YaziPaneRow>` (parent | current | preview):
|
* Renders entirely through `<PaneRow>` (parent | current | preview):
|
||||||
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
* parent = previous depth's list (sections at depth 1, items at depth 2);
|
||||||
* blank placeholder at depth 0 (1/7 slot kept).
|
* blank placeholder at depth 0 (1/5 slot kept).
|
||||||
* current = the current-depth list (or editor at depth 2); the only
|
* current = the current-depth list (or editor at depth 2); the only
|
||||||
* focusable column.
|
* focusable column.
|
||||||
* preview = help/preview text for the hovered item in current.
|
* preview = help/preview text for the hovered item in current.
|
||||||
@@ -33,8 +33,10 @@ import { usePreferencesItems } from "./PreferencesPanel";
|
|||||||
import { useVisualizerItems } from "./VisualizerSettings";
|
import { useVisualizerItems } from "./VisualizerSettings";
|
||||||
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
import { useSyncItems, closeSyncEditor } from "./SyncPanel";
|
||||||
import { useSourceItems } from "./SourceManager";
|
import { useSourceItems } from "./SourceManager";
|
||||||
import { YaziPaneRow } from "@/components/YaziPaneRow";
|
import { useDownloadItems } from "./DownloadManager";
|
||||||
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
|
||||||
export const SettingsPaneCount = 1;
|
export const SettingsPaneCount = 1;
|
||||||
|
|
||||||
@@ -61,13 +63,12 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
label: "Account",
|
label: "Downloads",
|
||||||
description: "Account login & OAuth (not yet implemented).",
|
description: "Manage downloaded episodes — delete by show or individually.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
/** Resolve the items for a section id at render time. */
|
||||||
* no items yet. */
|
|
||||||
function sectionItems(sectionId: number): SettingItem[] {
|
function sectionItems(sectionId: number): SettingItem[] {
|
||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -78,6 +79,8 @@ function sectionItems(sectionId: number): SettingItem[] {
|
|||||||
return usePreferencesItems();
|
return usePreferencesItems();
|
||||||
case 3:
|
case 3:
|
||||||
return useVisualizerItems();
|
return useVisualizerItems();
|
||||||
|
case 4:
|
||||||
|
return useDownloadItems();
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -377,7 +380,7 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
@@ -421,9 +424,16 @@ function Row(props: {
|
|||||||
: props.focused
|
: props.focused
|
||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
const fg = () =>
|
||||||
|
props.focused && props.active
|
||||||
|
? theme.surface
|
||||||
|
: props.focused
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.focused);
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
import { createSignal, For, Show } from "solid-js";
|
import { createSignal, For, Show } from "solid-js";
|
||||||
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 { 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";
|
||||||
@@ -61,6 +62,9 @@ function AddSourceForm() {
|
|||||||
const [name, setName] = createSignal("");
|
const [name, setName] = createSignal("");
|
||||||
const [url, setUrl] = createSignal("");
|
const [url, setUrl] = createSignal("");
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
// Yield navigation keybinds to the Shell router while either input is focused.
|
||||||
|
const nameRef = useInputFocusNav();
|
||||||
|
const urlRef = useInputFocusNav();
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const u = url().trim();
|
const u = url().trim();
|
||||||
@@ -94,6 +98,7 @@ function AddSourceForm() {
|
|||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>Name:</text>
|
<text fg={theme.textMuted}>Name:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={nameRef}
|
||||||
value={name()}
|
value={name()}
|
||||||
onInput={setName}
|
onInput={setName}
|
||||||
placeholder="My Custom Feed"
|
placeholder="My Custom Feed"
|
||||||
@@ -103,6 +108,7 @@ function AddSourceForm() {
|
|||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<text fg={theme.textMuted}>URL:</text>
|
<text fg={theme.textMuted}>URL:</text>
|
||||||
<input
|
<input
|
||||||
|
ref={urlRef}
|
||||||
value={url()}
|
value={url()}
|
||||||
onInput={(v) => {
|
onInput={(v) => {
|
||||||
setUrl(v);
|
setUrl(v);
|
||||||
|
|||||||
@@ -3,21 +3,13 @@
|
|||||||
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
* Export dialogs render as depth-2 editors. No own useKeyboard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { ImportDialog } from "./ImportDialog";
|
import { ImportDialog } from "./ImportDialog";
|
||||||
import { ExportDialog } from "./ExportDialog";
|
import { ExportDialog } from "./ExportDialog";
|
||||||
import { SyncStatus } from "./SyncStatus";
|
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
// Module-level state so the action items can open their dialogs as depth-2
|
// closeSyncEditor kept for SettingsPage's cleanup hook; its backing state
|
||||||
// editors. The SettingsPage reads `syncEditor()` to decide which dialog to show.
|
// (the syncEditor signal) was removed as dead — nothing ever read it.
|
||||||
const [syncEditor, setSyncEditor] = createSignal<"import" | "export" | null>(
|
export function closeSyncEditor() {}
|
||||||
null,
|
|
||||||
);
|
|
||||||
export { syncEditor };
|
|
||||||
export function closeSyncEditor() {
|
|
||||||
setSyncEditor(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSyncItems(): SettingItem[] {
|
export function useSyncItems(): SettingItem[] {
|
||||||
return [
|
return [
|
||||||
@@ -49,9 +41,3 @@ export function useSyncItems(): SettingItem[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the live sync status block (used by the Settings page header for the
|
|
||||||
* Sync section, when relevant). */
|
|
||||||
export function SyncStatusBlock() {
|
|
||||||
return <SyncStatus />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sync profile component for PodTUI
|
|
||||||
* Displays user profile information and sync status
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
|
||||||
|
|
||||||
interface SyncProfileProps {
|
|
||||||
focused?: boolean;
|
|
||||||
onLogout?: () => void;
|
|
||||||
onManageSync?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FocusField = "sync" | "export" | "logout";
|
|
||||||
|
|
||||||
export function SyncProfile(props: SyncProfileProps) {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const [focusField, setFocusField] = createSignal<FocusField>("sync");
|
|
||||||
const [lastSyncTime] = createSignal<Date | null>(new Date());
|
|
||||||
|
|
||||||
const fields: FocusField[] = ["sync", "export", "logout"];
|
|
||||||
|
|
||||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
|
||||||
if (key.name === "tab") {
|
|
||||||
const currentIndex = fields.indexOf(focusField());
|
|
||||||
const nextIndex = key.shift
|
|
||||||
? (currentIndex - 1 + fields.length) % fields.length
|
|
||||||
: (currentIndex + 1) % fields.length;
|
|
||||||
setFocusField(fields[nextIndex]);
|
|
||||||
} else if (key.name === "return") {
|
|
||||||
if (focusField() === "sync" && props.onManageSync) {
|
|
||||||
props.onManageSync();
|
|
||||||
} else if (focusField() === "logout" && props.onLogout) {
|
|
||||||
handleLogout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
auth.logout();
|
|
||||||
if (props.onLogout) {
|
|
||||||
props.onLogout();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null | undefined): string => {
|
|
||||||
if (!date) return "Never";
|
|
||||||
return format(date, "MMM d, yyyy HH:mm");
|
|
||||||
};
|
|
||||||
|
|
||||||
const user = () => auth.state().user;
|
|
||||||
|
|
||||||
// Get user initials for avatar
|
|
||||||
const userInitials = () => {
|
|
||||||
const name = user()?.name || "?";
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>User Profile</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* User avatar and info */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
{/* ASCII avatar */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
width={8}
|
|
||||||
height={4}
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>{userInitials()}</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* User details */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={theme.text}>{user()?.name || "Guest User"}</text>
|
|
||||||
<text fg={theme.textMuted}>{user()?.email || "No email"}</text>
|
|
||||||
<text fg={theme.textMuted}>Joined: {formatDate(user()?.createdAt)}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Sync status section */}
|
|
||||||
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>Sync Status</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Status:</text>
|
|
||||||
<text fg={user()?.syncEnabled ? theme.success : theme.warning}>
|
|
||||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Last Sync:</text>
|
|
||||||
<text fg={theme.text}>{formatDate(lastSyncTime())}</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Method:</text>
|
|
||||||
<text fg={theme.text}>File-based (JSON/XML)</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "sync" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "sync" ? theme.primary : undefined}>
|
|
||||||
[S] Manage Sync
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "export" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "export" ? theme.primary : undefined}>
|
|
||||||
[E] Export Data
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "logout" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "logout" ? theme.error : theme.textMuted}>
|
|
||||||
[L] Logout
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -29,12 +29,14 @@ const defaultSettings: AppSettings = {
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
|
transparentBackground: false,
|
||||||
visualizer: defaultVisualizerSettings,
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoJumpToPlayer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -43,7 +45,7 @@ const defaultState: AppState = {
|
|||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAppStore() {
|
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);
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ export function createAppStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next).catch(() => {});
|
saveAppStateToFile(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
|
|||||||
@@ -5,122 +5,122 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import {
|
import {
|
||||||
loadAudioNavFromFile,
|
loadAudioNavFromFile,
|
||||||
saveAudioNavToFile,
|
saveAudioNavToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
/** Source type for audio navigation */
|
/** 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 */
|
/** Audio navigation state */
|
||||||
export interface AudioNavState {
|
export interface AudioNavState {
|
||||||
/** Current source type */
|
/** Current source type */
|
||||||
source: AudioSource;
|
source: AudioSource;
|
||||||
/** Index of current episode in the ordered list */
|
/** Index of current episode in the ordered list */
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
/** Podcast ID for My Shows source */
|
/** Podcast ID for My Shows source */
|
||||||
podcastId?: string;
|
podcastId?: string;
|
||||||
/** Timestamp when navigation state was last saved */
|
/** Timestamp when navigation state was last saved */
|
||||||
lastUpdated: Date;
|
lastUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default navigation state */
|
/** 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 */
|
/** Create audio navigation store */
|
||||||
export function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
/** Persist current navigation state to file (fire-and-forget) */
|
/** Persist current navigation state to file (fire-and-forget) */
|
||||||
function persist(): void {
|
function persist(): void {
|
||||||
saveAudioNavToFile(navState()).catch(() => {});
|
saveAudioNavToFile(navState());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load navigation state from file */
|
/** Load navigation state from file */
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
const loaded = await loadAudioNavFromFile<AudioNavState>();
|
const loaded = await loadAudioNavFromFile<AudioNavState>();
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
setNavState(loaded);
|
setNavState(loaded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget initialization */
|
/** Fire-and-forget initialization */
|
||||||
init();
|
init();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Get current navigation state */
|
/** Get current navigation state */
|
||||||
get state(): AudioNavState {
|
get state(): AudioNavState {
|
||||||
return navState();
|
return navState();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update source type */
|
/** Update source type */
|
||||||
setSource: (source: AudioSource, podcastId?: string) => {
|
setSource: (source: AudioSource, podcastId?: string) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
source,
|
source,
|
||||||
podcastId,
|
podcastId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
}));
|
}));
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to next episode */
|
/** Move to next episode */
|
||||||
next: (currentIndex: number) => {
|
next: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
}));
|
}));
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to previous episode */
|
/** Move to previous episode */
|
||||||
prev: (currentIndex: number) => {
|
prev: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
}));
|
}));
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset to default state */
|
/** Reset to default state */
|
||||||
reset: () => {
|
reset: () => {
|
||||||
setNavState(defaultNavState);
|
setNavState(defaultNavState);
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current index */
|
/** Get current index */
|
||||||
getCurrentIndex: (): number => {
|
getCurrentIndex: (): number => {
|
||||||
return navState().currentIndex;
|
return navState().currentIndex;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current source */
|
/** Get current source */
|
||||||
getSource: (): AudioSource => {
|
getSource: (): AudioSource => {
|
||||||
return navState().source;
|
return navState().source;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current podcast ID */
|
/** Get current podcast ID */
|
||||||
getPodcastId: (): string | undefined => {
|
getPodcastId: (): string | undefined => {
|
||||||
return navState().podcastId;
|
return navState().podcastId;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton instance */
|
/** Singleton instance */
|
||||||
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
|
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
|
||||||
|
|
||||||
export function useAudioNavStore() {
|
export function useAudioNavStore() {
|
||||||
if (!audioNavInstance) {
|
if (!audioNavInstance) {
|
||||||
audioNavInstance = createAudioNavStore();
|
audioNavInstance = createAudioNavStore();
|
||||||
}
|
}
|
||||||
return audioNavInstance;
|
return audioNavInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication store for PodTUI
|
|
||||||
* Uses Zustand for state management with localStorage persistence
|
|
||||||
* Authentication is DISABLED by default
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
|
||||||
import type {
|
|
||||||
User,
|
|
||||||
AuthState,
|
|
||||||
AuthError,
|
|
||||||
AuthErrorCode,
|
|
||||||
LoginCredentials,
|
|
||||||
AuthScreen,
|
|
||||||
} from "../types/auth"
|
|
||||||
import { AUTH_CONFIG, DEFAULT_AUTH_ENABLED } from "../config/auth"
|
|
||||||
|
|
||||||
/** Initial auth state */
|
|
||||||
const initialState: AuthState = {
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load auth state from localStorage */
|
|
||||||
function loadAuthState(): AuthState {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return initialState
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(AUTH_CONFIG.storage.authState)
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored)
|
|
||||||
// Convert date strings back to Date objects
|
|
||||||
if (parsed.user?.createdAt) {
|
|
||||||
parsed.user.createdAt = new Date(parsed.user.createdAt)
|
|
||||||
}
|
|
||||||
if (parsed.user?.lastLoginAt) {
|
|
||||||
parsed.user.lastLoginAt = new Date(parsed.user.lastLoginAt)
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors, use initial state
|
|
||||||
}
|
|
||||||
|
|
||||||
return initialState
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save auth state to localStorage */
|
|
||||||
function saveAuthState(state: AuthState): void {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
localStorage.setItem(AUTH_CONFIG.storage.authState, JSON.stringify(state))
|
|
||||||
} catch {
|
|
||||||
// Ignore storage errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create auth store using Solid signals */
|
|
||||||
export function createAuthStore() {
|
|
||||||
const [state, setState] = createSignal<AuthState>(loadAuthState())
|
|
||||||
const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED)
|
|
||||||
const [currentScreen, setCurrentScreen] = createSignal<AuthScreen>("login")
|
|
||||||
|
|
||||||
/** Update state and persist */
|
|
||||||
const updateState = (updates: Partial<AuthState>) => {
|
|
||||||
setState((prev) => {
|
|
||||||
const next = { ...prev, ...updates }
|
|
||||||
saveAuthState(next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Login with email/password (placeholder - no real backend) */
|
|
||||||
const login = async (credentials: LoginCredentials): Promise<boolean> => {
|
|
||||||
updateState({ isLoading: true, error: null })
|
|
||||||
|
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
|
|
||||||
// Validate email format
|
|
||||||
if (!AUTH_CONFIG.email.pattern.test(credentials.email)) {
|
|
||||||
updateState({
|
|
||||||
isLoading: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_CREDENTIALS" as AuthErrorCode,
|
|
||||||
message: "Invalid email format",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate password length
|
|
||||||
if (credentials.password.length < AUTH_CONFIG.password.minLength) {
|
|
||||||
updateState({
|
|
||||||
isLoading: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_CREDENTIALS" as AuthErrorCode,
|
|
||||||
message: `Password must be at least ${AUTH_CONFIG.password.minLength} characters`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create mock user (in real app, this would validate against backend)
|
|
||||||
const user: User = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
email: credentials.email,
|
|
||||||
name: credentials.email.split("@")[0],
|
|
||||||
createdAt: new Date(),
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
syncEnabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState({
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Logout and clear state */
|
|
||||||
const logout = () => {
|
|
||||||
updateState({
|
|
||||||
user: null,
|
|
||||||
isAuthenticated: false,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
setCurrentScreen("login")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate 8-character code */
|
|
||||||
const validateCode = async (code: string): Promise<boolean> => {
|
|
||||||
updateState({ isLoading: true, error: null })
|
|
||||||
|
|
||||||
// Simulate network delay
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
|
|
||||||
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "")
|
|
||||||
|
|
||||||
// Check code length
|
|
||||||
if (normalizedCode.length !== AUTH_CONFIG.codeValidation.codeLength) {
|
|
||||||
updateState({
|
|
||||||
isLoading: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_CODE" as AuthErrorCode,
|
|
||||||
message: `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check code format
|
|
||||||
if (!AUTH_CONFIG.codeValidation.allowedChars.test(normalizedCode)) {
|
|
||||||
updateState({
|
|
||||||
isLoading: false,
|
|
||||||
error: {
|
|
||||||
code: "INVALID_CODE" as AuthErrorCode,
|
|
||||||
message: "Code must contain only letters and numbers",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock successful code validation
|
|
||||||
const user: User = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
email: `sync-${normalizedCode.toLowerCase()}@podtui.local`,
|
|
||||||
name: `Sync User (${normalizedCode.slice(0, 4)})`,
|
|
||||||
createdAt: new Date(),
|
|
||||||
lastLoginAt: new Date(),
|
|
||||||
syncEnabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState({
|
|
||||||
user,
|
|
||||||
isAuthenticated: true,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear error */
|
|
||||||
const clearError = () => {
|
|
||||||
updateState({ error: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Enable/disable auth */
|
|
||||||
const toggleAuthEnabled = () => {
|
|
||||||
setAuthEnabled((prev) => !prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State accessors (signals)
|
|
||||||
state,
|
|
||||||
authEnabled,
|
|
||||||
currentScreen,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
validateCode,
|
|
||||||
clearError,
|
|
||||||
setCurrentScreen,
|
|
||||||
toggleAuthEnabled,
|
|
||||||
|
|
||||||
// Computed
|
|
||||||
get user() {
|
|
||||||
return state().user
|
|
||||||
},
|
|
||||||
get isAuthenticated() {
|
|
||||||
return state().isAuthenticated
|
|
||||||
},
|
|
||||||
get isLoading() {
|
|
||||||
return state().isLoading
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return state().error
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Singleton auth store instance */
|
|
||||||
let authStoreInstance: ReturnType<typeof createAuthStore> | null = null
|
|
||||||
|
|
||||||
/** Get or create auth store */
|
|
||||||
export function useAuthStore() {
|
|
||||||
if (!authStoreInstance) {
|
|
||||||
authStoreInstance = createAuthStore()
|
|
||||||
}
|
|
||||||
return authStoreInstance
|
|
||||||
}
|
|
||||||
@@ -127,7 +127,6 @@ export function createDiscoverStore() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the podcast list from the manifest entries
|
|
||||||
const fetched = manifest.podcasts.map(entryToPodcast);
|
const fetched = manifest.podcasts.map(entryToPodcast);
|
||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
@@ -173,7 +172,6 @@ export function createDiscoverStore() {
|
|||||||
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) {
|
||||||
// Remove the feed from the feed store
|
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
feedStore.removeFeedByUrl(podcast.feedUrl);
|
feedStore.removeFeedByUrl(podcast.feedUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,355 +6,371 @@
|
|||||||
* download queue (max 2 concurrent).
|
* download queue (max 2 concurrent).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js"
|
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 { 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 { backupConfigFile } from "../utils/config-backup"
|
|
||||||
|
|
||||||
const DOWNLOADS_FILE = "downloads.json"
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2
|
const MAX_CONCURRENT = 2;
|
||||||
|
|
||||||
/** Serializable download record for persistence */
|
/** Serializable download record for persistence */
|
||||||
interface DownloadRecord {
|
interface DownloadRecord {
|
||||||
episodeId: string
|
episodeId: string;
|
||||||
feedId: string
|
feedId: string;
|
||||||
status: DownloadStatus
|
status: DownloadStatus;
|
||||||
filePath: string | null
|
filePath: string | null;
|
||||||
downloadedAt: string | null
|
downloadedAt: string | null;
|
||||||
fileSize: number
|
fileSize: number;
|
||||||
error: string | null
|
error: string | null;
|
||||||
audioUrl: string
|
audioUrl: string;
|
||||||
episodeTitle: string
|
episodeTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue item for pending downloads */
|
/** Queue item for pending downloads */
|
||||||
interface QueueItem {
|
interface QueueItem {
|
||||||
episodeId: string
|
episodeId: string;
|
||||||
feedId: string
|
feedId: string;
|
||||||
audioUrl: string
|
audioUrl: string;
|
||||||
episodeTitle: string
|
episodeTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create download store */
|
/** Create download store */
|
||||||
export function createDownloadStore() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map())
|
const [downloads, setDownloads] = createSignal<
|
||||||
const [queue, setQueue] = createSignal<QueueItem[]>([])
|
Map<string, DownloadedEpisode>
|
||||||
const [activeCount, setActiveCount] = createSignal(0)
|
>(new Map());
|
||||||
|
const [queue, setQueue] = createSignal<QueueItem[]>([]);
|
||||||
|
const [activeCount, setActiveCount] = createSignal(0);
|
||||||
|
|
||||||
/** Active AbortControllers keyed by episodeId */
|
/** Active AbortControllers keyed by episodeId */
|
||||||
const abortControllers = new Map<string, AbortController>()
|
const abortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
// Load persisted downloads on init
|
(async () => {
|
||||||
;(async () => {
|
const loaded = await loadDownloads();
|
||||||
const loaded = await loadDownloads()
|
if (loaded.size > 0) setDownloads(loaded);
|
||||||
if (loaded.size > 0) setDownloads(loaded)
|
// Resume any queued downloads from previous session
|
||||||
// Resume any queued downloads from previous session
|
resumeIncomplete();
|
||||||
resumeIncomplete()
|
})();
|
||||||
})()
|
|
||||||
|
|
||||||
/** Load downloads from JSON file */
|
/** Load downloads from JSON file */
|
||||||
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||||
const file = Bun.file(filePath)
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return new Map()
|
if (!(await file.exists())) return new Map();
|
||||||
|
|
||||||
const raw: DownloadRecord[] = await file.json()
|
const raw: DownloadRecord[] = await file.json();
|
||||||
if (!Array.isArray(raw)) return new Map()
|
if (!Array.isArray(raw)) return new Map();
|
||||||
|
|
||||||
const map = new Map<string, DownloadedEpisode>()
|
const map = new Map<string, DownloadedEpisode>();
|
||||||
for (const rec of raw) {
|
for (const rec of raw) {
|
||||||
map.set(rec.episodeId, {
|
map.set(rec.episodeId, {
|
||||||
episodeId: rec.episodeId,
|
episodeId: rec.episodeId,
|
||||||
feedId: rec.feedId,
|
feedId: rec.feedId,
|
||||||
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status,
|
status:
|
||||||
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
rec.status === DownloadStatus.DOWNLOADING
|
||||||
filePath: rec.filePath,
|
? DownloadStatus.QUEUED
|
||||||
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
: rec.status,
|
||||||
speed: 0,
|
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
|
||||||
fileSize: rec.fileSize,
|
filePath: rec.filePath,
|
||||||
error: rec.error,
|
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
|
||||||
})
|
speed: 0,
|
||||||
}
|
fileSize: rec.fileSize,
|
||||||
return map
|
error: rec.error,
|
||||||
} catch {
|
});
|
||||||
return new Map()
|
}
|
||||||
}
|
return map;
|
||||||
}
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Persist downloads to JSON file */
|
/** Persist downloads to JSON file */
|
||||||
async function saveDownloads(): Promise<void> {
|
async function saveDownloads(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir()
|
await ensureConfigDir();
|
||||||
await backupConfigFile(DOWNLOADS_FILE)
|
const map = downloads();
|
||||||
const map = downloads()
|
const records: DownloadRecord[] = [];
|
||||||
const records: DownloadRecord[] = []
|
for (const [, dl] of map) {
|
||||||
for (const [, dl] of map) {
|
// Find the audioUrl from queue or use empty string
|
||||||
// Find the audioUrl from queue or use empty string
|
const qItem = queue().find((q) => q.episodeId === dl.episodeId);
|
||||||
const qItem = queue().find((q) => q.episodeId === dl.episodeId)
|
records.push({
|
||||||
records.push({
|
episodeId: dl.episodeId,
|
||||||
episodeId: dl.episodeId,
|
feedId: dl.feedId,
|
||||||
feedId: dl.feedId,
|
status: dl.status,
|
||||||
status: dl.status,
|
filePath: dl.filePath,
|
||||||
filePath: dl.filePath,
|
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: qItem?.audioUrl ?? "",
|
episodeTitle: qItem?.episodeTitle ?? "",
|
||||||
episodeTitle: qItem?.episodeTitle ?? "",
|
});
|
||||||
})
|
}
|
||||||
}
|
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||||
const filePath = getConfigFilePath(DOWNLOADS_FILE)
|
await Bun.write(filePath, JSON.stringify(records, null, 2));
|
||||||
await Bun.write(filePath, JSON.stringify(records, null, 2))
|
} catch {
|
||||||
} catch {
|
// Silently ignore write errors
|
||||||
// Silently ignore write errors
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Resume incomplete downloads from a previous session */
|
/** Resume incomplete downloads from a previous session */
|
||||||
function resumeIncomplete(): void {
|
function resumeIncomplete(): void {
|
||||||
const map = downloads()
|
const map = downloads();
|
||||||
for (const [, dl] of map) {
|
for (const [, dl] of map) {
|
||||||
if (dl.status === DownloadStatus.QUEUED) {
|
if (dl.status === DownloadStatus.QUEUED) {
|
||||||
// Re-queue — but we lack audioUrl from persistence alone.
|
// Re-queue — but we lack audioUrl from persistence alone.
|
||||||
// These will sit as QUEUED until the user re-triggers them.
|
// These will sit as QUEUED until the user re-triggers them.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a single download entry and trigger reactivity */
|
/** Update a single download entry and trigger reactivity */
|
||||||
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void {
|
function updateDownload(
|
||||||
setDownloads((prev) => {
|
episodeId: string,
|
||||||
const next = new Map(prev)
|
updates: Partial<DownloadedEpisode>,
|
||||||
const existing = next.get(episodeId)
|
): void {
|
||||||
if (existing) {
|
setDownloads((prev) => {
|
||||||
next.set(episodeId, { ...existing, ...updates })
|
const next = new Map(prev);
|
||||||
}
|
const existing = next.get(episodeId);
|
||||||
return next
|
if (existing) {
|
||||||
})
|
next.set(episodeId, { ...existing, ...updates });
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
|
||||||
function processQueue(): void {
|
function processQueue(): void {
|
||||||
const current = activeCount()
|
const current = activeCount();
|
||||||
const q = queue()
|
const q = queue();
|
||||||
|
|
||||||
if (current >= MAX_CONCURRENT || q.length === 0) return
|
if (current >= MAX_CONCURRENT || q.length === 0) return;
|
||||||
|
|
||||||
const slotsAvailable = MAX_CONCURRENT - current
|
const slotsAvailable = MAX_CONCURRENT - current;
|
||||||
const toStart = q.slice(0, slotsAvailable)
|
const toStart = q.slice(0, slotsAvailable);
|
||||||
|
|
||||||
// Remove started items from queue
|
if (toStart.length > 0) {
|
||||||
if (toStart.length > 0) {
|
setQueue((prev) => prev.slice(toStart.length));
|
||||||
setQueue((prev) => prev.slice(toStart.length))
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of toStart) {
|
for (const item of toStart) {
|
||||||
executeDownload(item)
|
executeDownload(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute a single download */
|
/** 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);
|
||||||
setActiveCount((c) => c + 1)
|
setActiveCount((c) => c + 1);
|
||||||
|
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
status: DownloadStatus.DOWNLOADING,
|
status: DownloadStatus.DOWNLOADING,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
});
|
||||||
|
|
||||||
const result = await downloadEpisode(
|
const result = await downloadEpisode(
|
||||||
item.audioUrl,
|
item.audioUrl,
|
||||||
item.episodeTitle,
|
item.episodeTitle,
|
||||||
item.feedId,
|
item.feedId,
|
||||||
(progress) => {
|
(progress) => {
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
progress: progress.percent >= 0 ? progress.percent : 0,
|
progress: progress.percent >= 0 ? progress.percent : 0,
|
||||||
speed: progress.speed,
|
speed: progress.speed,
|
||||||
fileSize: progress.totalBytes,
|
fileSize: progress.totalBytes,
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
controller.signal,
|
controller.signal,
|
||||||
)
|
);
|
||||||
|
|
||||||
abortControllers.delete(item.episodeId)
|
abortControllers.delete(item.episodeId);
|
||||||
setActiveCount((c) => Math.max(0, c - 1))
|
setActiveCount((c) => Math.max(0, c - 1));
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
status: DownloadStatus.COMPLETED,
|
status: DownloadStatus.COMPLETED,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
filePath: result.filePath,
|
filePath: result.filePath,
|
||||||
fileSize: result.fileSize,
|
fileSize: result.fileSize,
|
||||||
downloadedAt: new Date(),
|
downloadedAt: new Date(),
|
||||||
speed: 0,
|
speed: 0,
|
||||||
error: null,
|
error: null,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
updateDownload(item.episodeId, {
|
updateDownload(item.episodeId, {
|
||||||
status: DownloadStatus.FAILED,
|
status: DownloadStatus.FAILED,
|
||||||
speed: 0,
|
speed: 0,
|
||||||
error: result.error ?? "Unknown error",
|
error: result.error ?? "Unknown error",
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDownloads().catch(() => {})
|
saveDownloads().catch(() => {});
|
||||||
// Process next items in queue
|
// Process next items in queue
|
||||||
processQueue()
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get download status for an episode */
|
/** 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) */
|
/** 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 */
|
/** 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 */
|
/** 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) {
|
||||||
return dl.filePath
|
return dl.filePath;
|
||||||
}
|
}
|
||||||
return null
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Start downloading an episode */
|
/** Start downloading an episode */
|
||||||
const startDownload = (episode: Episode, feedId: string): void => {
|
const startDownload = (episode: Episode, feedId: string): void => {
|
||||||
const existing = downloads().get(episode.id)
|
const existing = downloads().get(episode.id);
|
||||||
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) {
|
if (
|
||||||
return // Already downloading or queued
|
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||||
}
|
existing?.status === DownloadStatus.QUEUED
|
||||||
|
) {
|
||||||
|
return; // Already downloading or queued
|
||||||
|
}
|
||||||
|
|
||||||
// Create download entry
|
const entry: DownloadedEpisode = {
|
||||||
const entry: DownloadedEpisode = {
|
episodeId: episode.id,
|
||||||
episodeId: episode.id,
|
feedId,
|
||||||
feedId,
|
status: DownloadStatus.QUEUED,
|
||||||
status: DownloadStatus.QUEUED,
|
progress: 0,
|
||||||
progress: 0,
|
filePath: null,
|
||||||
filePath: null,
|
downloadedAt: null,
|
||||||
downloadedAt: null,
|
speed: 0,
|
||||||
speed: 0,
|
fileSize: episode.fileSize ?? 0,
|
||||||
fileSize: episode.fileSize ?? 0,
|
error: null,
|
||||||
error: null,
|
};
|
||||||
}
|
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.set(episode.id, entry)
|
next.set(episode.id, entry);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
|
|
||||||
// Add to queue
|
const queueItem: QueueItem = {
|
||||||
const queueItem: QueueItem = {
|
episodeId: episode.id,
|
||||||
episodeId: episode.id,
|
feedId,
|
||||||
feedId,
|
audioUrl: episode.audioUrl,
|
||||||
audioUrl: episode.audioUrl,
|
episodeTitle: episode.title,
|
||||||
episodeTitle: episode.title,
|
};
|
||||||
}
|
setQueue((prev) => [...prev, queueItem]);
|
||||||
setQueue((prev) => [...prev, queueItem])
|
|
||||||
|
|
||||||
saveDownloads().catch(() => {})
|
saveDownloads().catch(() => {});
|
||||||
processQueue()
|
processQueue();
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Cancel a download */
|
/** Cancel a download */
|
||||||
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);
|
||||||
if (controller) {
|
if (controller) {
|
||||||
controller.abort()
|
controller.abort();
|
||||||
abortControllers.delete(episodeId)
|
abortControllers.delete(episodeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from queue
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
|
||||||
|
|
||||||
// Update status
|
updateDownload(episodeId, {
|
||||||
updateDownload(episodeId, {
|
status: DownloadStatus.NONE,
|
||||||
status: DownloadStatus.NONE,
|
progress: 0,
|
||||||
progress: 0,
|
speed: 0,
|
||||||
speed: 0,
|
error: null,
|
||||||
error: null,
|
});
|
||||||
})
|
|
||||||
|
|
||||||
saveDownloads().catch(() => {})
|
saveDownloads().catch(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Remove a completed download (delete file and metadata) */
|
/** 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);
|
||||||
} catch {
|
} catch {
|
||||||
// File may already be gone
|
// File may already be gone
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev);
|
||||||
next.delete(episodeId)
|
next.delete(episodeId);
|
||||||
return next
|
return next;
|
||||||
})
|
});
|
||||||
|
|
||||||
saveDownloads().catch(() => {})
|
saveDownloads().catch(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get all downloads as an array */
|
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
||||||
return Array.from(downloads().values())
|
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
||||||
}
|
const eps = Array.from(downloads().values()).filter(
|
||||||
|
(d) => d.feedId === feedId,
|
||||||
|
);
|
||||||
|
for (const d of eps) {
|
||||||
|
cancelDownload(d.episodeId);
|
||||||
|
await removeDownload(d.episodeId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Get the current queue */
|
/** Get all downloads as an array */
|
||||||
const getQueue = (): QueueItem[] => {
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
return queue()
|
return Array.from(downloads().values());
|
||||||
}
|
};
|
||||||
|
|
||||||
/** Get count of active downloads */
|
/** Get the current queue */
|
||||||
const getActiveCount = (): number => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return activeCount()
|
return queue();
|
||||||
}
|
};
|
||||||
|
|
||||||
return {
|
/** Get count of active downloads */
|
||||||
// Getters
|
const getActiveCount = (): number => {
|
||||||
getDownloadStatus,
|
return activeCount();
|
||||||
getDownloadProgress,
|
};
|
||||||
getDownload,
|
|
||||||
getDownloadedFilePath,
|
|
||||||
getAllDownloads,
|
|
||||||
getQueue,
|
|
||||||
getActiveCount,
|
|
||||||
|
|
||||||
// Actions
|
return {
|
||||||
startDownload,
|
// Getters
|
||||||
cancelDownload,
|
getDownloadStatus,
|
||||||
removeDownload,
|
getDownloadProgress,
|
||||||
}
|
getDownload,
|
||||||
|
getDownloadedFilePath,
|
||||||
|
getAllDownloads,
|
||||||
|
getQueue,
|
||||||
|
getActiveCount,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startDownload,
|
||||||
|
cancelDownload,
|
||||||
|
removeDownload,
|
||||||
|
removeDownloadsForFeed,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton download store */
|
/** Singleton download store */
|
||||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||||
|
|
||||||
export function useDownloadStore() {
|
export function useDownloadStore() {
|
||||||
if (!downloadStoreInstance) {
|
if (!downloadStoreInstance) {
|
||||||
downloadStoreInstance = createDownloadStore()
|
downloadStoreInstance = createDownloadStore();
|
||||||
}
|
}
|
||||||
return downloadStoreInstance
|
return downloadStoreInstance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
import { useAuthStore } from "./auth";
|
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
@@ -35,16 +34,16 @@ const episodeLoadCount = new Map<string, number>();
|
|||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Save feeds to file (async, fire-and-forget) */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds).catch(() => {});
|
saveFeedsToFile(feeds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
saveSourcesToFile(sources).catch(() => {});
|
saveSourcesToFile(sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
export function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
...DEFAULT_SOURCES,
|
...DEFAULT_SOURCES,
|
||||||
@@ -62,31 +61,19 @@ export function createFeedStore() {
|
|||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// Filter by visibility
|
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
result = result.filter((feed) => feed.visibility === f.visibility);
|
||||||
} else if (f.visibility === "all") {
|
|
||||||
// Only show private feeds if authenticated
|
|
||||||
result = result.filter(
|
|
||||||
(feed) =>
|
|
||||||
feed.visibility === FeedVisibility.PUBLIC ||
|
|
||||||
authStore.isAuthenticated,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by source
|
|
||||||
if (f.sourceId) {
|
if (f.sourceId) {
|
||||||
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
result = result.filter((feed) => feed.sourceId === f.sourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by pinned
|
|
||||||
if (f.pinnedOnly) {
|
if (f.pinnedOnly) {
|
||||||
result = result.filter((feed) => feed.isPinned);
|
result = result.filter((feed) => feed.isPinned);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by search query
|
|
||||||
if (f.searchQuery) {
|
if (f.searchQuery) {
|
||||||
const query = f.searchQuery.toLowerCase();
|
const query = f.searchQuery.toLowerCase();
|
||||||
result = result.filter(
|
result = result.filter(
|
||||||
@@ -97,7 +84,6 @@ export function createFeedStore() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by selected field
|
|
||||||
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
const sortDir = f.sortDirection === "asc" ? 1 : -1;
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
switch (f.sortBy) {
|
switch (f.sortBy) {
|
||||||
@@ -120,7 +106,6 @@ export function createFeedStore() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Pinned feeds always first
|
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
if (a.isPinned && !b.isPinned) return -1;
|
if (a.isPinned && !b.isPinned) return -1;
|
||||||
if (!a.isPinned && b.isPinned) return 1;
|
if (!a.isPinned && b.isPinned) return 1;
|
||||||
@@ -233,25 +218,21 @@ export function createFeedStore() {
|
|||||||
newEpisodes: Episode[],
|
newEpisodes: Episode[],
|
||||||
count: number,
|
count: number,
|
||||||
) => {
|
) => {
|
||||||
try {
|
const dlStore = useDownloadStore();
|
||||||
const dlStore = useDownloadStore();
|
// Sort by pubDate descending (newest first)
|
||||||
// Sort by pubDate descending (newest first)
|
const sorted = [...newEpisodes].sort(
|
||||||
const sorted = [...newEpisodes].sort(
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
);
|
||||||
);
|
// count = 0 means download all new episodes
|
||||||
// count = 0 means download all new episodes
|
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
||||||
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
|
for (const ep of toDownload) {
|
||||||
for (const ep of toDownload) {
|
const status = dlStore.getDownloadStatus(ep.id);
|
||||||
const status = dlStore.getDownloadStatus(ep.id);
|
if (
|
||||||
if (
|
status === DownloadStatus.NONE ||
|
||||||
status === DownloadStatus.NONE ||
|
status === DownloadStatus.FAILED
|
||||||
status === DownloadStatus.FAILED
|
) {
|
||||||
) {
|
dlStore.startDownload(ep, feedId);
|
||||||
dlStore.startDownload(ep, feedId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// Download store may not be available yet
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import type { Progress } from "../types/episode";
|
import type { Progress } from "../types/episode";
|
||||||
import {
|
import {
|
||||||
loadProgressFromFile,
|
loadProgressFromFile,
|
||||||
saveProgressToFile,
|
saveProgressToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
/** Threshold (fraction 0-1) at which an episode is considered completed */
|
||||||
@@ -21,146 +21,146 @@ const MIN_POSITION_TO_SAVE = 5;
|
|||||||
// --- Singleton store ---
|
// --- Singleton store ---
|
||||||
|
|
||||||
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Persist current progress map to file (fire-and-forget) */
|
/** Persist current progress map to file (fire-and-forget) */
|
||||||
function persist(): void {
|
function persist(): void {
|
||||||
saveProgressToFile(progressMap()).catch(() => {});
|
saveProgressToFile(progressMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse raw progress entries from file, reviving Date objects */
|
/** Parse raw progress entries from file, reviving Date objects */
|
||||||
function parseProgressEntries(
|
function parseProgressEntries(
|
||||||
raw: Record<string, unknown>,
|
raw: Record<string, unknown>,
|
||||||
): Record<string, Progress> {
|
): Record<string, Progress> {
|
||||||
const result: Record<string, Progress> = {};
|
const result: Record<string, Progress> = {};
|
||||||
for (const [key, value] of Object.entries(raw)) {
|
for (const [key, value] of Object.entries(raw)) {
|
||||||
const p = value as Record<string, unknown>;
|
const p = value as Record<string, unknown>;
|
||||||
result[key] = {
|
result[key] = {
|
||||||
episodeId: p.episodeId as string,
|
episodeId: p.episodeId as string,
|
||||||
position: p.position as number,
|
position: p.position as number,
|
||||||
duration: p.duration as number,
|
duration: p.duration as number,
|
||||||
timestamp: new Date(p.timestamp as string),
|
timestamp: new Date(p.timestamp as string),
|
||||||
playbackSpeed: p.playbackSpeed as number | undefined,
|
playbackSpeed: p.playbackSpeed as number | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initProgress(): Promise<void> {
|
async function initProgress(): Promise<void> {
|
||||||
const raw = await loadProgressFromFile();
|
const raw = await loadProgressFromFile();
|
||||||
const parsed = parseProgressEntries(raw as Record<string, unknown>);
|
const parsed = parseProgressEntries(raw as Record<string, unknown>);
|
||||||
setProgressMap(parsed);
|
setProgressMap(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget init
|
// Fire-and-forget init
|
||||||
initProgress();
|
initProgress();
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Get progress for a specific episode.
|
* Get progress for a specific episode.
|
||||||
*/
|
*/
|
||||||
get(episodeId: string): Progress | undefined {
|
get(episodeId: string): Progress | undefined {
|
||||||
return progressMap()[episodeId];
|
return progressMap()[episodeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all progress entries.
|
* Get all progress entries.
|
||||||
*/
|
*/
|
||||||
all(): Record<string, Progress> {
|
all(): Record<string, Progress> {
|
||||||
return progressMap();
|
return progressMap();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update progress for an episode. Only persists if position is meaningful.
|
* Update progress for an episode. Only persists if position is meaningful.
|
||||||
*/
|
*/
|
||||||
update(
|
update(
|
||||||
episodeId: string,
|
episodeId: string,
|
||||||
position: number,
|
position: number,
|
||||||
duration: number,
|
duration: number,
|
||||||
playbackSpeed?: number,
|
playbackSpeed?: number,
|
||||||
): void {
|
): void {
|
||||||
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
|
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
|
||||||
|
|
||||||
setProgressMap((prev) => ({
|
setProgressMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[episodeId]: {
|
[episodeId]: {
|
||||||
episodeId,
|
episodeId,
|
||||||
position,
|
position,
|
||||||
duration,
|
duration,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
playbackSpeed,
|
playbackSpeed,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if an episode is completed.
|
* 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.
|
* 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;
|
||||||
return Math.min(100, Math.round((p.position / p.duration) * 100));
|
return Math.min(100, Math.round((p.position / p.duration) * 100));
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark an episode as completed (set position to duration).
|
* Mark an episode as completed (set position to duration).
|
||||||
*/
|
*/
|
||||||
markCompleted(episodeId: string): void {
|
markCompleted(episodeId: string): void {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
const duration = p?.duration ?? 0;
|
const duration = p?.duration ?? 0;
|
||||||
setProgressMap((prev) => ({
|
setProgressMap((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[episodeId]: {
|
[episodeId]: {
|
||||||
episodeId,
|
episodeId,
|
||||||
position: duration,
|
position: duration,
|
||||||
duration,
|
duration,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
playbackSpeed: p?.playbackSpeed,
|
playbackSpeed: p?.playbackSpeed,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove progress for an episode (e.g. "mark as new").
|
* Remove progress for an episode (e.g. "mark as new").
|
||||||
*/
|
*/
|
||||||
remove(episodeId: string): void {
|
remove(episodeId: string): void {
|
||||||
setProgressMap((prev) => {
|
setProgressMap((prev) => {
|
||||||
const next = { ...prev };
|
const next = { ...prev };
|
||||||
delete next[episodeId];
|
delete next[episodeId];
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all progress data.
|
* Clear all progress data.
|
||||||
*/
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
setProgressMap({});
|
setProgressMap({});
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
// Singleton instance
|
||||||
let instance: ReturnType<typeof createProgressStore> | null = null;
|
let instance: ReturnType<typeof createProgressStore> | null = null;
|
||||||
|
|
||||||
export function useProgressStore() {
|
export function useProgressStore() {
|
||||||
if (!instance) {
|
if (!instance) {
|
||||||
instance = createProgressStore();
|
instance = createProgressStore();
|
||||||
}
|
}
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts } from "../utils/search";
|
import { searchPodcasts, searchByFeedUrl } from "../utils/search";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult } from "../types/source";
|
import type { SearchResult } from "../types/source";
|
||||||
|
|
||||||
@@ -80,10 +80,18 @@ export function createSearchStore() {
|
|||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Add to history
|
|
||||||
addToHistory(q);
|
addToHistory(q);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// A query that is a direct RSS feed URL (e.g. a private feed that
|
||||||
|
// isn't in any public directory) resolves to that feed directly,
|
||||||
|
// independent of enabled search sources.
|
||||||
|
const urlResults = await searchByFeedUrl(q);
|
||||||
|
if (urlResults.length > 0) {
|
||||||
|
setResults(applySubscribedStatus(urlResults));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sources = feedStore.sources();
|
const sources = feedStore.sources();
|
||||||
const enabledSourceIds = sources
|
const enabledSourceIds = sources
|
||||||
.filter((s) => s.enabled)
|
.filter((s) => s.enabled)
|
||||||
@@ -122,7 +130,6 @@ export function createSearchStore() {
|
|||||||
/** Add query to history */
|
/** Add query to history */
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
// Remove duplicates and add to front
|
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
||||||
saveHistory(updated);
|
saveHistory(updated);
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* Authentication types for PodTUI
|
|
||||||
* Authentication is optional and disabled by default
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** User profile information */
|
|
||||||
export interface User {
|
|
||||||
id: string
|
|
||||||
email: string
|
|
||||||
name: string
|
|
||||||
createdAt: Date
|
|
||||||
lastLoginAt?: Date
|
|
||||||
syncEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Authentication state */
|
|
||||||
export interface AuthState {
|
|
||||||
user: User | null
|
|
||||||
isAuthenticated: boolean
|
|
||||||
isLoading: boolean
|
|
||||||
error: AuthError | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Authentication error */
|
|
||||||
export interface AuthError {
|
|
||||||
code: AuthErrorCode
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Error codes for authentication */
|
|
||||||
export enum AuthErrorCode {
|
|
||||||
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
|
|
||||||
INVALID_CODE = "INVALID_CODE",
|
|
||||||
CODE_EXPIRED = "CODE_EXPIRED",
|
|
||||||
NETWORK_ERROR = "NETWORK_ERROR",
|
|
||||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Login credentials */
|
|
||||||
export interface LoginCredentials {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Code validation request */
|
|
||||||
export interface CodeValidationRequest {
|
|
||||||
code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OAuth provider types */
|
|
||||||
export enum OAuthProvider {
|
|
||||||
GOOGLE = "google",
|
|
||||||
APPLE = "apple",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** OAuth provider configuration */
|
|
||||||
export interface OAuthProviderConfig {
|
|
||||||
id: OAuthProvider
|
|
||||||
name: string
|
|
||||||
enabled: boolean
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Auth screen types for navigation */
|
|
||||||
export type AuthScreen = "login" | "code" | "oauth" | "profile"
|
|
||||||
@@ -1,12 +1,4 @@
|
|||||||
import type {
|
import type { ThemeColors } from "../types/settings"
|
||||||
DesktopTheme,
|
|
||||||
ThemeColors,
|
|
||||||
ThemeDefinition,
|
|
||||||
ThemeName,
|
|
||||||
ThemeToken,
|
|
||||||
ThemeVariant,
|
|
||||||
} from "../types/settings"
|
|
||||||
import type { ColorValue } from "./theme-schema"
|
|
||||||
|
|
||||||
// Base theme colors
|
// Base theme colors
|
||||||
export const BASE_THEME_COLORS: ThemeColors = {
|
export const BASE_THEME_COLORS: ThemeColors = {
|
||||||
@@ -37,156 +29,3 @@ export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
|
|||||||
layer2: "#161b22",
|
layer2: "#161b22",
|
||||||
layer3: "#0d1117",
|
layer3: "#0d1117",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme tokens
|
|
||||||
export const BASE_THEME_TOKENS: ThemeToken = {
|
|
||||||
"background": "transparent",
|
|
||||||
"surface": "#1b1f27",
|
|
||||||
"primary": "#6fa8ff",
|
|
||||||
"secondary": "#a9b1d6",
|
|
||||||
"accent": "#f6c177",
|
|
||||||
"text": "#e6edf3",
|
|
||||||
"muted": "#7d8590",
|
|
||||||
"warning": "#f0b429",
|
|
||||||
"error": "#f47067",
|
|
||||||
"success": "#3fb950",
|
|
||||||
"layer0": "transparent",
|
|
||||||
"layer1": "#1e222e",
|
|
||||||
"layer2": "#161b22",
|
|
||||||
"layer3": "#0d1117",
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desktop theme structure
|
|
||||||
export const THEMES_DESKTOP: DesktopTheme = {
|
|
||||||
name: "PodTUI",
|
|
||||||
variants: [
|
|
||||||
{
|
|
||||||
name: "catppuccin",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#1e1e2e",
|
|
||||||
primary: "#89b4fa",
|
|
||||||
secondary: "#cba6f7",
|
|
||||||
accent: "#f9e2af",
|
|
||||||
text: "#cdd6f4",
|
|
||||||
textPrimary: "#cdd6f4",
|
|
||||||
textSecondary: "#cba6f7",
|
|
||||||
textTertiary: "#7f849c",
|
|
||||||
textSelectedPrimary: "#1e1e2e",
|
|
||||||
textSelectedSecondary: "#cdd6f4",
|
|
||||||
textSelectedTertiary: "#cba6f7",
|
|
||||||
muted: "#7f849c",
|
|
||||||
warning: "#fab387",
|
|
||||||
error: "#f38ba8",
|
|
||||||
success: "#a6e3a1",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#181825",
|
|
||||||
layer2: "#11111b",
|
|
||||||
layer3: "#0a0a0f",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gruvbox",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#282828",
|
|
||||||
primary: "#fabd2f",
|
|
||||||
secondary: "#83a598",
|
|
||||||
accent: "#fe8019",
|
|
||||||
text: "#ebdbb2",
|
|
||||||
textPrimary: "#ebdbb2",
|
|
||||||
textSecondary: "#83a598",
|
|
||||||
textTertiary: "#928374",
|
|
||||||
textSelectedPrimary: "#282828",
|
|
||||||
textSelectedSecondary: "#ebdbb2",
|
|
||||||
textSelectedTertiary: "#83a598",
|
|
||||||
muted: "#928374",
|
|
||||||
warning: "#fabd2f",
|
|
||||||
error: "#fb4934",
|
|
||||||
success: "#b8bb26",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#32302a",
|
|
||||||
layer2: "#1d2021",
|
|
||||||
layer3: "#0d0c0c",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "tokyo",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#1a1b26",
|
|
||||||
primary: "#7aa2f7",
|
|
||||||
secondary: "#bb9af7",
|
|
||||||
accent: "#e0af68",
|
|
||||||
text: "#c0caf5",
|
|
||||||
textPrimary: "#c0caf5",
|
|
||||||
textSecondary: "#bb9af7",
|
|
||||||
textTertiary: "#565f89",
|
|
||||||
textSelectedPrimary: "#1a1b26",
|
|
||||||
textSelectedSecondary: "#c0caf5",
|
|
||||||
textSelectedTertiary: "#bb9af7",
|
|
||||||
muted: "#565f89",
|
|
||||||
warning: "#e0af68",
|
|
||||||
error: "#f7768e",
|
|
||||||
success: "#9ece6a",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#16161e",
|
|
||||||
layer2: "#0f0f15",
|
|
||||||
layer3: "#08080b",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "nord",
|
|
||||||
colors: {
|
|
||||||
background: "transparent",
|
|
||||||
surface: "#2e3440",
|
|
||||||
primary: "#88c0d0",
|
|
||||||
secondary: "#81a1c1",
|
|
||||||
accent: "#ebcb8b",
|
|
||||||
text: "#eceff4",
|
|
||||||
textPrimary: "#eceff4",
|
|
||||||
textSecondary: "#81a1c1",
|
|
||||||
textTertiary: "#4c566a",
|
|
||||||
textSelectedPrimary: "#2e3440",
|
|
||||||
textSelectedSecondary: "#eceff4",
|
|
||||||
textSelectedTertiary: "#81a1c1",
|
|
||||||
muted: "#4c566a",
|
|
||||||
warning: "#ebcb8b",
|
|
||||||
error: "#bf616a",
|
|
||||||
success: "#a3be8c",
|
|
||||||
layerBackgrounds: {
|
|
||||||
layer0: "transparent",
|
|
||||||
layer1: "#3b4252",
|
|
||||||
layer2: "#242933",
|
|
||||||
layer3: "#1a1c23",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
defaultVariant: "catppuccin",
|
|
||||||
tokens: BASE_THEME_TOKENS,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get theme by name
|
|
||||||
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
|
|
||||||
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to get default theme
|
|
||||||
export function getDefaultTheme(): ThemeVariant {
|
|
||||||
return THEMES_DESKTOP.variants.find(
|
|
||||||
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
|
|
||||||
)!
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ThemeJsonFile = ThemeDefinition
|
|
||||||
|
|
||||||
export function isColorReference(value: ColorValue): value is string {
|
|
||||||
return typeof value === "string" && !value.startsWith("#")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,14 +97,6 @@ export interface FeedListOptions {
|
|||||||
compact: boolean
|
compact: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default feed list options */
|
|
||||||
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
|
|
||||||
showEpisodeCount: true,
|
|
||||||
showLastUpdated: true,
|
|
||||||
showSource: false,
|
|
||||||
compact: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feed statistics */
|
/** Feed statistics */
|
||||||
export interface FeedStats {
|
export interface FeedStats {
|
||||||
/** Total feed count */
|
/** Total feed count */
|
||||||
|
|||||||
@@ -79,12 +79,16 @@ export type AppSettings = {
|
|||||||
fontSize: number;
|
fontSize: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
|
/** Render the app background transparent (let the terminal's own bg show). */
|
||||||
|
transparentBackground: boolean;
|
||||||
visualizer: VisualizerSettings;
|
visualizer: VisualizerSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
|
autoJumpToPlayer: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export type SyncData = {
|
|
||||||
version: string
|
|
||||||
lastSyncedAt: string
|
|
||||||
feeds: {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
isPrivate: boolean
|
|
||||||
}[]
|
|
||||||
sources: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
settings: {
|
|
||||||
theme: string
|
|
||||||
playbackSpeed: number
|
|
||||||
downloadPath: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
showExplicit: boolean
|
|
||||||
autoDownload: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
export type SyncDataXML = {
|
|
||||||
version: string
|
|
||||||
lastSyncedAt: string
|
|
||||||
feeds: {
|
|
||||||
feed: {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
isPrivate: boolean
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
sources: {
|
|
||||||
source: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
url: string
|
|
||||||
}[]
|
|
||||||
}
|
|
||||||
settings: {
|
|
||||||
theme: string
|
|
||||||
playbackSpeed: number
|
|
||||||
downloadPath: string
|
|
||||||
}
|
|
||||||
preferences: {
|
|
||||||
showExplicit: boolean
|
|
||||||
autoDownload: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,12 @@ export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
|||||||
export type ThemeJson = {
|
export type ThemeJson = {
|
||||||
$schema?: string
|
$schema?: string
|
||||||
defs?: Record<string, HexColor | RefName>
|
defs?: Record<string, HexColor | RefName>
|
||||||
theme: Record<string, ColorValue> & {
|
theme: Record<string, ColorValue | boolean> & {
|
||||||
selectedListItemText?: ColorValue
|
selectedListItemText?: ColorValue
|
||||||
backgroundMenu?: ColorValue
|
backgroundMenu?: ColorValue
|
||||||
thinkingOpacity?: number
|
thinkingOpacity?: number
|
||||||
|
/** Render the app background transparent (let the terminal's own bg show). */
|
||||||
|
transparent?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ function init() {
|
|||||||
);
|
);
|
||||||
const suspended = () => suspendCount() > 0;
|
const suspended = () => suspendCount() > 0;
|
||||||
|
|
||||||
// Handle keybind shortcuts
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (suspended()) return;
|
if (suspended()) return;
|
||||||
if (dialog.isOpen) return;
|
if (dialog.isOpen) return;
|
||||||
@@ -180,9 +179,8 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
const keybind = useKeybinds();
|
const keybind = useKeybinds();
|
||||||
|
|
||||||
// Open the command palette via the `command` keybind (bound to `:` in
|
// Open the command palette via the `command` keybind (bound to `:` or `q`
|
||||||
// keybinds.jsonc). The old hardcoded "command_list" name was never a
|
// in keybinds.jsonc; the Shell router owns the action and runs it first).
|
||||||
// canonical action, so the palette was unreachable dead code.
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (value.suspended()) return;
|
if (value.suspended()) return;
|
||||||
if (dialog.isOpen) return;
|
if (dialog.isOpen) return;
|
||||||
@@ -258,7 +256,6 @@ function CommandDialog(props: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle text input
|
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
setFilter((f) => f + evt.name);
|
setFilter((f) => f + evt.name);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type DialogSize = "medium" | "large"
|
|||||||
/**
|
/**
|
||||||
* Dialog component that renders a modal overlay with content.
|
* Dialog component that renders a modal overlay with content.
|
||||||
*/
|
*/
|
||||||
export function Dialog(
|
function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
size?: DialogSize
|
size?: DialogSize
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|||||||
@@ -1,158 +1,153 @@
|
|||||||
/**
|
/**
|
||||||
* App state persistence via JSON file in XDG_CONFIG_HOME
|
* App state persistence — settings, preferences, and custom theme are stored
|
||||||
|
* in the centralized `config.json` (see utils/config.ts). Playback progress
|
||||||
|
* and audio-nav state stay in separate files (they change on every seek and
|
||||||
|
* would thrash config.json).
|
||||||
*
|
*
|
||||||
* Reads and writes app settings, preferences, and custom theme to a JSON file
|
* No backups — writes always overwrite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { backupConfigFile } from "./config-backup";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
UserPreferences,
|
UserPreferences,
|
||||||
VisualizerSettings,
|
VisualizerSettings,
|
||||||
} from "../types/settings";
|
} from "../types/settings";
|
||||||
import { DEFAULT_THEME } from "../constants/themes";
|
import { DEFAULT_THEME } from "../constants/themes";
|
||||||
|
|
||||||
const APP_STATE_FILE = "app-state.json";
|
|
||||||
const PROGRESS_FILE = "progress.json";
|
|
||||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
|
||||||
|
|
||||||
// --- Defaults ---
|
// --- Defaults ---
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
bars: 32,
|
bars: 32,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
lowCutOff: 50,
|
lowCutOff: 50,
|
||||||
highCutOff: 10000,
|
highCutOff: 10000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
visualizer: defaultVisualizerSettings,
|
transparentBackground: false,
|
||||||
|
visualizer: defaultVisualizerSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
showExplicit: false,
|
showExplicit: false,
|
||||||
autoDownload: false,
|
autoDownload: false,
|
||||||
|
autoJumpToPlayer: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
settings: defaultSettings,
|
settings: defaultSettings,
|
||||||
preferences: defaultPreferences,
|
preferences: defaultPreferences,
|
||||||
customTheme: DEFAULT_THEME,
|
customTheme: DEFAULT_THEME,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- App State ---
|
// ── App State (config.json) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Load app state from JSON file */
|
/** Load app state from config.json */
|
||||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!cfg || typeof cfg !== "object") return defaultState;
|
||||||
if (!(await file.exists())) return defaultState;
|
return {
|
||||||
|
settings: { ...defaultSettings, ...cfg.settings },
|
||||||
const raw = await file.json();
|
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||||
if (!raw || typeof raw !== "object") return defaultState;
|
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||||
|
};
|
||||||
const parsed = raw as Partial<AppState>;
|
} catch {
|
||||||
return {
|
return defaultState;
|
||||||
settings: { ...defaultSettings, ...parsed.settings },
|
}
|
||||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
|
||||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return defaultState;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save app state to JSON file */
|
/** Save app state to config.json */
|
||||||
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
export function saveAppStateToFile(state: AppState): void {
|
||||||
try {
|
updateConfig({
|
||||||
await ensureConfigDir();
|
settings: state.settings,
|
||||||
await backupConfigFile(APP_STATE_FILE);
|
preferences: state.preferences,
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
customTheme: state.customTheme,
|
||||||
await Bun.write(filePath, JSON.stringify(state, null, 2));
|
});
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Playback Progress (separate file — changes on every seek) ───────────────
|
||||||
|
|
||||||
|
const PROGRESS_FILE = "progress.json";
|
||||||
|
|
||||||
interface ProgressEntry {
|
interface ProgressEntry {
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
position: number;
|
position: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
timestamp: string | Date;
|
timestamp: string | Date;
|
||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load progress map from JSON file */
|
/** Load progress map from JSON file */
|
||||||
export async function loadProgressFromFile(): Promise<
|
export async function loadProgressFromFile(): Promise<
|
||||||
Record<string, ProgressEntry>
|
Record<string, ProgressEntry>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
const filePath = getConfigFilePath(PROGRESS_FILE);
|
||||||
const file = Bun.file(filePath);
|
const file = Bun.file(filePath);
|
||||||
if (!(await file.exists())) return {};
|
if (!(await file.exists())) return {};
|
||||||
|
|
||||||
const raw = await file.json();
|
const raw = await file.json();
|
||||||
if (!raw || typeof raw !== "object") return {};
|
if (!raw || typeof raw !== "object") return {};
|
||||||
return raw as Record<string, ProgressEntry>;
|
return raw as Record<string, ProgressEntry>;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save progress map to JSON file */
|
/** Save progress map to JSON file (overwrite, no backup) */
|
||||||
export async function saveProgressToFile(
|
export function saveProgressToFile(data: Record<string, unknown>): void {
|
||||||
data: Record<string, unknown>,
|
(async () => {
|
||||||
): Promise<void> {
|
try {
|
||||||
try {
|
await ensureConfigDir();
|
||||||
await ensureConfigDir();
|
await Bun.write(
|
||||||
await backupConfigFile(PROGRESS_FILE);
|
getConfigFilePath(PROGRESS_FILE),
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
JSON.stringify(data, null, 2),
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioNavEntry {
|
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||||
source: string;
|
|
||||||
currentIndex: number;
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
podcastId?: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load audio navigation state from JSON file */
|
/** 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 filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||||
const file = Bun.file(filePath);
|
if (!(await file.exists())) return null;
|
||||||
if (!(await file.exists())) return null;
|
|
||||||
|
|
||||||
const raw = await file.json();
|
const raw = await file.json();
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
return raw as T;
|
return raw as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save audio navigation state to JSON file */
|
/** Save audio navigation state to JSON file (overwrite, no backup) */
|
||||||
export async function saveAudioNavToFile<T>(
|
export function saveAudioNavToFile<T>(data: T): void {
|
||||||
data: T,
|
(async () => {
|
||||||
): Promise<void> {
|
try {
|
||||||
try {
|
await ensureConfigDir();
|
||||||
await ensureConfigDir();
|
await Bun.write(
|
||||||
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
getConfigFilePath(AUDIO_NAV_FILE),
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
JSON.stringify(data, null, 2),
|
||||||
} catch {
|
);
|
||||||
// Silently ignore write errors
|
} catch {
|
||||||
}
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,10 +135,8 @@ export class AudioStreamReader {
|
|||||||
this.writePos = 0;
|
this.writePos = 0;
|
||||||
this.totalSamplesWritten = 0;
|
this.totalSamplesWritten = 0;
|
||||||
|
|
||||||
// Capture generation for this run
|
|
||||||
const myGeneration = this.generation;
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
// Start async reading loop
|
|
||||||
this.readLoop(myGeneration);
|
this.readLoop(myGeneration);
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
/**
|
|
||||||
* Audio waveform analysis for PodTUI
|
|
||||||
*
|
|
||||||
* Extracts amplitude data from audio files using ffmpeg (when available)
|
|
||||||
* Results are cache in-memory keyed by audio URL.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Number of amplitude data points to generate */
|
|
||||||
const DEFAULT_RESOLUTION = 128;
|
|
||||||
|
|
||||||
/** In-memory cache: audioUrl -> amplitude data */
|
|
||||||
const waveformCache = new Map<string, number[]>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to extract real waveform data from an audio URL using ffmpeg.
|
|
||||||
* Returns null if ffmpeg is not available or the extraction fails.
|
|
||||||
*/
|
|
||||||
async function extractWithFfmpeg(
|
|
||||||
audioUrl: string,
|
|
||||||
resolution: number,
|
|
||||||
): Promise<number[] | null> {
|
|
||||||
try {
|
|
||||||
if (!Bun.which("ffmpeg")) return null;
|
|
||||||
|
|
||||||
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
|
|
||||||
// -t 300: read at most 5 minutes (enough data to fill the waveform)
|
|
||||||
const proc = Bun.spawn(
|
|
||||||
[
|
|
||||||
"ffmpeg",
|
|
||||||
"-i",
|
|
||||||
audioUrl,
|
|
||||||
"-t",
|
|
||||||
"300",
|
|
||||||
"-ac",
|
|
||||||
"1", // mono
|
|
||||||
"-ar",
|
|
||||||
"8000", // low sample rate to keep data small
|
|
||||||
"-f",
|
|
||||||
"s16le", // raw signed 16-bit PCM
|
|
||||||
"-v",
|
|
||||||
"quiet",
|
|
||||||
"-",
|
|
||||||
],
|
|
||||||
{ stdout: "pipe", stderr: "ignore" },
|
|
||||||
);
|
|
||||||
|
|
||||||
const output = await new Response(proc.stdout).arrayBuffer();
|
|
||||||
await proc.exited;
|
|
||||||
|
|
||||||
if (output.byteLength === 0) return null;
|
|
||||||
|
|
||||||
const samples = new Int16Array(output);
|
|
||||||
if (samples.length === 0) return null;
|
|
||||||
|
|
||||||
// Downsample to `resolution` buckets by taking the max absolute amplitude
|
|
||||||
// in each bucket.
|
|
||||||
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
|
|
||||||
const data: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < resolution; i++) {
|
|
||||||
const start = i * bucketSize;
|
|
||||||
const end = Math.min(start + bucketSize, samples.length);
|
|
||||||
let maxAbs = 0;
|
|
||||||
for (let j = start; j < end; j++) {
|
|
||||||
const abs = Math.abs(samples[j]);
|
|
||||||
if (abs > maxAbs) maxAbs = abs;
|
|
||||||
}
|
|
||||||
// Normalise to 0-1
|
|
||||||
data.push(Number((maxAbs / 32768).toFixed(3)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get waveform data for an audio URL.
|
|
||||||
*
|
|
||||||
* Returns cached data if available, otherwise attempts ffmpeg extraction
|
|
||||||
*/
|
|
||||||
export async function getWaveformData(
|
|
||||||
audioUrl: string,
|
|
||||||
resolution: number = DEFAULT_RESOLUTION,
|
|
||||||
): Promise<number[]> {
|
|
||||||
const cacheKey = `${audioUrl}:${resolution}`;
|
|
||||||
const cached = waveformCache.get(cacheKey);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const real = await extractWithFfmpeg(audioUrl, resolution);
|
|
||||||
if (real) {
|
|
||||||
waveformCache.set(cacheKey, real);
|
|
||||||
return real;
|
|
||||||
} else {
|
|
||||||
console.error("generation failure");
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearWaveformCache(): void {
|
|
||||||
waveformCache.clear();
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
type CacheEntry<T> = {
|
|
||||||
value: T
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_KEY = "podtui_cache"
|
|
||||||
const DEFAULT_TTL = 1000 * 60 * 60
|
|
||||||
|
|
||||||
const loadCache = (): Record<string, CacheEntry<unknown>> => {
|
|
||||||
if (typeof localStorage === "undefined") return {}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(CACHE_KEY)
|
|
||||||
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = loadCache()
|
|
||||||
|
|
||||||
export const cacheValue = <T,>(key: string, value: T) => {
|
|
||||||
cache[key] = { value, timestamp: Date.now() }
|
|
||||||
saveCache(cache)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
|
|
||||||
const entry = cache[key] as CacheEntry<T> | undefined
|
|
||||||
if (!entry) return null
|
|
||||||
if (Date.now() - entry.timestamp > ttl) {
|
|
||||||
delete cache[key]
|
|
||||||
saveCache(cache)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return entry.value
|
|
||||||
}
|
|
||||||
|
|
||||||
export const invalidateCache = (prefix?: string) => {
|
|
||||||
if (!prefix) {
|
|
||||||
Object.keys(cache).forEach((key) => delete cache[key])
|
|
||||||
saveCache(cache)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(cache)
|
|
||||||
.filter((key) => key.startsWith(prefix))
|
|
||||||
.forEach((key) => delete cache[key])
|
|
||||||
saveCache(cache)
|
|
||||||
}
|
|
||||||
@@ -106,7 +106,7 @@ export namespace Clipboard {
|
|||||||
/**
|
/**
|
||||||
* Read text from the clipboard.
|
* Read text from the clipboard.
|
||||||
*/
|
*/
|
||||||
export async function readText(): Promise<string | undefined> {
|
async function readText(): Promise<string | undefined> {
|
||||||
const os = platform()
|
const os = platform()
|
||||||
|
|
||||||
if (os === "darwin") {
|
if (os === "darwin") {
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Config file backup utility for PodTUI
|
|
||||||
*
|
|
||||||
* Creates timestamped backups of config files before updates.
|
|
||||||
* Keeps the most recent N backups and cleans up older ones.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readdir, unlink } from "fs/promises"
|
|
||||||
import path from "path"
|
|
||||||
import { getConfigDir, ensureConfigDir } from "./config-dir"
|
|
||||||
|
|
||||||
/** Maximum number of backup files to keep per config file */
|
|
||||||
const MAX_BACKUPS = 5
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a timestamped backup filename.
|
|
||||||
* Example: feeds.json -> feeds.json.2026-02-05T120000.backup
|
|
||||||
*/
|
|
||||||
function backupFilename(originalName: string): string {
|
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15)
|
|
||||||
return `${originalName}.${ts}.backup`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a backup of a config file before overwriting it.
|
|
||||||
* No-op if the source file does not exist.
|
|
||||||
*/
|
|
||||||
export async function backupConfigFile(filename: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await ensureConfigDir()
|
|
||||||
const dir = getConfigDir()
|
|
||||||
const srcPath = path.join(dir, filename)
|
|
||||||
const srcFile = Bun.file(srcPath)
|
|
||||||
|
|
||||||
if (!(await srcFile.exists())) return false
|
|
||||||
|
|
||||||
const content = await srcFile.text()
|
|
||||||
if (!content || content.trim().length === 0) return false
|
|
||||||
|
|
||||||
const backupName = backupFilename(filename)
|
|
||||||
const backupPath = path.join(dir, backupName)
|
|
||||||
await Bun.write(backupPath, content)
|
|
||||||
|
|
||||||
// Clean up old backups
|
|
||||||
await pruneBackups(filename)
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep only the most recent MAX_BACKUPS backup files for a given config file.
|
|
||||||
*/
|
|
||||||
async function pruneBackups(filename: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const dir = getConfigDir()
|
|
||||||
const entries = await readdir(dir)
|
|
||||||
|
|
||||||
// Match pattern: filename.*.backup
|
|
||||||
const prefix = `${filename}.`
|
|
||||||
const suffix = ".backup"
|
|
||||||
const backups = entries
|
|
||||||
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
|
|
||||||
.sort() // Lexicographic sort works because timestamps are ISO-like
|
|
||||||
|
|
||||||
if (backups.length <= MAX_BACKUPS) return
|
|
||||||
|
|
||||||
const toRemove = backups.slice(0, backups.length - MAX_BACKUPS)
|
|
||||||
for (const name of toRemove) {
|
|
||||||
await unlink(path.join(dir, name)).catch(() => {})
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List existing backup files for a given config file, newest first.
|
|
||||||
*/
|
|
||||||
export async function listBackups(filename: string): Promise<string[]> {
|
|
||||||
try {
|
|
||||||
const dir = getConfigDir()
|
|
||||||
const entries = await readdir(dir)
|
|
||||||
|
|
||||||
const prefix = `${filename}.`
|
|
||||||
const suffix = ".backup"
|
|
||||||
return entries
|
|
||||||
.filter((e) => e.startsWith(prefix) && e.endsWith(suffix))
|
|
||||||
.sort()
|
|
||||||
.reverse()
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,7 @@ import path from "path"
|
|||||||
const APP_DIR_NAME = "podtui"
|
const APP_DIR_NAME = "podtui"
|
||||||
|
|
||||||
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
/** Resolve the XDG_CONFIG_HOME directory, defaulting to ~/.config */
|
||||||
export function getXdgConfigHome(): string {
|
function getXdgConfigHome(): string {
|
||||||
const xdg = process.env.XDG_CONFIG_HOME
|
const xdg = process.env.XDG_CONFIG_HOME
|
||||||
if (xdg) return xdg
|
if (xdg) return xdg
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export async function ensureConfigDir(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
/** Resolve the XDG_DATA_HOME directory, defaulting to ~/.local/share */
|
||||||
export function getXdgDataHome(): string {
|
function getXdgDataHome(): string {
|
||||||
const xdg = process.env.XDG_DATA_HOME
|
const xdg = process.env.XDG_DATA_HOME
|
||||||
if (xdg) return xdg
|
if (xdg) return xdg
|
||||||
|
|
||||||
@@ -55,12 +55,12 @@ export function getXdgDataHome(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get the application-specific data directory path */
|
/** Get the application-specific data directory path */
|
||||||
export function getDataDir(): string {
|
function getDataDir(): string {
|
||||||
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
return path.join(getXdgDataHome(), APP_DIR_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the downloads directory path */
|
/** Get the downloads directory path */
|
||||||
export function getDownloadsDir(): string {
|
function getDownloadsDir(): string {
|
||||||
return path.join(getDataDir(), "downloads")
|
return path.join(getDataDir(), "downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/**
|
|
||||||
* Validates JSON structure of config files, handles corrupted files
|
|
||||||
* gracefully (falling back to defaults), and provides a single
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getConfigFilePath } from "./config-dir";
|
|
||||||
// --- Validation helpers ---
|
|
||||||
|
|
||||||
/** Check that a value is a non-null object */
|
|
||||||
function isObject(v: unknown): v is Record<string, unknown> {
|
|
||||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate AppState JSON structure */
|
|
||||||
export function validateAppState(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!isObject(data)) {
|
|
||||||
return { valid: false, errors: ["app-state.json is not an object"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// settings
|
|
||||||
if (data.settings !== undefined) {
|
|
||||||
if (!isObject(data.settings)) {
|
|
||||||
errors.push("settings must be an object");
|
|
||||||
} else {
|
|
||||||
const s = data.settings as Record<string, unknown>;
|
|
||||||
if (s.theme !== undefined && typeof s.theme !== "string")
|
|
||||||
errors.push("settings.theme must be a string");
|
|
||||||
if (s.fontSize !== undefined && typeof s.fontSize !== "number")
|
|
||||||
errors.push("settings.fontSize must be a number");
|
|
||||||
if (s.playbackSpeed !== undefined && typeof s.playbackSpeed !== "number")
|
|
||||||
errors.push("settings.playbackSpeed must be a number");
|
|
||||||
if (s.downloadPath !== undefined && typeof s.downloadPath !== "string")
|
|
||||||
errors.push("settings.downloadPath must be a string");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// preferences
|
|
||||||
if (data.preferences !== undefined) {
|
|
||||||
if (!isObject(data.preferences)) {
|
|
||||||
errors.push("preferences must be an object");
|
|
||||||
} else {
|
|
||||||
const p = data.preferences as Record<string, unknown>;
|
|
||||||
if (p.showExplicit !== undefined && typeof p.showExplicit !== "boolean")
|
|
||||||
errors.push("preferences.showExplicit must be a boolean");
|
|
||||||
if (p.autoDownload !== undefined && typeof p.autoDownload !== "boolean")
|
|
||||||
errors.push("preferences.autoDownload must be a boolean");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// customTheme
|
|
||||||
if (data.customTheme !== undefined && !isObject(data.customTheme)) {
|
|
||||||
errors.push("customTheme must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate feeds JSON structure */
|
|
||||||
export function validateFeeds(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
return { valid: false, errors: ["feeds.json is not an array"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
const feed = data[i];
|
|
||||||
if (!isObject(feed)) {
|
|
||||||
errors.push(`feeds[${i}] is not an object`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof feed.id !== "string")
|
|
||||||
errors.push(`feeds[${i}].id must be a string`);
|
|
||||||
if (!isObject(feed.podcast))
|
|
||||||
errors.push(`feeds[${i}].podcast must be an object`);
|
|
||||||
if (!Array.isArray(feed.episodes))
|
|
||||||
errors.push(`feeds[${i}].episodes must be an array`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate progress JSON structure */
|
|
||||||
export function validateProgress(data: unknown): {
|
|
||||||
valid: boolean;
|
|
||||||
errors: string[];
|
|
||||||
} {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (!isObject(data)) {
|
|
||||||
return { valid: false, errors: ["progress.json is not an object"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(data)) {
|
|
||||||
if (!isObject(value)) {
|
|
||||||
errors.push(`progress["${key}"] is not an object`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const p = value as Record<string, unknown>;
|
|
||||||
if (typeof p.episodeId !== "string")
|
|
||||||
errors.push(`progress["${key}"].episodeId must be a string`);
|
|
||||||
if (typeof p.position !== "number")
|
|
||||||
errors.push(`progress["${key}"].position must be a number`);
|
|
||||||
if (typeof p.duration !== "number")
|
|
||||||
errors.push(`progress["${key}"].duration must be a number`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: errors.length === 0, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Safe config file reading ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Safely read and validate a config file.
|
|
||||||
* Returns the parsed data if valid, or null if the file is missing/corrupt.
|
|
||||||
*/
|
|
||||||
export async function safeReadConfigFile<T>(
|
|
||||||
filename: string,
|
|
||||||
validator: (data: unknown) => { valid: boolean; errors: string[] },
|
|
||||||
): Promise<{ data: T | null; errors: string[] }> {
|
|
||||||
try {
|
|
||||||
const filePath = getConfigFilePath(filename);
|
|
||||||
const file = Bun.file(filePath);
|
|
||||||
if (!(await file.exists())) {
|
|
||||||
return { data: null, errors: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await file.text();
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return { data: null, errors: [`${filename}: invalid JSON`] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = validator(parsed);
|
|
||||||
if (!result.valid) {
|
|
||||||
return { data: null, errors: result.errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { data: parsed as T, errors: [] };
|
|
||||||
} catch (err) {
|
|
||||||
return { data: null, errors: [`${filename}: ${String(err)}`] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
182
src/utils/config.ts
Normal file
182
src/utils/config.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Centralized PodTui configuration — a single `config.json` holding every
|
||||||
|
* user-facing bit needed to migrate to a new machine by copying one file.
|
||||||
|
*
|
||||||
|
* Contains: settings, preferences, custom theme, feeds (subscriptions), and
|
||||||
|
* sources (podcast search/RSS sources).
|
||||||
|
*
|
||||||
|
* Runtime state that changes on every playback action (progress, downloads,
|
||||||
|
* audio-nav) stays in separate files to avoid rewriting this file on every
|
||||||
|
* seek. Keybinds remain in `keybinds.jsonc` (user-editable JSONC).
|
||||||
|
*
|
||||||
|
* Writes are serialized to avoid concurrent read-modify-write races, and
|
||||||
|
* always overwrite — no backup files are created.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
|
import type {
|
||||||
|
AppSettings,
|
||||||
|
UserPreferences,
|
||||||
|
ThemeColors,
|
||||||
|
} from "../types/settings";
|
||||||
|
import type { Feed } from "../types/feed";
|
||||||
|
import type { PodcastSource } from "../types/source";
|
||||||
|
|
||||||
|
/** Everything a user needs to migrate, in one file. */
|
||||||
|
export interface PodTuiConfig {
|
||||||
|
settings?: AppSettings;
|
||||||
|
preferences?: UserPreferences;
|
||||||
|
customTheme?: ThemeColors;
|
||||||
|
feeds?: Feed[];
|
||||||
|
sources?: PodcastSource[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_FILE = "config.json";
|
||||||
|
|
||||||
|
/** Legacy per-section files, migrated into config.json on first load. */
|
||||||
|
const LEGACY_FILES = ["app-state.json", "feeds.json", "sources.json"] as const;
|
||||||
|
|
||||||
|
/** Load the full config from disk. Returns {} if missing or corrupt.
|
||||||
|
* Runs one-time legacy migration on first call. */
|
||||||
|
export async function loadConfig(): Promise<PodTuiConfig> {
|
||||||
|
await migrateOnce();
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(CONFIG_FILE));
|
||||||
|
if (!(await file.exists())) return {};
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!raw || typeof raw !== "object") return {};
|
||||||
|
return raw as PodTuiConfig;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write serialization ────────────────────────────────────────────────────
|
||||||
|
// A simple promise chain ensures reads-modify-writes execute sequentially so
|
||||||
|
// two concurrent saves can't clobber each other's sections.
|
||||||
|
let writeChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
|
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||||
|
writeChain = writeChain.then(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const current = await loadConfig();
|
||||||
|
const next = { ...current, ...patch };
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
JSON.stringify(next, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget persistence — silently ignore write errors.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards so migration runs exactly once per process. */
|
||||||
|
let migrationDone = false;
|
||||||
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Run legacy migration + backup cleanup once, before the first config read. */
|
||||||
|
async function migrateOnce(): Promise<void> {
|
||||||
|
if (migrationDone) return;
|
||||||
|
if (!migrationPromise) migrationPromise = migrateLegacyConfig();
|
||||||
|
await migrationPromise;
|
||||||
|
migrationDone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time migration: if config.json doesn't exist but legacy per-section
|
||||||
|
* files do, merge them into a single config.json. Also cleans up any stale
|
||||||
|
* backup files (`.backup` suffix) left by the old config-backup module.
|
||||||
|
*
|
||||||
|
* Safe to call on every startup — no-op once config.json exists (except for
|
||||||
|
* backup cleanup, which runs unconditionally since those files are now dead).
|
||||||
|
*/
|
||||||
|
async function migrateLegacyConfig(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
const dir = getConfigDir();
|
||||||
|
const configExists = await Bun.file(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
).exists();
|
||||||
|
|
||||||
|
if (!configExists) {
|
||||||
|
const merged: PodTuiConfig = {};
|
||||||
|
|
||||||
|
// app-state.json → settings, preferences, customTheme
|
||||||
|
const appStateFile = Bun.file(getConfigFilePath("app-state.json"));
|
||||||
|
if (await appStateFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await appStateFile.json();
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
merged.settings = raw.settings;
|
||||||
|
merged.preferences = raw.preferences;
|
||||||
|
merged.customTheme = raw.customTheme;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore corrupt legacy file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// feeds.json → feeds
|
||||||
|
const feedsFile = Bun.file(getConfigFilePath("feeds.json"));
|
||||||
|
if (await feedsFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await feedsFile.json();
|
||||||
|
if (Array.isArray(raw)) merged.feeds = raw;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sources.json → sources
|
||||||
|
const sourcesFile = Bun.file(getConfigFilePath("sources.json"));
|
||||||
|
if (await sourcesFile.exists()) {
|
||||||
|
try {
|
||||||
|
const raw = await sourcesFile.json();
|
||||||
|
if (Array.isArray(raw)) merged.sources = raw;
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(merged).length > 0) {
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(CONFIG_FILE),
|
||||||
|
JSON.stringify(merged, null, 2),
|
||||||
|
);
|
||||||
|
// Remove migrated legacy files
|
||||||
|
for (const name of LEGACY_FILES) {
|
||||||
|
await Bun.file(getConfigFilePath(name))
|
||||||
|
.exists()
|
||||||
|
.then(async (exists) => {
|
||||||
|
if (exists)
|
||||||
|
await import("fs/promises").then((fs) =>
|
||||||
|
fs.unlink(getConfigFilePath(name)).catch(() => {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up stale backup files (no longer created, remove old ones)
|
||||||
|
await cleanBackups(dir);
|
||||||
|
} catch {
|
||||||
|
// Migration is best-effort — never block startup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all `.backup` files from the config directory. */
|
||||||
|
async function cleanBackups(dir: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { readdir, unlink } = await import("fs/promises");
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const backups = entries.filter((e) => e.endsWith(".backup"));
|
||||||
|
for (const name of backups) {
|
||||||
|
await unlink(`${dir}/${name}`).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
import type { Episode } from "../types/episode"
|
|
||||||
import type { Podcast } from "../types/podcast"
|
|
||||||
import { cacheValue, getCachedValue } from "./cache"
|
|
||||||
import { fetchEpisodes } from "@/api/client"
|
|
||||||
|
|
||||||
const feedKey = (feedUrl: string) => `feed:${feedUrl}`
|
|
||||||
const episodesKey = (feedUrl: string) => `episodes:${feedUrl}`
|
|
||||||
const searchKey = (query: string) => `search:${query.toLowerCase()}`
|
|
||||||
|
|
||||||
export const fetchFeedWithCache = async (feedUrl: string): Promise<Feed | null> => {
|
|
||||||
const cached = getCachedValue<Feed>(feedKey(feedUrl))
|
|
||||||
if (cached) return cached
|
|
||||||
try {
|
|
||||||
const episodes = await fetchEpisodes(feedUrl)
|
|
||||||
const feed: Feed = {
|
|
||||||
id: feedUrl,
|
|
||||||
podcast: {
|
|
||||||
id: feedUrl,
|
|
||||||
title: feedUrl,
|
|
||||||
description: "",
|
|
||||||
feedUrl,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes,
|
|
||||||
visibility: FeedVisibility.PUBLIC,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
isPinned: false,
|
|
||||||
}
|
|
||||||
cacheValue(feedKey(feedUrl), feed)
|
|
||||||
return feed
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fetchEpisodesWithCache = async (feedUrl: string): Promise<Episode[]> => {
|
|
||||||
const cached = getCachedValue<Episode[]>(episodesKey(feedUrl))
|
|
||||||
if (cached) return cached
|
|
||||||
const episodes = await fetchEpisodes(feedUrl)
|
|
||||||
cacheValue(episodesKey(feedUrl), episodes)
|
|
||||||
return episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
export const searchWithCache = async (
|
|
||||||
query: string,
|
|
||||||
fetcher: () => Promise<Podcast[]>
|
|
||||||
): Promise<Podcast[]> => {
|
|
||||||
const cached = getCachedValue<Podcast[]>(searchKey(query))
|
|
||||||
if (cached) return cached
|
|
||||||
const results = await fetcher()
|
|
||||||
cacheValue(searchKey(query), results)
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
@@ -75,10 +75,11 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
|||||||
"sort",
|
"sort",
|
||||||
"toggle-hidden",
|
"toggle-hidden",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"unsubscribe",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
/** Resolve a `tab-goto-N` digit action (1..TabsCount) to a TABS value, or null. */
|
||||||
export function tabByDigit(action: KeybindActionName): TABS | null {
|
function tabByDigit(action: KeybindActionName): TABS | null {
|
||||||
if (action.startsWith("tab-goto-")) {
|
if (action.startsWith("tab-goto-")) {
|
||||||
const n = Number(action.slice("tab-goto-".length));
|
const n = Number(action.slice("tab-goto-".length));
|
||||||
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
return (n >= 1 && n <= TabsCount ? n : null) as TABS | null;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createEventBus(): EventBusInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Singleton event bus instance
|
// Singleton event bus instance
|
||||||
export const EventBus = createEventBus();
|
const EventBus = createEventBus();
|
||||||
|
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { TABS } from "@/utils/navigation";
|
import type { TABS } from "@/utils/navigation";
|
||||||
@@ -105,8 +105,8 @@ export type AppEvents = {
|
|||||||
"player.play": { episodeId: string };
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string };
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {};
|
"player.stop": {};
|
||||||
"auth.login": { userId: string };
|
// Emitted when a NEW episode begins playback (not on resume).
|
||||||
"auth.logout": {};
|
"player.started": { episodeId: string };
|
||||||
"toast.show": {
|
"toast.show": {
|
||||||
message: string;
|
message: string;
|
||||||
variant: "info" | "success" | "warning" | "error";
|
variant: "info" | "success" | "warning" | "error";
|
||||||
|
|||||||
@@ -1,82 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
* Feeds & sources persistence — stored in the centralized `config.json`
|
||||||
*
|
* (see utils/config.ts). No backups; writes always overwrite.
|
||||||
* Reads and writes feeds to a JSON file instead of localStorage.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import { backupConfigFile } from "./config-backup";
|
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
|
import type { PodcastSource } from "../types/source";
|
||||||
const FEEDS_FILE = "feeds.json";
|
|
||||||
const SOURCES_FILE = "sources.json";
|
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
return {
|
return {
|
||||||
...feed,
|
...feed,
|
||||||
lastUpdated: new Date(feed.lastUpdated),
|
lastUpdated: new Date(feed.lastUpdated),
|
||||||
podcast: {
|
podcast: {
|
||||||
...feed.podcast,
|
...feed.podcast,
|
||||||
lastUpdated: new Date(feed.podcast.lastUpdated),
|
lastUpdated: new Date(feed.podcast.lastUpdated),
|
||||||
},
|
},
|
||||||
episodes: feed.episodes.map((ep) => ({
|
episodes: feed.episodes.map((ep) => ({
|
||||||
...ep,
|
...ep,
|
||||||
pubDate: new Date(ep.pubDate),
|
pubDate: new Date(ep.pubDate),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load feeds from JSON file */
|
/** Load feeds from config.json */
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
if (!(await file.exists())) return [];
|
return cfg.feeds.map(reviveDates);
|
||||||
|
} catch {
|
||||||
const raw = await file.json();
|
return [];
|
||||||
if (!Array.isArray(raw)) return [];
|
}
|
||||||
return raw.map(reviveDates);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to JSON file */
|
/** Save feeds to config.json */
|
||||||
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
export function saveFeedsToFile(feeds: Feed[]): void {
|
||||||
try {
|
updateConfig({ feeds });
|
||||||
await ensureConfigDir();
|
|
||||||
await backupConfigFile(FEEDS_FILE);
|
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
|
||||||
await Bun.write(filePath, JSON.stringify(feeds, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from JSON file */
|
/** Load sources from config.json */
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.sources)) return null;
|
||||||
if (!(await file.exists())) return null;
|
return cfg.sources as T[];
|
||||||
|
} catch {
|
||||||
const raw = await file.json();
|
return null;
|
||||||
if (!Array.isArray(raw)) return null;
|
}
|
||||||
return raw as T[];
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to JSON file */
|
/** Save sources to config.json */
|
||||||
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
try {
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
await ensureConfigDir();
|
|
||||||
await backupConfigFile(SOURCES_FILE);
|
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
|
||||||
await Bun.write(filePath, JSON.stringify(sources, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
/**
|
/**
|
||||||
* Remove JSONC comments from a string
|
* Remove JSONC comments from a string
|
||||||
*/
|
*/
|
||||||
export function stripComments(jsonString: string): string {
|
function stripComments(jsonString: string): string {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||||
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
{ pattern: /\/\*[\s\S]*?\*\//g, replacement: "" },
|
||||||
|
|||||||
@@ -53,9 +53,10 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
|||||||
"tab-goto-4": ["4"],
|
"tab-goto-4": ["4"],
|
||||||
"tab-goto-5": ["5"],
|
"tab-goto-5": ["5"],
|
||||||
"tab-goto-6": ["6"],
|
"tab-goto-6": ["6"],
|
||||||
// command / help / quit
|
// command palette / help / quit
|
||||||
command: [":"],
|
// q opens the palette (type q + Enter to quit there); Q is the quick quit.
|
||||||
quit: ["q", "ctrl-c"],
|
command: [":", "q"],
|
||||||
|
quit: ["Q", "ctrl-c"],
|
||||||
help: ["~", "f1"],
|
help: ["~", "f1"],
|
||||||
// list ops
|
// list ops
|
||||||
search: ["s"],
|
search: ["s"],
|
||||||
@@ -63,6 +64,7 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
|||||||
sort: [","],
|
sort: [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
refresh: ["r"],
|
refresh: ["r"],
|
||||||
|
unsubscribe: ["x"],
|
||||||
// audio transport (preserved; shifted single keys, no collisions)
|
// audio transport (preserved; shifted single keys, no collisions)
|
||||||
"audio-toggle": ["P"],
|
"audio-toggle": ["P"],
|
||||||
"audio-next": ["N"],
|
"audio-next": ["N"],
|
||||||
@@ -76,7 +78,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
const targetPath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
const targetFile = Bun.file(targetPath);
|
const targetFile = Bun.file(targetPath);
|
||||||
if (await targetFile.exists()) return;
|
if (await targetFile.exists()) return;
|
||||||
|
|
||||||
|
|||||||
@@ -57,13 +57,13 @@ export function rootFrameFor(
|
|||||||
// terminal size — more robust than fixed percentages and exactly mirrors
|
// terminal size — more robust than fixed percentages and exactly mirrors
|
||||||
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
// yazi's `mgr.ratio` config. Set a slot's ratio to 0 to hide it (2-pane tabs).
|
||||||
//
|
//
|
||||||
// NOTE (task 01 leave-behind): the nav-model task intentionally does NOT
|
// Current ratios: parent : current : preview = 1 : 2 : 2, i.e. 1/5 : 2/5 : 2/5
|
||||||
// touch these values. Task 02 re-tunes them to the remake target ratios
|
// (20% / 40% / 40% of the row width). 2-pane tabs drop the preview slot and
|
||||||
// (parent : current : preview = 1 : 3 : 3 i.e. 1/7 : 3/7 : 3/7). Do it there.
|
// give `current` the combined 4/5.
|
||||||
export const PANE_RATIO = {
|
export const PANE_RATIO = {
|
||||||
parent: 1,
|
parent: 1,
|
||||||
current: 3,
|
current: 2,
|
||||||
preview: 3,
|
preview: 2,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Number of *focusable* content panes per tab. The three visible columns
|
// Number of *focusable* content panes per tab. The three visible columns
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import type { AppSettings, UserPreferences } from "../types/settings"
|
|
||||||
import type { Feed } from "../types/feed"
|
|
||||||
|
|
||||||
const STORAGE_KEYS = {
|
|
||||||
settings: "podtui_settings",
|
|
||||||
preferences: "podtui_preferences",
|
|
||||||
feeds: "podtui_feeds",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const savePreference = (key: keyof UserPreferences, value: boolean) => {
|
|
||||||
const current = loadPreferences()
|
|
||||||
const next = { ...current, [key]: value }
|
|
||||||
savePreferences(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadPreference = (key: keyof UserPreferences) => {
|
|
||||||
return loadPreferences()[key]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveSettings = (settings: AppSettings) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadSettings = (): AppSettings | null => {
|
|
||||||
if (typeof localStorage === "undefined") return null
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.settings)
|
|
||||||
return raw ? (JSON.parse(raw) as AppSettings) : null
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const savePreferences = (preferences: UserPreferences) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadPreferences = (): UserPreferences => {
|
|
||||||
if (typeof localStorage === "undefined") {
|
|
||||||
return { showExplicit: false, autoDownload: false }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.preferences)
|
|
||||||
return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false }
|
|
||||||
} catch {
|
|
||||||
return { showExplicit: false, autoDownload: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const saveFeeds = (feeds: Feed[]) => {
|
|
||||||
if (typeof localStorage === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadFeeds = (): Feed[] => {
|
|
||||||
if (typeof localStorage === "undefined") return []
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEYS.feeds)
|
|
||||||
return raw ? (JSON.parse(raw) as Feed[]) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { searchSourceByType } from "./source-searcher";
|
import { searchSourceByType } from "./source-searcher";
|
||||||
|
import { parseRSSFeed } from "../api/rss-parser";
|
||||||
|
import { SourceType } from "../types/source";
|
||||||
import type { PodcastSource, SearchResult } from "../types/source";
|
import type { PodcastSource, SearchResult } from "../types/source";
|
||||||
import type { Episode } from "../types/episode";
|
|
||||||
|
|
||||||
type SearchCacheEntry = {
|
type SearchCacheEntry = {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
@@ -56,6 +57,47 @@ const dedupeResults = (results: SearchResult[]): SearchResult[] => {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FEED_URL_RE = /^https?:\/\/.+/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the query is a direct RSS feed URL (useful for private feeds that aren't
|
||||||
|
* in public directories), fetch and parse it into a single search result.
|
||||||
|
* Returns an empty array when the query is not a URL so normal search proceeds.
|
||||||
|
*/
|
||||||
|
export const searchByFeedUrl = async (
|
||||||
|
query: string,
|
||||||
|
): Promise<SearchResult[]> => {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!FEED_URL_RE.test(trimmed)) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(trimmed, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const podcast = parseRSSFeed(xml, trimmed);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
sourceId: "direct-rss",
|
||||||
|
sourceName: "RSS Feed",
|
||||||
|
sourceType: SourceType.RSS,
|
||||||
|
// parseRSSFeed marks feeds subscribed; a search result should start
|
||||||
|
// unsubscribed so the store can flag it correctly if already added.
|
||||||
|
podcast: { ...podcast, isSubscribed: false },
|
||||||
|
score: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const searchPodcasts = async (
|
export const searchPodcasts = async (
|
||||||
query: string,
|
query: string,
|
||||||
sourceIds: string[],
|
sourceIds: string[],
|
||||||
@@ -114,61 +156,4 @@ export const searchPodcasts = async (
|
|||||||
return sorted;
|
return sorted;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ItunesEpisodeResult = {
|
|
||||||
trackId?: number;
|
|
||||||
trackName?: string;
|
|
||||||
description?: string;
|
|
||||||
shortDescription?: string;
|
|
||||||
releaseDate?: string;
|
|
||||||
trackTimeMillis?: number;
|
|
||||||
episodeUrl?: string;
|
|
||||||
previewUrl?: string;
|
|
||||||
trackViewUrl?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ItunesEpisodeResponse = {
|
|
||||||
resultCount: number;
|
|
||||||
results: ItunesEpisodeResult[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const searchEpisodes = async (
|
|
||||||
query: string,
|
|
||||||
feedId: string,
|
|
||||||
): Promise<Episode[]> => {
|
|
||||||
const trimmed = query.trim();
|
|
||||||
if (!trimmed) return [];
|
|
||||||
|
|
||||||
const url = new URL("https://itunes.apple.com/search");
|
|
||||||
url.searchParams.set("term", trimmed);
|
|
||||||
url.searchParams.set("media", "podcast");
|
|
||||||
url.searchParams.set("entity", "podcastEpisode");
|
|
||||||
url.searchParams.set("country", "US");
|
|
||||||
url.searchParams.set("lang", "en_us");
|
|
||||||
|
|
||||||
const response = await fetch(url.toString());
|
|
||||||
if (!response.ok) return [];
|
|
||||||
|
|
||||||
const data = (await response.json()) as ItunesEpisodeResponse;
|
|
||||||
return data.results
|
|
||||||
.map((item) => {
|
|
||||||
if (!item.trackName) return null;
|
|
||||||
const id = item.trackId
|
|
||||||
? `episode-${item.trackId}`
|
|
||||||
: `episode-${item.trackName}`;
|
|
||||||
const audioUrl =
|
|
||||||
item.episodeUrl || item.previewUrl || item.trackViewUrl || "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
podcastId: feedId,
|
|
||||||
title: item.trackName,
|
|
||||||
description: item.description || item.shortDescription || "",
|
|
||||||
audioUrl,
|
|
||||||
duration: item.trackTimeMillis
|
|
||||||
? Math.round(item.trackTimeMillis / 1000)
|
|
||||||
: 0,
|
|
||||||
pubDate: item.releaseDate ? new Date(item.releaseDate) : new Date(),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((item): item is Episode => Boolean(item));
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const makeResults = (query: string, source: PodcastSource, seedOffset = 0): Sear
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchRSSSource = async (
|
const searchRSSSource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
@@ -148,7 +148,7 @@ const mapItunesResult = (result: ItunesResult, source: PodcastSource): Podcast |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchAPISource = async (
|
const searchAPISource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
@@ -173,7 +173,7 @@ export const searchAPISource = async (
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchCustomSource = async (
|
const searchCustomSource = async (
|
||||||
query: string,
|
query: string,
|
||||||
source: PodcastSource
|
source: PodcastSource
|
||||||
): Promise<SearcherResult> => {
|
): Promise<SearcherResult> => {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import type { SyncData } from "../types/sync-json"
|
|
||||||
import type { SyncDataXML } from "../types/sync-xml"
|
|
||||||
import { syncFormats } from "../constants/sync-formats"
|
|
||||||
|
|
||||||
const isObject = (value: unknown): value is { [key: string]: unknown } =>
|
|
||||||
typeof value === "object" && value !== null
|
|
||||||
|
|
||||||
const hasVersion = (value: unknown): value is { version: string } =>
|
|
||||||
isObject(value) && typeof value.version === "string"
|
|
||||||
|
|
||||||
export function validateJSONSync(data: unknown): SyncData {
|
|
||||||
if (!hasVersion(data) || data.version !== syncFormats.json.version) {
|
|
||||||
throw { message: "Unsupported sync format" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as SyncData
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateXMLSync(data: unknown): SyncDataXML {
|
|
||||||
if (!hasVersion(data) || data.version !== syncFormats.xml.version) {
|
|
||||||
throw { message: "Unsupported sync format" }
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as SyncDataXML
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { SyncData } from "../types/sync-json"
|
|
||||||
import type { SyncDataXML } from "../types/sync-xml"
|
|
||||||
import { validateJSONSync, validateXMLSync } from "./sync-validation"
|
|
||||||
import { syncFormats } from "../constants/sync-formats"
|
|
||||||
import { FeedVisibility } from "../types/feed"
|
|
||||||
|
|
||||||
export function exportToJSON(data: SyncData): string {
|
|
||||||
return `{\n "version": "${data.version}",\n "lastSyncedAt": "${data.lastSyncedAt}",\n "feeds": [],\n "sources": [],\n "settings": {\n "theme": "${data.settings.theme}",\n "playbackSpeed": ${data.settings.playbackSpeed},\n "downloadPath": "${data.settings.downloadPath}"\n },\n "preferences": {\n "showExplicit": ${data.preferences.showExplicit},\n "autoDownload": ${data.preferences.autoDownload}\n }\}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importFromJSON(json: string): SyncData {
|
|
||||||
const data = json
|
|
||||||
return validateJSONSync(data as unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exportToXML(data: SyncDataXML): string {
|
|
||||||
const feedItems = ""
|
|
||||||
const sourceItems = ""
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>\n` +
|
|
||||||
`<podcastSync version="${syncFormats.xml.version}">\n` +
|
|
||||||
` <lastSyncedAt>${data.lastSyncedAt}</lastSyncedAt>\n` +
|
|
||||||
` <feeds>\n` +
|
|
||||||
feedItems +
|
|
||||||
` </feeds>\n` +
|
|
||||||
` <sources>\n` +
|
|
||||||
sourceItems +
|
|
||||||
` </sources>\n` +
|
|
||||||
` <settings>\n` +
|
|
||||||
` <theme>${data.settings.theme}</theme>\n` +
|
|
||||||
` <playbackSpeed>${data.settings.playbackSpeed}</playbackSpeed>\n` +
|
|
||||||
` <downloadPath>${data.settings.downloadPath}</downloadPath>\n` +
|
|
||||||
` </settings>\n` +
|
|
||||||
` <preferences>\n` +
|
|
||||||
` <showExplicit>${data.preferences.showExplicit}</showExplicit>\n` +
|
|
||||||
` <autoDownload>${data.preferences.autoDownload}</autoDownload>\n` +
|
|
||||||
` </preferences>\n` +
|
|
||||||
`</podcastSync>`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function importFromXML(xml: string): SyncDataXML {
|
|
||||||
const version = syncFormats.xml.version
|
|
||||||
const data = {
|
|
||||||
version,
|
|
||||||
lastSyncedAt: "",
|
|
||||||
feeds: { feed: [] },
|
|
||||||
sources: { source: [] },
|
|
||||||
settings: {
|
|
||||||
theme: "system",
|
|
||||||
playbackSpeed: 1,
|
|
||||||
downloadPath: "",
|
|
||||||
},
|
|
||||||
preferences: {
|
|
||||||
showExplicit: false,
|
|
||||||
autoDownload: false,
|
|
||||||
},
|
|
||||||
} as SyncDataXML
|
|
||||||
|
|
||||||
return validateXMLSync(data)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user