docs: mark AUR package as pending registration reopen
This commit is contained in:
15
README.md
15
README.md
@@ -80,7 +80,8 @@ sudo ln -sf /opt/podtui/podtui /usr/local/bin/podtui
|
|||||||
### 3. Arch Linux (AUR)
|
### 3. Arch Linux (AUR)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yay -S podtui-bin
|
# Status: PKGBUILD ready, not yet on the AUR (see note below)
|
||||||
|
yay -S podtui-bin # once published
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
Requires an AUR helper ([paru](https://github.com/morgan/paru)). The AUR
|
||||||
@@ -88,6 +89,18 @@ 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`
|
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.
|
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
|
||||||
|
|
||||||
Requires [Bun](https://bun.sh) ≥ 1.2.
|
Requires [Bun](https://bun.sh) ≥ 1.2.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 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, 3, 3]` 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/7 : 3/7 : 3/7 of the row width via Yoga `flexGrow`, so every list
|
||||||
@@ -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,7 +41,7 @@ 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/7 slot is always preserved). */
|
||||||
parent?: PaneContent;
|
parent?: PaneContent;
|
||||||
@@ -95,7 +95,7 @@ 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;
|
||||||
@@ -149,7 +149,7 @@ function YaziPane(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 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. */
|
||||||
@@ -180,7 +180,7 @@ 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/7) — previous-depth list; always muted ─────────────── */}
|
||||||
<YaziPane
|
<Pane
|
||||||
grow={PANE_RATIO.parent}
|
grow={PANE_RATIO.parent}
|
||||||
label={parentLabel}
|
label={parentLabel}
|
||||||
content={parentContent}
|
content={parentContent}
|
||||||
@@ -188,7 +188,7 @@ 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}
|
||||||
@@ -197,7 +197,7 @@ export function YaziPaneRow(props: YaziPaneRowProps) {
|
|||||||
/>
|
/>
|
||||||
{/* ── preview (3/7) — hovered-item detail; always muted ────────────── */}
|
{/* ── preview (3/7) — 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}
|
||||||
@@ -25,7 +25,7 @@ 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",
|
||||||
@@ -252,7 +252,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>
|
||||||
|
|||||||
@@ -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> = {
|
||||||
@@ -67,8 +68,10 @@ 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"
|
||||||
|
|||||||
@@ -61,6 +61,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
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const shortcuts = [
|
|||||||
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
{ keys: "Esc", action: "Clear selection / exit visual / cancel" },
|
||||||
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
{ keys: ":", action: "Open command bar (:quit :refresh :play …)" },
|
||||||
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
{ keys: "r / s / f", action: "Refresh / search / filter" },
|
||||||
|
{ keys: "x", action: "Unsubscribe focused show (My Shows)" },
|
||||||
{ keys: ", / .", action: "Sort / toggle hidden" },
|
{ keys: ", / .", action: "Sort / toggle hidden" },
|
||||||
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
{ keys: "P / N / B", action: "Play-pause / next / prev episode" },
|
||||||
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
{ keys: "< / >", action: "Seek backward / forward 10s" },
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export type KeybindActionName =
|
|||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "unsubscribe"
|
||||||
| "audio-toggle"
|
| "audio-toggle"
|
||||||
| "audio-next"
|
| "audio-next"
|
||||||
| "audio-prev"
|
| "audio-prev"
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
387
src/index.tsx
387
src/index.tsx
@@ -1,225 +1,238 @@
|
|||||||
const VERSION = "0.2.0";
|
const VERSION = "0.2.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
const query = cliArgs.query;
|
||||||
const normalizedQuery = query.toLowerCase();
|
const normalizedQuery = query.toLowerCase();
|
||||||
|
|
||||||
const matches = feeds.filter((feed) => {
|
const matches = feeds.filter((feed) => {
|
||||||
const title = feed.podcast.title.toLowerCase();
|
const title = feed.podcast.title.toLowerCase();
|
||||||
return title.includes(normalizedQuery);
|
return title.includes(normalizedQuery);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
console.log(`No shows found matching: ${query}`);
|
console.log(`No shows found matching: ${query}`);
|
||||||
if (feeds.length > 0) {
|
if (feeds.length > 0) {
|
||||||
console.log("\nAvailable shows:");
|
console.log("\nAvailable shows:");
|
||||||
feeds.slice(0, 5).forEach((feed) => {
|
feeds.slice(0, 5).forEach((feed) => {
|
||||||
console.log(` - ${feed.podcast.title}`);
|
console.log(` - ${feed.podcast.title}`);
|
||||||
});
|
});
|
||||||
if (feeds.length > 5) {
|
if (feeds.length > 5) {
|
||||||
console.log(` ... and ${feeds.length - 5} more`);
|
console.log(` ... and ${feeds.length - 5} more`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length === 1) {
|
if (matches.length === 1) {
|
||||||
const feed = matches[0];
|
const feed = matches[0];
|
||||||
console.log(`\n${feed.podcast.title}`);
|
console.log(`\n${feed.podcast.title}`);
|
||||||
if (feed.podcast.description) {
|
if (feed.podcast.description) {
|
||||||
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
|
console.log(
|
||||||
}
|
feed.podcast.description.substring(0, 200) +
|
||||||
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
|
(feed.podcast.description.length > 200 ? "..." : ""),
|
||||||
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})`);
|
console.log(
|
||||||
});
|
`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`,
|
||||||
process.exit(0);
|
);
|
||||||
}
|
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}":`);
|
console.log(`\nClosest matches for "${query}":`);
|
||||||
matches.slice(0, 5).forEach((feed, idx) => {
|
matches.slice(0, 5).forEach((feed, idx) => {
|
||||||
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
console.log(` ${idx + 1}. ${feed.podcast.title}`);
|
||||||
});
|
});
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cliArgs.play !== null) {
|
if (cliArgs.play !== null) {
|
||||||
const playArg = cliArgs.play;
|
const playArg = cliArgs.play;
|
||||||
const normalizedArg = playArg.toLowerCase();
|
const normalizedArg = playArg.toLowerCase();
|
||||||
|
|
||||||
let feedResult: typeof feeds[0] | null = null;
|
let feedResult: (typeof feeds)[0] | null = null;
|
||||||
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
|
let episodeResult: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||||
|
|
||||||
if (normalizedArg === "latest") {
|
if (normalizedArg === "latest") {
|
||||||
let latestFeed: typeof feeds[0] | null = null;
|
let latestFeed: (typeof feeds)[0] | null = null;
|
||||||
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
|
let latestEpisode: (typeof feeds)[0]["episodes"][0] | null = null;
|
||||||
let latestDate = 0;
|
let latestDate = 0;
|
||||||
|
|
||||||
for (const feed of feeds) {
|
for (const feed of feeds) {
|
||||||
if (feed.episodes.length > 0) {
|
if (feed.episodes.length > 0) {
|
||||||
const ep = feed.episodes[0];
|
const ep = feed.episodes[0];
|
||||||
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
|
const epDate =
|
||||||
if (epDate > latestDate) {
|
ep.pubDate instanceof Date
|
||||||
latestDate = epDate;
|
? ep.pubDate.getTime()
|
||||||
latestFeed = feed;
|
: Number(ep.pubDate);
|
||||||
latestEpisode = ep;
|
if (epDate > latestDate) {
|
||||||
}
|
latestDate = epDate;
|
||||||
}
|
latestFeed = feed;
|
||||||
}
|
latestEpisode = ep;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
feedResult = latestFeed;
|
feedResult = latestFeed;
|
||||||
episodeResult = latestEpisode;
|
episodeResult = latestEpisode;
|
||||||
} else {
|
} else {
|
||||||
const parts = normalizedArg.split("/");
|
const parts = normalizedArg.split("/");
|
||||||
const showQuery = parts[0];
|
const showQuery = parts[0];
|
||||||
const episodeQuery = parts[1];
|
const episodeQuery = parts[1];
|
||||||
|
|
||||||
const matchingFeeds = feeds.filter((feed) =>
|
const matchingFeeds = feeds.filter((feed) =>
|
||||||
feed.podcast.title.toLowerCase().includes(showQuery)
|
feed.podcast.title.toLowerCase().includes(showQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingFeeds.length === 0) {
|
if (matchingFeeds.length === 0) {
|
||||||
console.log(`No show found matching: ${showQuery}`);
|
console.log(`No show found matching: ${showQuery}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = matchingFeeds[0];
|
const feed = matchingFeeds[0];
|
||||||
|
|
||||||
if (!episodeQuery) {
|
if (!episodeQuery) {
|
||||||
if (feed.episodes.length > 0) {
|
if (feed.episodes.length > 0) {
|
||||||
feedResult = feed;
|
feedResult = feed;
|
||||||
episodeResult = feed.episodes[0];
|
episodeResult = feed.episodes[0];
|
||||||
} else {
|
} else {
|
||||||
console.log(`No episodes available for: ${feed.podcast.title}`);
|
console.log(`No episodes available for: ${feed.podcast.title}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} else if (episodeQuery === "latest") {
|
} else if (episodeQuery === "latest") {
|
||||||
feedResult = feed;
|
feedResult = feed;
|
||||||
episodeResult = feed.episodes[0];
|
episodeResult = feed.episodes[0];
|
||||||
} else {
|
} else {
|
||||||
const matchingEpisode = feed.episodes.find((ep) =>
|
const matchingEpisode = feed.episodes.find((ep) =>
|
||||||
ep.title.toLowerCase().includes(episodeQuery)
|
ep.title.toLowerCase().includes(episodeQuery),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingEpisode) {
|
if (matchingEpisode) {
|
||||||
feedResult = feed;
|
feedResult = feed;
|
||||||
episodeResult = matchingEpisode;
|
episodeResult = matchingEpisode;
|
||||||
} else {
|
} else {
|
||||||
console.log(`Episode not found: ${episodeQuery}`);
|
console.log(`Episode not found: ${episodeQuery}`);
|
||||||
console.log(`Available episodes for ${feed.podcast.title}:`);
|
console.log(`Available episodes for ${feed.podcast.title}:`);
|
||||||
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
feed.episodes.slice(0, 5).forEach((ep, idx) => {
|
||||||
console.log(` ${idx + 1}. ${ep.title}`);
|
console.log(` ${idx + 1}. ${ep.title}`);
|
||||||
});
|
});
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!feedResult || !episodeResult) {
|
if (!feedResult || !episodeResult) {
|
||||||
console.log("Could not find episode to play");
|
console.log("Could not find episode to play");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`\nPlaying: ${episodeResult.title}`);
|
console.log(`\nPlaying: ${episodeResult.title}`);
|
||||||
console.log(`Show: ${feedResult.podcast.title}`);
|
console.log(`Show: ${feedResult.podcast.title}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { createAudioBackend } = await import("./utils/audio-player");
|
const { createAudioBackend } = await import("./utils/audio-player");
|
||||||
const backend = createAudioBackend();
|
const backend = createAudioBackend();
|
||||||
if (episodeResult.audioUrl) {
|
if (episodeResult.audioUrl) {
|
||||||
await backend.play(episodeResult.audioUrl);
|
await backend.play(episodeResult.audioUrl);
|
||||||
console.log("Playback started (use the UI to control)");
|
console.log("Playback started (use the UI to control)");
|
||||||
} else {
|
} else {
|
||||||
console.log("No audio URL available for this episode");
|
console.log("No audio URL available for this episode");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Playback error:", err);
|
console.error("Playback error:", err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
})
|
||||||
console.error("Error:", err);
|
.catch((err) => {
|
||||||
process.exit(1);
|
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 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* 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);
|
||||||
|
|
||||||
@@ -160,22 +160,27 @@ function DiscoverPage() {
|
|||||||
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 +193,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 +212,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 +230,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 +279,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 +289,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 +369,7 @@ function DiscoverPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -187,8 +188,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 +293,7 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* 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());
|
||||||
@@ -205,8 +216,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 +256,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 +298,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 +378,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 +425,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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -256,8 +257,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 +305,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}
|
||||||
@@ -419,7 +424,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}
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
* 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/7 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
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -64,6 +66,11 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
label: "Account",
|
label: "Account",
|
||||||
description: "Account login & OAuth (not yet implemented).",
|
description: "Account login & OAuth (not yet implemented).",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
label: "Downloads",
|
||||||
|
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. Section 4 (Account) has
|
||||||
@@ -78,6 +85,8 @@ function sectionItems(sectionId: number): SettingItem[] {
|
|||||||
return usePreferencesItems();
|
return usePreferencesItems();
|
||||||
case 3:
|
case 3:
|
||||||
return useVisualizerItems();
|
return useVisualizerItems();
|
||||||
|
case 5:
|
||||||
|
return useDownloadItems();
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -377,7 +386,7 @@ export function SettingsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={parentContent}
|
parent={parentContent}
|
||||||
current={currentContent}
|
current={currentContent}
|
||||||
preview={previewContent}
|
preview={previewContent}
|
||||||
@@ -422,8 +431,10 @@ function Row(props: {
|
|||||||
? theme.border
|
? theme.border
|
||||||
: undefined;
|
: undefined;
|
||||||
const fg = () => (props.focused && props.active ? theme.surface : theme.text);
|
const fg = () => (props.focused && props.active ? theme.surface : 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}
|
||||||
|
|||||||
@@ -6,355 +6,379 @@
|
|||||||
* 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"
|
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() {
|
export 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
|
// 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)
|
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
|
// 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
|
// 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
|
// 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
|
// Remove from queue
|
||||||
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
|
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId));
|
||||||
|
|
||||||
// Update status
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ 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. */
|
||||||
|
|||||||
@@ -63,6 +63,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"],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* YaziPaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
* PaneRow tests — the 1:3:3 parent|current|preview layout primitive.
|
||||||
*
|
*
|
||||||
* Verified through the opentui test renderer's captured frames (the same
|
* Verified through the opentui test renderer's captured frames (the same
|
||||||
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
* mechanism the `.harness` drive uses), since `flexGrow` ratios are only
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
import { describe, test, expect, afterAll } from "bun:test";
|
import { describe, test, expect, afterAll } from "bun:test";
|
||||||
import { testRender } from "@opentui/solid";
|
import { testRender } from "@opentui/solid";
|
||||||
import { ThemeProvider } from "../src/context/ThemeContext";
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
import { YaziPaneRow } from "../src/components/YaziPaneRow";
|
import { PaneRow } from "../src/components/PaneRow";
|
||||||
|
|
||||||
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
type Span = { text: string; fg: { buffer: ArrayLike<number> } | null };
|
||||||
type Frame = { lines: { spans: Span[] }[] };
|
type Frame = { lines: { spans: Span[] }[] };
|
||||||
@@ -88,7 +88,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
const setup = await testRender(
|
const setup = await testRender(
|
||||||
() => (
|
() => (
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<YaziPaneRow
|
<PaneRow
|
||||||
parent={props.parent as any}
|
parent={props.parent as any}
|
||||||
current={props.current as any}
|
current={props.current as any}
|
||||||
preview={props.preview as any}
|
preview={props.preview as any}
|
||||||
@@ -126,7 +126,7 @@ afterAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
// ── Unit: three columns at 1:3:3 regardless of null children ───────────────
|
||||||
describe("YaziPaneRow layout", () => {
|
describe("PaneRow layout", () => {
|
||||||
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
test("renders three columns at 1:3:3 even with null parent/preview", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
@@ -172,7 +172,7 @@ describe("YaziPaneRow layout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Integration: focused toggles the accent ring on the current column ─────
|
// ── Integration: focused toggles the accent ring on the current column ─────
|
||||||
describe("YaziPaneRow focus ring", () => {
|
describe("PaneRow focus ring", () => {
|
||||||
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
test("focused=true puts the accent border on current; parent/preview stay muted", async () => {
|
||||||
const { spans, destroy } = await renderPaneRow({
|
const { spans, destroy } = await renderPaneRow({
|
||||||
parent: null,
|
parent: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user