Compare commits
33 Commits
v0.4.0
...
20d5b57cb6
| Author | SHA1 | Date | |
|---|---|---|---|
| 20d5b57cb6 | |||
| d7aec4e810 | |||
| 77531ce41d | |||
| 26729fa5e6 | |||
| 4127fd1181 | |||
| 6c99b96b12 | |||
| acbaf2ed1c | |||
| e7ed89056e | |||
| b5432e3e5f | |||
| 13664c3cec | |||
| 0b4a551744 | |||
| ed75c2fff7 | |||
| bd7d988741 | |||
| deac6081ca | |||
| e09ae15e32 | |||
| 33af131b77 | |||
| 3d6d4918bc | |||
| 35ad858d0d | |||
| af827a9a96 | |||
| 2cf9559b0b | |||
| 20336ea716 | |||
| 8b7b38276e | |||
| 8496922aaf | |||
| 5e3ad48a2d | |||
| 005ac8fde3 | |||
| 1f0b9de456 | |||
| 3388757185 | |||
| 8049d02457 | |||
| 1b55b7117c | |||
| 15f8a098b5 | |||
| 2d7d49b91c | |||
| df9c519439 | |||
| 2bf1c229c7 |
@@ -1,11 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
parser: "@typescript-eslint/parser",
|
|
||||||
plugins: ["@typescript-eslint"],
|
|
||||||
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
|
|
||||||
env: {
|
|
||||||
es2022: true,
|
|
||||||
node: true,
|
|
||||||
},
|
|
||||||
ignorePatterns: ["dist", "node_modules"],
|
|
||||||
}
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,3 +35,5 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
.harness/
|
.harness/
|
||||||
.ralpi
|
.ralpi
|
||||||
notes.md
|
notes.md
|
||||||
|
# pygienium run-state and check artifacts
|
||||||
|
.pygienium/
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
|
||||||
|
|
||||||
### Linting
|
### Linting
|
||||||
- `bun run lint` - Run ESLint with TypeScript rules
|
- `bun run lint` - Run the TypeScript typecheck (`bun tsc --noEmit`)
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,6 @@ Linux (arm64/x64). Pick whichever fits your platform.
|
|||||||
brew install mikefreno/tap/podtui
|
brew install mikefreno/tap/podtui
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS the tarball also ships a `PodTui.app` bundle. PodTui plays audio
|
|
||||||
through a copy of mpv that lives **inside the bundle**, so macOS attributes
|
|
||||||
the Now Playing session to PodTui — the Control Center / lock-screen entry
|
|
||||||
shows the PodTui name and icon, and podcast cover art as its artwork —
|
|
||||||
rather than a blank placeholder for an unbundled binary. Installers can drop
|
|
||||||
`PodTui.app` into `/Applications`; the `podtui` entry point should point at
|
|
||||||
`PodTui.app/Contents/MacOS/podtui` so the bundled mpv is used.
|
|
||||||
|
|
||||||
### 2. Standalone tarball (all platforms)
|
### 2. Standalone tarball (all platforms)
|
||||||
|
|
||||||
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
Grab `podtui-<platform>-<arch>.tar.gz` from the latest
|
||||||
|
|||||||
Binary file not shown.
111
build.ts
111
build.ts
@@ -131,117 +131,6 @@ if (COMPILE) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS app bundle: PodTui.app. We run our audio backend (mpv) from
|
|
||||||
// INSIDE the bundle (Contents/MacOS/mpv) so macOS attributes its Now
|
|
||||||
// Playing session to PodTui — the source-app icon + name in Control
|
|
||||||
// Center / lock screen — instead of a blank placeholder for an
|
|
||||||
// unbundled binary. AudioPlayer's resolver prefers this sibling.
|
|
||||||
if (platform === "darwin") {
|
|
||||||
const appRoot = join(tarRoot, "PodTui.app");
|
|
||||||
const macosDir = join(appRoot, "Contents", "MacOS");
|
|
||||||
const resDir = join(appRoot, "Contents", "Resources");
|
|
||||||
mkdirSync(macosDir, { recursive: true });
|
|
||||||
mkdirSync(resDir, { recursive: true });
|
|
||||||
|
|
||||||
copyFileSync(outfile, join(macosDir, "podtui"));
|
|
||||||
for (const lib of [`libopentui.${libExt}`, cavacoreLib]) {
|
|
||||||
const s = join("dist", lib);
|
|
||||||
if (existsSync(s)) copyFileSync(s, join(macosDir, lib));
|
|
||||||
}
|
|
||||||
|
|
||||||
const mpvResolve = Bun.spawnSync(["which", "mpv"]);
|
|
||||||
const mpvPath =
|
|
||||||
mpvResolve.exitCode === 0 ? mpvResolve.stdout.toString().trim() : "";
|
|
||||||
if (mpvPath) {
|
|
||||||
copyFileSync(mpvPath, join(macosDir, "mpv"));
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Warning: mpv not found in PATH — skipping bundle mpv (Now Playing attribution won't work)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const icnsSrc = join("assets", "App Icon", "AppIcon.icns");
|
|
||||||
if (existsSync(icnsSrc)) {
|
|
||||||
copyFileSync(icnsSrc, join(resDir, "AppIcon.icns"));
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Warning: assets/App Icon/AppIcon.icns missing — app bundle has no icon",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep CFBundleShortVersionString in sync with src/index.tsx VERSION.
|
|
||||||
Bun.write(
|
|
||||||
join(appRoot, "Contents", "Info.plist"),
|
|
||||||
`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>PodTui</string>
|
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>PodTui</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.mikefreno.podtui</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>podtui</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>AppIcon</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>0.3.1</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>0.3.1</string>
|
|
||||||
<key>LSMinimumSystemVersion</key>
|
|
||||||
<string>12.0</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Ad-hoc sign so the bundle launches cleanly on fresh machines.
|
|
||||||
// Identity overridable via PODTUI_CODESIGN_IDENTITY (e.g. a Developer
|
|
||||||
// ID cert for release builds); default ad-hoc.
|
|
||||||
const signIdentity = process.env.PODTUI_CODESIGN_IDENTITY || "-";
|
|
||||||
const sign = Bun.spawnSync([
|
|
||||||
"codesign",
|
|
||||||
"--force",
|
|
||||||
"--deep",
|
|
||||||
"-s",
|
|
||||||
signIdentity,
|
|
||||||
appRoot,
|
|
||||||
]);
|
|
||||||
if (sign.exitCode !== 0) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: codesign failed (${sign.stderr.toString().trim()}) — app bundle unsigned`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign the nested mpv LAST with our bundle identifier. mediaremoted
|
|
||||||
// resolves the Now Playing client from the registering process's
|
|
||||||
// code-signing identifier — without an explicit --identifier codesign
|
|
||||||
// stamps "mpv" (its basename) and the audio center shows a blank
|
|
||||||
// placeholder. Must run after the bundle sign above (a later bundle
|
|
||||||
// re-seal would re-derive the basename identifier).
|
|
||||||
const signMpv = Bun.spawnSync([
|
|
||||||
"codesign",
|
|
||||||
"--force",
|
|
||||||
"-s",
|
|
||||||
signIdentity,
|
|
||||||
"--identifier",
|
|
||||||
"com.mikefreno.podtui",
|
|
||||||
join(macosDir, "mpv"),
|
|
||||||
]);
|
|
||||||
if (signMpv.exitCode !== 0) {
|
|
||||||
console.warn(
|
|
||||||
`Warning: nested mpv signing failed (${signMpv.stderr
|
|
||||||
.toString()
|
|
||||||
.trim()}) — Now Playing attribution won't work`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
console.log(`App bundle: ${appRoot}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tar = Bun.spawnSync([
|
const tar = Bun.spawnSync([
|
||||||
"tar",
|
"tar",
|
||||||
"-czf",
|
"-czf",
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -74,6 +74,77 @@ const parseEpisodeType = (raw: string): EpisodeType | undefined => {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extract the `<item>` blocks from an RSS document. Matches items directly
|
||||||
|
* on the full XML string — scoping to <channel> first is a redundant 5MB
|
||||||
|
* regex pass that doubles parse cost with no practical benefit (well-formed
|
||||||
|
* RSS has no items outside <channel>). */
|
||||||
|
export const getRSSItems = (xml: string): string[] => {
|
||||||
|
return xml.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel-level artwork: `<itunes:image href>` (podcasts) or RSS 2.0
|
||||||
|
* `<image><url>`. Works on the full XML — channel-level tags precede
|
||||||
|
* <item> blocks in RSS, so the first match is the channel image. */
|
||||||
|
export const parseChannelCoverUrl = (xml: string): string | undefined => {
|
||||||
|
const itunesHref = getAttr(xml, "itunes:image", "href")
|
||||||
|
if (itunesHref) return itunesHref
|
||||||
|
const url = getTagValue(xml, "image").match(/<url>([\s\S]*?)<\/url>/i)?.[1]
|
||||||
|
return url?.trim() || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single `<item>` into an Episode. Exported so the feed store can
|
||||||
|
* parse large feeds in bounded chunks (yielding to the event loop between
|
||||||
|
* chunks) instead of one synchronous block. */
|
||||||
|
export const parseRSSItem = (item: string, feedUrl: string, index: number): Episode => {
|
||||||
|
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
||||||
|
const epDescription = cleanField(getTagValue(item, "description"))
|
||||||
|
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
||||||
|
|
||||||
|
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
||||||
|
const audioUrl = enclosure?.[1] ?? ""
|
||||||
|
const fileSizeStr = getAttr(item, "enclosure", "length")
|
||||||
|
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
||||||
|
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
||||||
|
|
||||||
|
const durationRaw = getTagValue(item, "itunes:duration")
|
||||||
|
const duration = parseDuration(durationRaw)
|
||||||
|
|
||||||
|
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
||||||
|
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
||||||
|
const seasonNumRaw = getTagValue(item, "itunes:season")
|
||||||
|
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
||||||
|
|
||||||
|
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
||||||
|
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
||||||
|
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
||||||
|
|
||||||
|
// Episode image (itunes:image has href attribute)
|
||||||
|
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
||||||
|
|
||||||
|
const ep: Episode = {
|
||||||
|
id: `${feedUrl}#${index}`,
|
||||||
|
podcastId: feedUrl,
|
||||||
|
title: epTitle,
|
||||||
|
description: epDescription,
|
||||||
|
audioUrl,
|
||||||
|
duration,
|
||||||
|
pubDate,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
||||||
|
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
||||||
|
if (episodeType) ep.episodeType = episodeType
|
||||||
|
if (explicit !== undefined) ep.explicit = explicit
|
||||||
|
if (imageUrl) ep.imageUrl = imageUrl
|
||||||
|
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
||||||
|
if (mimeType) ep.mimeType = mimeType
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a full RSS document (channel metadata + all episodes). The sync
|
||||||
|
* whole-feed variant — callers that parse potentially huge feeds on a UI
|
||||||
|
* thread should prefer the store's chunked incremental parse instead. */
|
||||||
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
|
||||||
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
|
||||||
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
|
||||||
@@ -81,58 +152,8 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
const author = decodeEntities(getTagValue(channel, "itunes:author"))
|
||||||
const lastUpdated = new Date()
|
const lastUpdated = new Date()
|
||||||
|
|
||||||
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
|
const items = getRSSItems(xml)
|
||||||
const episodes = items.map((item, index) => {
|
const episodes = items.map((item, index) => parseRSSItem(item, feedUrl, index))
|
||||||
const epTitle = cleanField(getTagValue(item, "title")) || `Episode ${index + 1}`
|
|
||||||
const epDescription = cleanField(getTagValue(item, "description"))
|
|
||||||
const pubDate = new Date(getTagValue(item, "pubDate") || Date.now())
|
|
||||||
|
|
||||||
// Audio URL + file size + MIME type from <enclosure>
|
|
||||||
const enclosure = item.match(/<enclosure[^>]*url=["']([^"']+)["'][^>]*>/i)
|
|
||||||
const audioUrl = enclosure?.[1] ?? ""
|
|
||||||
const fileSizeStr = getAttr(item, "enclosure", "length")
|
|
||||||
const fileSize = fileSizeStr ? parseInt(fileSizeStr, 10) : undefined
|
|
||||||
const mimeType = getAttr(item, "enclosure", "type") || undefined
|
|
||||||
|
|
||||||
// Duration from <itunes:duration>
|
|
||||||
const durationRaw = getTagValue(item, "itunes:duration")
|
|
||||||
const duration = parseDuration(durationRaw)
|
|
||||||
|
|
||||||
// Episode & season numbers
|
|
||||||
const episodeNumRaw = getTagValue(item, "itunes:episode")
|
|
||||||
const episodeNumber = episodeNumRaw ? parseInt(episodeNumRaw, 10) : undefined
|
|
||||||
const seasonNumRaw = getTagValue(item, "itunes:season")
|
|
||||||
const seasonNumber = seasonNumRaw ? parseInt(seasonNumRaw, 10) : undefined
|
|
||||||
|
|
||||||
// Episode type & explicit
|
|
||||||
const episodeType = parseEpisodeType(getTagValue(item, "itunes:episodeType"))
|
|
||||||
const explicitRaw = getTagValue(item, "itunes:explicit").toLowerCase()
|
|
||||||
const explicit = explicitRaw === "yes" || explicitRaw === "true" ? true : undefined
|
|
||||||
|
|
||||||
// Episode image (itunes:image has href attribute)
|
|
||||||
const imageUrl = getAttr(item, "itunes:image", "href") || undefined
|
|
||||||
|
|
||||||
const ep: Episode = {
|
|
||||||
id: `${feedUrl}#${index}`,
|
|
||||||
podcastId: feedUrl,
|
|
||||||
title: epTitle,
|
|
||||||
description: epDescription,
|
|
||||||
audioUrl,
|
|
||||||
duration,
|
|
||||||
pubDate,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only set optional fields if present
|
|
||||||
if (episodeNumber !== undefined && !isNaN(episodeNumber)) ep.episodeNumber = episodeNumber
|
|
||||||
if (seasonNumber !== undefined && !isNaN(seasonNumber)) ep.seasonNumber = seasonNumber
|
|
||||||
if (episodeType) ep.episodeType = episodeType
|
|
||||||
if (explicit !== undefined) ep.explicit = explicit
|
|
||||||
if (imageUrl) ep.imageUrl = imageUrl
|
|
||||||
if (fileSize !== undefined && !isNaN(fileSize) && fileSize > 0) ep.fileSize = fileSize
|
|
||||||
if (mimeType) ep.mimeType = mimeType
|
|
||||||
|
|
||||||
return ep
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: feedUrl,
|
id: feedUrl,
|
||||||
@@ -142,6 +163,7 @@ export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes
|
|||||||
feedUrl,
|
feedUrl,
|
||||||
lastUpdated,
|
lastUpdated,
|
||||||
isSubscribed: true,
|
isSubscribed: true,
|
||||||
|
coverUrl: parseChannelCoverUrl(channel),
|
||||||
episodes,
|
episodes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
src/components/EpisodeList.tsx
Normal file
246
src/components/EpisodeList.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Shared list-row and preview components for the Feed and My Shows pages.
|
||||||
|
*
|
||||||
|
* Both pages render the same episode rows (marker + title, optional subtitle
|
||||||
|
* line, date/duration/selection/download meta line), "[Fetch More]" rows, and
|
||||||
|
* hovered-episode / fetch-more preview panes; the pages differ only in the
|
||||||
|
* props they pass (subtitle line, hint text, manual-mode wording). Extracted
|
||||||
|
* so the previously 3-4-level-nested render blocks run as flat named
|
||||||
|
* components.
|
||||||
|
*
|
||||||
|
* Anything that can change at runtime arrives as a signal getter: Solid
|
||||||
|
* components do not re-render, so only props that are called inside the
|
||||||
|
* component's own JSX stay reactive (focus, selection, download state).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Show } from "solid-js";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
|
import { NF_ICONS } from "@/utils/nerd-fonts";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import type { Episode } from "@/types/episode";
|
||||||
|
|
||||||
|
// ── formatting helpers ──────────────────────────────────────────────────────
|
||||||
|
export const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
export const formatDuration = (s: number) => {
|
||||||
|
const mins = Math.floor(s / 60);
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── EpisodeRow ──────────────────────────────────────────────────────────────
|
||||||
|
export function EpisodeRow(props: {
|
||||||
|
/** The episode this row renders. */
|
||||||
|
episode: Episode;
|
||||||
|
/** Optional second line under the title (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
/** For index signal (row position). */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index in this list (-1 while the Fetch More row is
|
||||||
|
* focused, so no episode row draws the cursor). */
|
||||||
|
focused: () => number;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
/** Whether this episode is selection-marked. */
|
||||||
|
selected: () => boolean;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.episode.episodeNumber ? `#${props.episode.episodeNumber} ` : ""}
|
||||||
|
{props.episode.title}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
{/* podcast name on its own row — readable at a glance; the 50%
|
||||||
|
current pane fits it in full for typical names, and truncate
|
||||||
|
keeps the row one line tall either way */}
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.subtitle?.()}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : theme.info}>
|
||||||
|
{formatDate(props.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<text flexShrink={0} fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
{formatDuration(props.episode.duration)}
|
||||||
|
</text>
|
||||||
|
<Show when={props.selected()}>
|
||||||
|
<text flexShrink={0} fg={theme.warning}>
|
||||||
|
●
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMoreRow ────────────────────────────────────────────────────────────
|
||||||
|
export function FetchMoreRow(props: {
|
||||||
|
/** Row index of the Fetch More button within the list. */
|
||||||
|
index: () => number;
|
||||||
|
/** Focused row index. */
|
||||||
|
focused: () => number;
|
||||||
|
/** True while the Fetch More row itself is focused. */
|
||||||
|
onMore: () => boolean;
|
||||||
|
/** Whether the current pane has keyboard focus. */
|
||||||
|
active: () => boolean;
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
nerd: boolean;
|
||||||
|
marker: () => string;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(props.onMore);
|
||||||
|
const bg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
props.index() === props.focused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: props.index() === props.focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{props.onMore() ? props.marker() : " "}</text>
|
||||||
|
{props.nerd && (
|
||||||
|
<text fg={fg()}>{NF_ICONS.more}</text>
|
||||||
|
)}
|
||||||
|
<Show
|
||||||
|
when={!props.isLoadingMore()}
|
||||||
|
fallback={<LoadingIndicator label="Fetching…" />}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>[Fetch More]</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EpisodePreview ──────────────────────────────────────────────────────────
|
||||||
|
export function EpisodePreview(props: {
|
||||||
|
episode: () => Episode;
|
||||||
|
/** Optional line under the meta row (podcast/show name). */
|
||||||
|
subtitle?: () => string | undefined;
|
||||||
|
author: () => string | undefined;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
/** Page-specific action-hint line. */
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>
|
||||||
|
{props.episode().episodeNumber ? `#${props.episode().episodeNumber} ` : ""}
|
||||||
|
{props.episode().title}
|
||||||
|
</strong>
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<text fg={theme.info}>{formatDate(props.episode().pubDate)}</text>
|
||||||
|
<text fg={muted()}>{formatDuration(props.episode().duration)}</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>{props.downloadLabel()}</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={props.subtitle?.()}>
|
||||||
|
<text fg={muted()}>{props.subtitle?.()}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.author()}>
|
||||||
|
<text fg={muted()}>by {props.author()}</text>
|
||||||
|
</Show>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
{props.episode().description?.slice(0, 400) ?? "No description available."}
|
||||||
|
{(props.episode().description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── FetchMorePreview ────────────────────────────────────────────────────────
|
||||||
|
export function FetchMorePreview(props: {
|
||||||
|
isLoadingMore: () => boolean;
|
||||||
|
fetchMoreMode: () => string;
|
||||||
|
/** Manual-mode explanation line ("across all feeds" vs "for this show"). */
|
||||||
|
manualText: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>[Fetch More]</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{props.isLoadingMore()
|
||||||
|
? "Loading the next batch of episodes…"
|
||||||
|
: props.fetchMoreMode() === "auto"
|
||||||
|
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
||||||
|
: props.manualText()}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: load more · h back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/components/GlobalActivityIndicator.tsx
Normal file
35
src/components/GlobalActivityIndicator.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Show } from "solid-js";
|
||||||
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useSearchStore } from "@/stores/search";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useActivityStore } from "@/stores/activity";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GlobalActivityIndicator — one global top-right signal that ANY feed
|
||||||
|
* refresh, fetch-more, subscribe fetch, search, or download is in flight.
|
||||||
|
* Per-page spinners are unchanged; this overlays the content row and status
|
||||||
|
* bar as a single app-wide "something is happening" indicator.
|
||||||
|
*/
|
||||||
|
export function GlobalActivityIndicator() {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const searchStore = useSearchStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
/** True while any tracked activity is in flight */
|
||||||
|
const isActive = () =>
|
||||||
|
feedStore.isLoadingFeeds() ||
|
||||||
|
feedStore.isLoadingMore() ||
|
||||||
|
searchStore.isSearching() ||
|
||||||
|
downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 ||
|
||||||
|
activity.isActive();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isActive()}>
|
||||||
|
<box position="absolute" top={0} right={0} paddingRight={1}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,9 @@ export type PaneRowProps = {
|
|||||||
/** Number of visible columns. `3` (default) = parent|current|preview;
|
/** Number of visible columns. `3` (default) = parent|current|preview;
|
||||||
* `2` = parent|current (preview omitted, current grows to fill). */
|
* `2` = parent|current (preview omitted, current grows to fill). */
|
||||||
panes?: 2 | 3;
|
panes?: 2 | 3;
|
||||||
|
/** Which sides of the current column's border render. Defaults to
|
||||||
|
* `["left", "right"]` (the standard focused-list frame). */
|
||||||
|
currentBorder?: boolean | BorderSides[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
@@ -181,6 +184,9 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
? PANE_RATIO.current + PANE_RATIO.preview
|
? PANE_RATIO.current + PANE_RATIO.preview
|
||||||
: PANE_RATIO.current,
|
: PANE_RATIO.current,
|
||||||
);
|
);
|
||||||
|
const currentBorder = createMemo<boolean | BorderSides[]>(
|
||||||
|
() => props.currentBorder ?? ["left", "right"],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
<box flexDirection="row" flexGrow={1} width="100%" height="100%">
|
||||||
@@ -197,7 +203,7 @@ export function PaneRow(props: PaneRowProps) {
|
|||||||
grow={currentGrow()}
|
grow={currentGrow()}
|
||||||
label={() => ""}
|
label={() => ""}
|
||||||
content={currentContent}
|
content={currentContent}
|
||||||
border={["left", "right"]}
|
border={currentBorder()}
|
||||||
scrollFocused={() => focused()}
|
scrollFocused={() => focused()}
|
||||||
/>
|
/>
|
||||||
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
{/* ── preview (30%) — hovered-item detail; no border, no header ────── */}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { TABS } from "@/utils/navigation";
|
|||||||
import { createDispatcher } from "@/utils/dispatch";
|
import { createDispatcher } from "@/utils/dispatch";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
|
import { GlobalActivityIndicator } from "@/components/GlobalActivityIndicator";
|
||||||
|
|
||||||
export function Shell() {
|
export function Shell() {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -168,7 +169,6 @@ export function Shell() {
|
|||||||
nav.backspaceCommand();
|
nav.backspaceCommand();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// printable char
|
|
||||||
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
nav.appendCommand(evt.name);
|
nav.appendCommand(evt.name);
|
||||||
@@ -396,6 +396,8 @@ export function Shell() {
|
|||||||
theme={t as any}
|
theme={t as any}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
{/* ── Global activity indicator (top-right overlay) ─────────────────────── */}
|
||||||
|
<GlobalActivityIndicator />
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
"sort": [","],
|
"sort": [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
"refresh": ["r"],
|
"refresh": ["r"],
|
||||||
|
"subscribe": ["a"], // subscribe focused show/episode result in place (Search)
|
||||||
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
"unsubscribe": ["x"], // unsubscribe focused show in My Shows
|
||||||
|
|
||||||
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
// ── Downloads & auto-download whitelist ───────────────────────────────────
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export type KeybindActionName =
|
|||||||
| "sort"
|
| "sort"
|
||||||
| "toggle-hidden"
|
| "toggle-hidden"
|
||||||
| "refresh"
|
| "refresh"
|
||||||
|
| "subscribe"
|
||||||
| "unsubscribe"
|
| "unsubscribe"
|
||||||
| "download"
|
| "download"
|
||||||
| "delete-download"
|
| "delete-download"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
generateSyntax,
|
generateSyntax,
|
||||||
generateSubtleSyntax,
|
generateSubtleSyntax,
|
||||||
} from "../utils/syntax-highlighter";
|
} from "../utils/syntax-highlighter";
|
||||||
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
|
import { resolveTerminalTheme } from "../utils/theme";
|
||||||
|
import { getCustomThemes } from "../utils/custom-themes";
|
||||||
import { detectModeFromBackground } from "../utils/system-theme";
|
import { detectModeFromBackground } from "../utils/system-theme";
|
||||||
import { createSimpleContext } from "./helper";
|
import { createSimpleContext } from "./helper";
|
||||||
import {
|
import {
|
||||||
@@ -175,7 +176,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
resolveSystemTheme();
|
resolveSystemTheme();
|
||||||
loadThemes()
|
getCustomThemes()
|
||||||
.then((custom) => {
|
.then((custom) => {
|
||||||
setStore(
|
setStore(
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
@@ -187,7 +188,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
|||||||
setStore("active", "catppuccin");
|
setStore("active", "catppuccin");
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// Only set ready if not waiting for system theme
|
|
||||||
if (store.active !== "system") {
|
if (store.active !== "system") {
|
||||||
setStore("ready", true);
|
setStore("ready", true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
import { unlinkSync } from "fs";
|
import {
|
||||||
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
cachedCoverPath,
|
||||||
|
fetchCoverArt,
|
||||||
|
} from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
@@ -22,13 +24,39 @@ import {
|
|||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
} from "../utils/audio-player";
|
} from "../utils/audio-player";
|
||||||
|
import {
|
||||||
|
isPlaying,
|
||||||
|
setIsPlaying,
|
||||||
|
position,
|
||||||
|
setPosition,
|
||||||
|
duration,
|
||||||
|
setDuration,
|
||||||
|
volume,
|
||||||
|
setVolume,
|
||||||
|
speed,
|
||||||
|
setSpeed,
|
||||||
|
backendName,
|
||||||
|
setBackendName,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
currentEpisode,
|
||||||
|
setCurrentEpisode,
|
||||||
|
availablePlayers,
|
||||||
|
setAvailablePlayers,
|
||||||
|
} from "../utils/audio-signals";
|
||||||
import { emit, on } from "../utils/event-bus";
|
import { emit, on } from "../utils/event-bus";
|
||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
import { useProgressStore } from "../stores/progress";
|
||||||
import { useMediaRegistry } from "../utils/media-registry";
|
import { useMediaRegistry } from "../utils/media-registry";
|
||||||
import type { Episode } from "../types/episode";
|
import {
|
||||||
|
loadLastPlayerFromFile,
|
||||||
|
saveLastPlayerToFile,
|
||||||
|
saveLastPlayerSync,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
|
import type { Episode, Progress } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "../stores/audio-nav";
|
||||||
|
import { useDownloadStore } from "../stores/download";
|
||||||
import { useFeedStore } from "../stores/feed";
|
import { useFeedStore } from "../stores/feed";
|
||||||
|
|
||||||
export interface AudioControls {
|
export interface AudioControls {
|
||||||
@@ -45,6 +73,8 @@ export interface AudioControls {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
play: (episode: Episode) => Promise<void>;
|
play: (episode: Episode) => Promise<void>;
|
||||||
|
/** Load an episode into the player WITHOUT starting playback. */
|
||||||
|
load: (episode: Episode) => Promise<void>;
|
||||||
pause: () => Promise<void>;
|
pause: () => Promise<void>;
|
||||||
resume: () => Promise<void>;
|
resume: () => Promise<void>;
|
||||||
togglePlayback: () => Promise<void>;
|
togglePlayback: () => Promise<void>;
|
||||||
@@ -64,17 +94,26 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|||||||
let refCount = 0;
|
let refCount = 0;
|
||||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||||
const [position, setPosition] = createSignal(0);
|
// so non-component consumers (the visualizer store) can subscribe without
|
||||||
const [duration, setDuration] = createSignal(0);
|
// mounting a useAudio() owner.
|
||||||
const [volume, setVolume] = createSignal(0.7);
|
|
||||||
const [speed, setSpeed] = createSignal(1);
|
/** True once the current episode has been handed to the backend (play
|
||||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
* restored at boot) and the first play action must start the backend
|
||||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
* instead of unpausing it. */
|
||||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
let startedPlayback = false;
|
||||||
[],
|
|
||||||
);
|
/** Completion fraction at/above which an episode is NOT restored at boot. */
|
||||||
|
const RESTORE_COMPLETION_THRESHOLD = 0.98;
|
||||||
|
|
||||||
|
/** True when saved progress is below the restore cutoff. Episodes with no
|
||||||
|
* progress (never reached the persist threshold) or unknown duration count
|
||||||
|
* as eligible — they restore from the start. */
|
||||||
|
function isRestoreEligible(progress: Progress | undefined): boolean {
|
||||||
|
if (!progress || progress.duration <= 0) return true;
|
||||||
|
return progress.position / progress.duration < RESTORE_COMPLETION_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureBackend(): AudioBackend {
|
function ensureBackend(): AudioBackend {
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
@@ -101,6 +140,17 @@ function registerExitTeardown(): void {
|
|||||||
exitTeardownRegistered = true;
|
exitTeardownRegistered = true;
|
||||||
const teardown = (): void => {
|
const teardown = (): void => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
// Persist "what's loaded in the player right now" synchronously —
|
||||||
|
// process.exit(0) runs this handler synchronously and an async write
|
||||||
|
// would never land. The next launch restores this episode paused.
|
||||||
|
try {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
saveLastPlayerSync({ episodeId: ep.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* best-effort at exit */
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
backend?.dispose();
|
backend?.dispose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -111,11 +161,6 @@ function registerExitTeardown(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
unlinkSync(coverTempPath());
|
|
||||||
} catch {
|
|
||||||
/* best-effort at exit */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
process.on("exit", teardown);
|
process.on("exit", teardown);
|
||||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
@@ -126,6 +171,52 @@ function registerExitTeardown(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Poll ticks between paused-state checks (~1s at 150ms/tick). While the
|
||||||
|
* UI believes playback is paused we only need to catch an external
|
||||||
|
* resume (AirPod play tap, lock-screen/media-center play); checking every
|
||||||
|
* tick would just hammer mpv IPC for nothing. */
|
||||||
|
const PAUSE_WATCH_TICKS = 7;
|
||||||
|
|
||||||
|
/** The player process died while we believed playback was live — track
|
||||||
|
* ended (mpv quits at EOF) or the process crashed. Persist the final
|
||||||
|
* position and stop polling. */
|
||||||
|
function finalizeTrackEnd(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
stopPolling();
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mpv paused itself OUTSIDE PodTUI — system sleep/lock, AirPod removal,
|
||||||
|
* device swap, OS media keys, the Now Playing center. Bring the UI in
|
||||||
|
* sync; the poll stays armed so an external resume is caught too. */
|
||||||
|
function reconcileExternalPause(): void {
|
||||||
|
setIsPlaying(false);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, position(), duration(), speed());
|
||||||
|
emit("player.pause", { episodeId: ep.id });
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
media.setPosition(position());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Playback was restarted from outside PodTUI (AirPods, lock-screen or
|
||||||
|
* media-center play, OS media keys). Bring the UI back to "playing". */
|
||||||
|
function reconcileExternalResume(): void {
|
||||||
|
setIsPlaying(true);
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
emit("player.play", { episodeId: ep.id });
|
||||||
|
useMediaRegistry().setPlaybackState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling(): void {
|
function startPolling(): void {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollCount = 0;
|
pollCount = 0;
|
||||||
@@ -133,36 +224,57 @@ function startPolling(): void {
|
|||||||
// interval (getPosition opens a fresh mpv IPC connection per call).
|
// interval (getPosition opens a fresh mpv IPC connection per call).
|
||||||
let pollInFlight = false;
|
let pollInFlight = false;
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
if (!backend || !isPlaying() || pollInFlight) return;
|
if (!backend || pollInFlight) return;
|
||||||
pollInFlight = true;
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const pos = await backend.getPosition();
|
|
||||||
const dur = await backend.getDuration();
|
|
||||||
setPosition(pos);
|
|
||||||
if (dur > 0) setDuration(dur);
|
|
||||||
|
|
||||||
// Save progress every ~5 seconds (33 ticks * 150ms)
|
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount % 33 === 0) {
|
if (isPlaying()) {
|
||||||
const ep = currentEpisode();
|
// Track ended (eof-reached observed) or process died. Check
|
||||||
if (ep) {
|
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||||
const progressStore = useProgressStore();
|
// and reports pause=true there, which would otherwise be
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
// mistaken for an external pause and never finalize.
|
||||||
|
if (!backend.isPlaying()) {
|
||||||
const media = useMediaRegistry();
|
finalizeTrackEnd();
|
||||||
media.setPosition(pos);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if backend stopped playing (track ended)
|
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||||
if (!backend.isPlaying() && isPlaying()) {
|
// staying stuck on "playing" with a frozen waveform
|
||||||
setIsPlaying(false);
|
// (getPosition would just re-read the same frozen time-pos).
|
||||||
stopPolling();
|
const paused = await backend.getPauseState();
|
||||||
// Save final position on track end
|
if (paused === true) {
|
||||||
const ep = currentEpisode();
|
reconcileExternalPause();
|
||||||
if (ep) {
|
return;
|
||||||
const progressStore = useProgressStore();
|
}
|
||||||
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
|
||||||
|
const pos = await backend.getPosition();
|
||||||
|
const dur = await backend.getDuration();
|
||||||
|
setPosition(pos);
|
||||||
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
|
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||||
|
if (pollCount % 33 === 0) {
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) {
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed());
|
||||||
|
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setPosition(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||||
|
// Paused — watch for playback restarted from outside (AirPods,
|
||||||
|
// lock-screen/media-center play). Only while the player is
|
||||||
|
// still alive: a dead player while we thought we were paused
|
||||||
|
// means the track ended (mpv quits at EOF) or it crashed.
|
||||||
|
if (!backend.isAlive()) {
|
||||||
|
finalizeTrackEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paused = await backend.getPauseState();
|
||||||
|
if (paused === false) {
|
||||||
|
reconcileExternalResume();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -185,6 +297,34 @@ function stopPolling(): void {
|
|||||||
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
// from `--cover-art-files`. Shared helper (utils/cover-art.ts) fetches the
|
||||||
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
// podcast cover to a temp file BEFORE playback starts, bounded to 3s.
|
||||||
|
|
||||||
|
/** Resolve cover art to a local path for mpv's --cover-art-files, per the
|
||||||
|
* call site's latency budget:
|
||||||
|
* "cache" — disk cache only (sync): resume paths must never wait on the
|
||||||
|
* network, so a miss plays artless and warms for next time.
|
||||||
|
* "bounded" — disk hit, else fetch capped at 1.2s: cold play needs the art
|
||||||
|
* at file LOAD, but a slow cover server must not stall audio.
|
||||||
|
* "await" — disk hit, else full (8s-bounded) fetch: boot restore preloads
|
||||||
|
* while feeds/progress load anyway, so the wait is free and the
|
||||||
|
* cover must be present when the file loads.
|
||||||
|
* fetchCoverArt already short-circuits on the disk cache, so "await" costs
|
||||||
|
* nothing on a warm cache. */
|
||||||
|
async function resolveCoverArt(
|
||||||
|
coverUrl: string | undefined,
|
||||||
|
mode: "cache" | "bounded" | "await",
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!coverUrl) return null;
|
||||||
|
if (mode === "cache") return cachedCoverPath(coverUrl);
|
||||||
|
if (mode === "bounded") {
|
||||||
|
const cached = cachedCoverPath(coverUrl);
|
||||||
|
if (cached) return cached;
|
||||||
|
return Promise.race([
|
||||||
|
fetchCoverArt(coverUrl),
|
||||||
|
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1200)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return fetchCoverArt(coverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
async function play(episode: Episode): Promise<void> {
|
async function play(episode: Episode): Promise<void> {
|
||||||
const b = ensureBackend();
|
const b = ensureBackend();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -204,9 +344,20 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const coverArtPath = feed?.podcast.coverUrl
|
// Play the downloaded file when present (offline + no network stalls);
|
||||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
// otherwise stream. Cover resolves to the feed art, falling back to the
|
||||||
: null;
|
// episode's own image (feeds added by URL may lack a channel cover).
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
// Cover art only applies at file LOAD (the runtime video-add fallback
|
||||||
|
// never becomes an albumart track), so a cold-cache play must wait for
|
||||||
|
// the fetch or play artless. Serve the disk cache synchronously; on a
|
||||||
|
// miss, await the bounded fetch (covers fetch in ~300ms typically) —
|
||||||
|
// past the 1.2s cap, play bare and let the fetch warm the cache.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"bounded",
|
||||||
|
);
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
@@ -215,11 +366,11 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
startPos = savedProgress.position;
|
startPos = savedProgress.position;
|
||||||
}
|
}
|
||||||
|
|
||||||
await b.play(episode.audioUrl, {
|
await b.play(url, {
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
startPosition: startPos > 0 ? startPos : undefined,
|
startPosition: startPos > 0 ? startPos : undefined,
|
||||||
mediaTitle: podcastTitle ? `${podcastTitle} — ${episode.title}` : episode.title,
|
mediaTitle: episode.title,
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,6 +379,11 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
setSpeed(spd);
|
setSpeed(spd);
|
||||||
if (episode.duration) setDuration(episode.duration);
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
startedPlayback = true;
|
||||||
|
|
||||||
|
// Remember this episode as "loaded in the player" so the next launch
|
||||||
|
// can restore it paused (cleared by stop()).
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
|
||||||
// Register with platform media controls
|
// Register with platform media controls
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
@@ -250,12 +406,85 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load an episode into the player WITHOUT starting playback. The player tab
|
||||||
|
* renders it paused at its saved position; the first play action starts the
|
||||||
|
* backend from there (see togglePlayback). Used to restore the last player
|
||||||
|
* session at boot.
|
||||||
|
*/
|
||||||
|
async function load(episode: Episode): Promise<void> {
|
||||||
|
ensureBackend();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
setCurrentEpisode(episode);
|
||||||
|
setIsPlaying(false);
|
||||||
|
startedPlayback = false;
|
||||||
|
|
||||||
|
// Show the saved position so the player tab reflects where playback
|
||||||
|
// will resume; episodes at/above the completion threshold start from 0.
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
const saved = progressStore.get(episode.id);
|
||||||
|
const pos = saved && isRestoreEligible(saved) ? saved.position : 0;
|
||||||
|
setPosition(pos);
|
||||||
|
if (episode.duration) setDuration(episode.duration);
|
||||||
|
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
|
setSpeed(storeSpeed || speed());
|
||||||
|
|
||||||
|
// Surface the loaded-but-paused track to the OS media controls.
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
|
const media = useMediaRegistry();
|
||||||
|
media.setNowPlaying({
|
||||||
|
title: episode.title,
|
||||||
|
artist: podcastTitle || episode.podcastId,
|
||||||
|
duration: episode.duration,
|
||||||
|
});
|
||||||
|
media.setPlaybackState(false);
|
||||||
|
if (pos > 0) media.setPosition(pos);
|
||||||
|
|
||||||
|
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||||
|
// fills its demuxer cache while parked, so the user's first Play flips
|
||||||
|
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||||
|
// — a failed preload just makes the first play take the cold path.
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const url = downloadStore.getDownloadedFilePath(episode.id) ?? episode.audioUrl;
|
||||||
|
if (episode.audioUrl && backend) {
|
||||||
|
// The preload must carry the cover AT LOAD: cover-art-files only
|
||||||
|
// applies when the file loads, and the runtime video-add fallback
|
||||||
|
// never becomes an albumart track (verified). Restore already waits
|
||||||
|
// on feeds/progress at boot, so the bounded fetch (~300ms typical,
|
||||||
|
// 8s worst case) is free. Falls back to the episode's own image when
|
||||||
|
// the feed has no channel cover.
|
||||||
|
const coverArtPath = await resolveCoverArt(
|
||||||
|
feed?.podcast.coverUrl ?? episode.imageUrl,
|
||||||
|
"await",
|
||||||
|
);
|
||||||
|
const backendSnap = backend;
|
||||||
|
backendSnap
|
||||||
|
.preload(url, {
|
||||||
|
volume: volume(),
|
||||||
|
speed: storeSpeed || speed(),
|
||||||
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
|
mediaTitle: episode.title,
|
||||||
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
async function pause(): Promise<void> {
|
async function pause(): Promise<void> {
|
||||||
if (!backend) return;
|
if (!backend) return;
|
||||||
try {
|
try {
|
||||||
await backend.pause();
|
await backend.pause();
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
stopPolling();
|
// Polling stays armed (paused-watch mode): playback can be resumed
|
||||||
|
// from OUTSIDE PodTUI — AirPods, lock-screen/media-center play —
|
||||||
|
// and the poll must be live to catch it.
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
// Save progress on pause
|
// Save progress on pause
|
||||||
@@ -294,7 +523,15 @@ async function togglePlayback(): Promise<void> {
|
|||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
await pause();
|
await pause();
|
||||||
} else if (currentEpisode()) {
|
} else if (currentEpisode()) {
|
||||||
await resume();
|
if (startedPlayback) {
|
||||||
|
await resume();
|
||||||
|
} else {
|
||||||
|
// Episode is only LOADED (e.g. restored at boot) — the backend
|
||||||
|
// was never started, so unpausing a dead player would fail
|
||||||
|
// silently. Start playback from the saved position instead.
|
||||||
|
const ep = currentEpisode();
|
||||||
|
if (ep) await play(ep);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,9 +548,13 @@ async function stop(): Promise<void> {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setPosition(0);
|
setPosition(0);
|
||||||
setCurrentEpisode(null);
|
setCurrentEpisode(null);
|
||||||
|
startedPlayback = false;
|
||||||
stopPolling();
|
stopPolling();
|
||||||
emit("player.stop", {});
|
emit("player.stop", {});
|
||||||
|
|
||||||
|
// Player is empty again — nothing to restore on the next launch.
|
||||||
|
saveLastPlayerToFile({ episodeId: null, timestamp: null });
|
||||||
|
|
||||||
const media = useMediaRegistry();
|
const media = useMediaRegistry();
|
||||||
media.clearNowPlaying();
|
media.clearNowPlaying();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -346,6 +587,10 @@ async function doSetVolume(vol: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVolume(clamped);
|
setVolume(clamped);
|
||||||
|
|
||||||
|
// Sync back to app store (persisted to config.json for the next launch).
|
||||||
|
const appStore = useAppStore();
|
||||||
|
appStore.updateSettings({ volume: clamped });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doSetSpeed(spd: number): Promise<void> {
|
async function doSetSpeed(spd: number): Promise<void> {
|
||||||
@@ -389,19 +634,21 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
.feeds()
|
.feeds()
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const coverArtPath = feed?.podcast.coverUrl
|
const url =
|
||||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
useDownloadStore().getDownloadedFilePath(ep.id) ?? ep.audioUrl;
|
||||||
: null;
|
const coverArtPath = await resolveCoverArt(
|
||||||
await backend.play(ep.audioUrl, {
|
feed?.podcast.coverUrl ?? ep.imageUrl,
|
||||||
|
"cache",
|
||||||
|
);
|
||||||
|
await backend.play(url, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
speed: spd,
|
speed: spd,
|
||||||
mediaTitle: podcastTitle
|
mediaTitle: ep.title,
|
||||||
? `${podcastTitle} — ${ep.title}`
|
|
||||||
: ep.title,
|
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
|
startedPlayback = true;
|
||||||
startPolling();
|
startPolling();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Backend switch failed");
|
setError(err instanceof Error ? err.message : "Backend switch failed");
|
||||||
@@ -410,6 +657,46 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serialized restore chain: the boot-triggered restore and any explicit
|
||||||
|
* call run one after another, so a late-finishing earlier restore can never
|
||||||
|
* overwrite state changed by a later one (and callers can await the latest
|
||||||
|
* attempt deterministically). */
|
||||||
|
let restoreChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time session restore: reload the episode that was loaded in the
|
||||||
|
* player when the previous run ended (persisted on play/load and at exit),
|
||||||
|
* paused at its saved position — never autostarted. Episodes at/above the
|
||||||
|
* completion threshold are skipped. Silently no-ops when there is nothing
|
||||||
|
* to restore (empty player, unsubscribed show, or completed episode).
|
||||||
|
*/
|
||||||
|
export async function restoreLastSession(): Promise<void> {
|
||||||
|
const attempt = restoreChain.then(async () => {
|
||||||
|
const marker = await loadLastPlayerFromFile();
|
||||||
|
if (!marker?.episodeId) return;
|
||||||
|
|
||||||
|
// Feeds and progress load asynchronously at boot; wait for both
|
||||||
|
// before looking the episode up.
|
||||||
|
await Promise.all([
|
||||||
|
useProgressStore().whenReady(),
|
||||||
|
useFeedStore().whenReady(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const episode = useFeedStore().findEpisode(marker.episodeId);
|
||||||
|
if (!episode) return;
|
||||||
|
|
||||||
|
// Only restore episodes below the completion threshold.
|
||||||
|
const saved = useProgressStore().get(episode.id);
|
||||||
|
if (!isRestoreEligible(saved)) return;
|
||||||
|
|
||||||
|
await load(episode);
|
||||||
|
});
|
||||||
|
// Keep the chain alive even when an attempt fails; the caller awaiting
|
||||||
|
// this attempt still observes its own outcome.
|
||||||
|
restoreChain = attempt.catch(() => {});
|
||||||
|
await attempt;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive audio controls hook.
|
* Reactive audio controls hook.
|
||||||
*
|
*
|
||||||
@@ -420,13 +707,29 @@ export function useAudio(): AudioControls {
|
|||||||
// Initialize backend on first use
|
// Initialize backend on first use
|
||||||
ensureBackend();
|
ensureBackend();
|
||||||
|
|
||||||
// Sync initial speed from app store
|
// Sync initial speed/volume from app store (reuse the previous session's
|
||||||
|
// playback levels; defaults are 1x and 100%).
|
||||||
if (refCount === 0) {
|
if (refCount === 0) {
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const storeSpeed = appStore.state().settings.playbackSpeed;
|
const storeSpeed = appStore.state().settings.playbackSpeed;
|
||||||
if (storeSpeed && storeSpeed !== speed()) {
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
setSpeed(storeSpeed);
|
setSpeed(storeSpeed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Volume re-syncs once settings finish loading (async config read)
|
||||||
|
// so a level persisted last session is applied at boot.
|
||||||
|
appStore
|
||||||
|
.whenReady()
|
||||||
|
.then(() => {
|
||||||
|
const storeVolume = appStore.state().settings.volume;
|
||||||
|
if (storeVolume !== undefined && storeVolume !== volume()) {
|
||||||
|
setVolume(storeVolume);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
// Restore the last player session once at boot (loaded, not playing).
|
||||||
|
restoreLastSession().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
refCount++;
|
refCount++;
|
||||||
@@ -574,6 +877,7 @@ export function useAudio(): AudioControls {
|
|||||||
availablePlayers,
|
availablePlayers,
|
||||||
|
|
||||||
play,
|
play,
|
||||||
|
load,
|
||||||
pause,
|
pause,
|
||||||
resume,
|
resume,
|
||||||
togglePlayback,
|
togglePlayback,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "s":
|
case "s":
|
||||||
|
// Speed is shift+s (S) so plain `s` stays free for search.
|
||||||
|
if (!key.shift) return;
|
||||||
emit("media.speedCycle", {});
|
emit("media.speedCycle", {});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Feed } from "./types/feed"
|
import type { Feed } from "./types/feed"
|
||||||
import type { Episode } from "./types/episode"
|
import type { Episode } from "./types/episode"
|
||||||
|
|
||||||
const VERSION = "0.4.0";
|
const VERSION = "0.6.2";
|
||||||
|
|
||||||
interface CliArgs {
|
interface CliArgs {
|
||||||
version: boolean;
|
version: boolean;
|
||||||
@@ -42,7 +42,6 @@ if (cliArgs.version) {
|
|||||||
|
|
||||||
// ── CLI handlers ──────────────────────────────────────────────────────
|
// ── CLI handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Find the most recent episode across all feeds */
|
|
||||||
function findLatestEpisode(
|
function findLatestEpisode(
|
||||||
feeds: Feed[],
|
feeds: Feed[],
|
||||||
): { feed: Feed; episode: Episode } | null {
|
): { feed: Feed; episode: Episode } | null {
|
||||||
@@ -186,12 +185,16 @@ async function handlePlay(feeds: Feed[], arg: string): Promise<void> {
|
|||||||
const backend = createAudioBackend()
|
const backend = createAudioBackend()
|
||||||
if (episodeResult.audioUrl) {
|
if (episodeResult.audioUrl) {
|
||||||
// Stage the podcast cover so the system Now Playing shows
|
// Stage the podcast cover so the system Now Playing shows
|
||||||
// artwork (mpv --cover-art-files), like the UI path does.
|
// artwork (mpv --cover-art-files), like the UI path does. Falls
|
||||||
const coverArtPath = feedResult.podcast.coverUrl
|
// back to the episode's own image when the feed has no channel
|
||||||
? await fetchCoverArt(feedResult.podcast.coverUrl)
|
// cover (URL-added feeds).
|
||||||
|
const coverUrl =
|
||||||
|
feedResult.podcast.coverUrl ?? episodeResult.imageUrl;
|
||||||
|
const coverArtPath = coverUrl
|
||||||
|
? await fetchCoverArt(coverUrl)
|
||||||
: null
|
: null
|
||||||
await backend.play(episodeResult.audioUrl, {
|
await backend.play(episodeResult.audioUrl, {
|
||||||
mediaTitle: `${feedResult.podcast.title} — ${episodeResult.title}`,
|
mediaTitle: episodeResult.title,
|
||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
})
|
})
|
||||||
console.log("Playback started (use the UI to control)")
|
console.log("Playback started (use the UI to control)")
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-j
|
|||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { prefetchCoverArt } from "@/utils/cover-art";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -32,14 +32,19 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
export const FeedPaneCount = 1;
|
export const FeedPaneCount = 1;
|
||||||
@@ -63,14 +68,29 @@ function FeedPage() {
|
|||||||
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
() => feedStore.getAllEpisodesChronological() as EpItem[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Cover warm-up ────────────────────────────────────────────────────────
|
||||||
|
// Prefetch covers for episodes around the focus (plus the top of the
|
||||||
|
// list) so plays land on a warm cache: cover-art-files only applies at
|
||||||
|
// file load, and there is no working runtime fallback. Single-flight +
|
||||||
|
// cache short-circuit keep repeat runs cheap (hits resolve immediately).
|
||||||
|
createEffect(() => {
|
||||||
|
const list = episodes();
|
||||||
|
const focusIdx = focusedEpIdx();
|
||||||
|
const start = Math.max(0, focusIdx - 10);
|
||||||
|
const end = Math.min(list.length, focusIdx + 11);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const item = list[i];
|
||||||
|
if (item?.feed.podcast.coverUrl) prefetchCoverArt(item.feed.podcast.coverUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Fetch More ───────────────────────────────────────────────────────────
|
// ── Fetch More ───────────────────────────────────────────────────────────
|
||||||
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
// A "[Fetch More]" row at the bottom of the list advances every feed's
|
||||||
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
// loaded window by 50 episodes. manual mode: Enter on the row. auto mode:
|
||||||
// reaching the bottom row fetches automatically (see the effect below).
|
// reaching the bottom row fetches automatically (see the effect below).
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
const showFetchMore = () => feedStore.hasMoreAcrossAll();
|
||||||
// Total navigable rows: episodes + the optional Fetch More row.
|
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focus = () => nav.depthFocus(0);
|
const focus = () => nav.depthFocus(0);
|
||||||
const focusedRow = () =>
|
const focusedRow = () =>
|
||||||
@@ -86,7 +106,6 @@ function FeedPage() {
|
|||||||
const focusedItem = (): EpItem | undefined =>
|
const focusedItem = (): EpItem | undefined =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const curLen = () => rowCount();
|
const curLen = () => rowCount();
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (rowCount() > 0 && focus() >= rowCount())
|
if (rowCount() > 0 && focus() >= rowCount())
|
||||||
@@ -112,12 +131,6 @@ function FeedPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ────────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -212,19 +225,6 @@ function FeedPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
// Row highlight within the list. `active=true` only for the current pane.
|
|
||||||
const focusBg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active
|
|
||||||
? theme.primary
|
|
||||||
: i === listFocus
|
|
||||||
? theme.border
|
|
||||||
: undefined;
|
|
||||||
const focusFg = (i: number, listFocus: number, active: boolean) =>
|
|
||||||
i === listFocus && active
|
|
||||||
? theme.surface
|
|
||||||
: i === listFocus
|
|
||||||
? theme.selectedListItemText ?? theme.text
|
|
||||||
: theme.text;
|
|
||||||
|
|
||||||
const currentLabel = () => `Feed · ${episodes().length}`;
|
const currentLabel = () => `Feed · ${episodes().length}`;
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ function FeedPage() {
|
|||||||
<Show
|
<Show
|
||||||
when={episodes().length > 0}
|
when={episodes().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1} alignItems="center">
|
||||||
<Show
|
<Show
|
||||||
when={feedStore.isLoadingFeeds()}
|
when={feedStore.isLoadingFeeds()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -251,111 +251,41 @@ function FeedPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(item, index) => {
|
{(item, index) => (
|
||||||
const fi = () => focusedEpIdx();
|
<EpisodeRow
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
episode={item.episode}
|
||||||
return (
|
subtitle={() => item.feed.customName || item.feed.podcast.title}
|
||||||
<box
|
index={index}
|
||||||
ref={ref}
|
focused={focusedEpIdx}
|
||||||
flexDirection="column"
|
active={isActive}
|
||||||
gap={0}
|
selected={() => nav.isSelected(item.episode.id)}
|
||||||
paddingRight={1}
|
downloadLabel={() => downloadLabel(item.episode.id)}
|
||||||
backgroundColor={focusBg(index(), fi(), isActive())}
|
downloadColor={() => downloadColor(item.episode.id)}
|
||||||
onMouseDown={() => {
|
marker={marker}
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
onMouseDown={() => {
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
}}
|
nav.setDepthFocus(index(), 0);
|
||||||
>
|
}}
|
||||||
<box flexDirection="row" gap={1}>
|
/>
|
||||||
<text
|
)}
|
||||||
flexShrink={0}
|
|
||||||
fg={focusFg(index(), fi(), isActive())}
|
|
||||||
>
|
|
||||||
{index() === fi() ? marker() : " "}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
fg={focusFg(index(), fi(), isActive())}
|
|
||||||
>
|
|
||||||
{item.episode.episodeNumber
|
|
||||||
? `#${item.episode.episodeNumber} `
|
|
||||||
: ""}
|
|
||||||
{item.episode.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
{/* podcast name on its own row — readable at a glance; the
|
|
||||||
50% current pane fits it in full for typical names, and
|
|
||||||
truncate keeps the row one line tall either way */}
|
|
||||||
<box paddingLeft={2}>
|
|
||||||
<text
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
fg={index() === fi() ? theme.surface : theme.textSecondary}
|
|
||||||
>
|
|
||||||
{item.feed.customName || item.feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
fg={index() === fi() ? theme.surface : theme.info}
|
|
||||||
>
|
|
||||||
{formatDate(item.episode.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
fg={index() === fi() ? theme.surface : muted()}
|
|
||||||
>
|
|
||||||
{formatDuration(item.episode.duration)}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(item.episode.id)}>
|
|
||||||
<text flexShrink={0} fg={theme.warning}>
|
|
||||||
●
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(item.episode.id)}>
|
|
||||||
<text flexShrink={0} fg={downloadColor(item.episode.id)}>
|
|
||||||
{downloadLabel(item.episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box
|
<FetchMoreRow
|
||||||
ref={moreRef}
|
index={() => episodes().length}
|
||||||
flexDirection="row"
|
focused={focusedRow}
|
||||||
gap={1}
|
onMore={focusedOnMore}
|
||||||
paddingRight={1}
|
active={isActive}
|
||||||
backgroundColor={focusBg(episodes().length, focusedRow(), isActive())}
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
|
nerd={nerd}
|
||||||
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(episodes().length, 0);
|
nav.setDepthFocus(episodes().length, 0);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
{focusedOnMore() ? marker() : " "}
|
|
||||||
</text>
|
|
||||||
{nerd && (
|
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
{NF_ICONS.more}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
<Show
|
|
||||||
when={!feedStore.isLoadingMore()}
|
|
||||||
fallback={<LoadingIndicator label="Fetching…" />}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
[Fetch More]
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={feedStore.isLoadingFeeds()}>
|
<Show when={feedStore.isLoadingFeeds()}>
|
||||||
<box paddingLeft={2} paddingTop={1}>
|
<box alignItems="center" paddingTop={1}>
|
||||||
<LoadingIndicator label="Refreshing…" />
|
<LoadingIndicator label="Refreshing…" />
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -363,23 +293,23 @@ function FeedPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
// ── preview pane: hovered-episode detail (or the Fetch More row) ──────────
|
||||||
|
const episodeHint = (item: EpItem) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(item.episode.id) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
} · space: select · h back`;
|
||||||
|
|
||||||
const previewContent = () => (
|
const previewContent = () => (
|
||||||
<>
|
<>
|
||||||
<Show when={focusedOnMore()}>
|
<Show when={focusedOnMore()}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<FetchMorePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<strong>[Fetch More]</strong>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</text>
|
manualText={() =>
|
||||||
<text fg={muted()}>
|
"Load the next batch of older episodes across all feeds (Enter)."
|
||||||
{feedStore.isLoadingMore()
|
}
|
||||||
? "Loading the next batch of episodes…"
|
/>
|
||||||
: fetchMoreMode() === "auto"
|
|
||||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
|
||||||
: "Load the next batch of older episodes across all feeds (Enter)."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: load more · h back</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!focusedOnMore()}>
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -391,46 +321,16 @@ function FeedPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<EpisodePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
episode={() => item().episode}
|
||||||
<strong>
|
subtitle={() =>
|
||||||
{item().episode.episodeNumber
|
item().feed.customName || item().feed.podcast.title
|
||||||
? `#${item().episode.episodeNumber} `
|
}
|
||||||
: ""}
|
author={() => item().feed.podcast.author}
|
||||||
{item().episode.title}
|
downloadLabel={() => downloadLabel(item().episode.id)}
|
||||||
</strong>
|
downloadColor={() => downloadColor(item().episode.id)}
|
||||||
</text>
|
hint={() => episodeHint(item())}
|
||||||
<box flexDirection="row" gap={2}>
|
/>
|
||||||
<text fg={theme.info}>{formatDate(item().episode.pubDate)}</text>
|
|
||||||
<text fg={muted()}>{formatDuration(item().episode.duration)}</text>
|
|
||||||
<Show when={downloadLabel(item().episode.id)}>
|
|
||||||
<text fg={downloadColor(item().episode.id)}>
|
|
||||||
{downloadLabel(item().episode.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{item().feed.customName || item().feed.podcast.title}
|
|
||||||
</text>
|
|
||||||
<Show when={item().feed.podcast.author}>
|
|
||||||
<text fg={muted()}>by {item().feed.podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{item().episode.description?.slice(0, 400) ??
|
|
||||||
"No description available."}
|
|
||||||
{(item().episode.description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter: play · d: download
|
|
||||||
{downloadStore.getDownloadStatus(item().episode.id) !==
|
|
||||||
DownloadStatus.NONE
|
|
||||||
? " · D: delete"
|
|
||||||
: ""}{" "}
|
|
||||||
· space: select · h back
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
import { createMemo, createEffect, For, Show, onMount, onCleanup } from "solid-js";
|
||||||
|
import type { RGBA } from "@opentui/core";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
import { useDownloadStore } from "@/stores/download";
|
import { useDownloadStore } from "@/stores/download";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { DownloadStatus } from "@/types/episode";
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
import {
|
import {
|
||||||
@@ -31,16 +31,208 @@ import {
|
|||||||
} from "@/context/NavigationContext";
|
} from "@/context/NavigationContext";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { on, off } from "@/utils/event-bus";
|
import { on, off } from "@/utils/event-bus";
|
||||||
import { NF_ICONS, supportsNerdFonts } from "@/utils/nerd-fonts";
|
import { supportsNerdFonts } from "@/utils/nerd-fonts";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import type { Episode } from "@/types/episode";
|
import type { Episode, DownloadedEpisode } from "@/types/episode";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
import {
|
||||||
|
EpisodeRow,
|
||||||
|
FetchMoreRow,
|
||||||
|
EpisodePreview,
|
||||||
|
FetchMorePreview,
|
||||||
|
formatDate,
|
||||||
|
} from "@/components/EpisodeList";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
import { TabListPane } from "@/components/TabPanel";
|
import { TabListPane } from "@/components/TabPanel";
|
||||||
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
import { useScrollIntoView } from "@/hooks/useScrollIntoView";
|
||||||
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
import { useSelectionMarker } from "@/hooks/useSelectionMarker";
|
||||||
|
|
||||||
|
// ── render components ────────────────────────────────────────────────────────
|
||||||
|
// Depth-0 rows (subscribed shows, unsubscribed-show downloads) and their
|
||||||
|
// preview panes are My Shows-specific; episode rows/previews are shared with
|
||||||
|
// the Feed page (see EpisodeList.tsx).
|
||||||
|
|
||||||
|
/** A subscribed-show row (depth 0). */
|
||||||
|
function ShowRow(props: {
|
||||||
|
feed: Feed;
|
||||||
|
title: string;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
wlScope: () => boolean;
|
||||||
|
wlInList: () => boolean;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="row"
|
||||||
|
gap={1}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<text fg={fg()}>{isFocused() ? props.marker() : " "}</text>
|
||||||
|
<text fg={fg()}>{props.title}</text>
|
||||||
|
<text fg={isFocused() ? theme.surface : muted()}>
|
||||||
|
({props.feed.episodes.length})
|
||||||
|
</text>
|
||||||
|
<Show when={props.wlScope()}>
|
||||||
|
<text
|
||||||
|
fg={
|
||||||
|
isFocused()
|
||||||
|
? theme.surface
|
||||||
|
: props.wlInList()
|
||||||
|
? theme.warning
|
||||||
|
: muted()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.wlInList() ? "●" : "○"}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unsubscribed-show download row (depth 0, below the shows list). */
|
||||||
|
function UnsubscribedRow(props: {
|
||||||
|
d: DownloadedEpisode;
|
||||||
|
index: () => number;
|
||||||
|
focused: () => number;
|
||||||
|
active: () => boolean;
|
||||||
|
marker: () => string;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
onMouseDown: () => void;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const ref = useScrollIntoView(() => props.index() === props.focused());
|
||||||
|
const isFocused = () => props.index() === props.focused();
|
||||||
|
const bg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.primary
|
||||||
|
: isFocused()
|
||||||
|
? theme.border
|
||||||
|
: undefined;
|
||||||
|
const fg = () =>
|
||||||
|
isFocused() && props.active()
|
||||||
|
? theme.surface
|
||||||
|
: isFocused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
ref={ref}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={0}
|
||||||
|
paddingRight={1}
|
||||||
|
backgroundColor={bg()}
|
||||||
|
onMouseDown={props.onMouseDown}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text flexShrink={0} fg={fg()}>
|
||||||
|
{isFocused() ? props.marker() : " "}
|
||||||
|
</text>
|
||||||
|
<text wrapMode="none" truncate fg={fg()}>
|
||||||
|
{props.d.episodeTitle ?? props.d.episodeId}
|
||||||
|
</text>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text flexShrink={0} fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<box paddingLeft={2}>
|
||||||
|
<text
|
||||||
|
wrapMode="none"
|
||||||
|
truncate
|
||||||
|
fg={isFocused() ? theme.surface : theme.textSecondary}
|
||||||
|
>
|
||||||
|
{props.d.podcastTitle ?? props.d.feedId}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered subscribed show. */
|
||||||
|
function ShowPreview(props: {
|
||||||
|
show: () => Feed;
|
||||||
|
title: () => string;
|
||||||
|
hint: () => string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const show = props.show;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{props.title()}</strong>
|
||||||
|
</text>
|
||||||
|
<Show when={show().podcast.author}>
|
||||||
|
<text fg={muted()}>by {show().podcast.author}</text>
|
||||||
|
</Show>
|
||||||
|
<text fg={theme.textSecondary}>{show().episodes.length} episodes</text>
|
||||||
|
<text fg={muted()}>
|
||||||
|
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>{props.hint()}</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth-0 preview: the hovered unsubscribed-show download. */
|
||||||
|
function UnsubscribedPreview(props: {
|
||||||
|
d: () => DownloadedEpisode;
|
||||||
|
downloadLabel: () => string;
|
||||||
|
downloadColor: () => RGBA;
|
||||||
|
}) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const muted = () => theme.muted || theme.text;
|
||||||
|
const d = props.d;
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1} padding={1}>
|
||||||
|
<text fg={theme.textPrimary ?? theme.text}>
|
||||||
|
<strong>{d().episodeTitle ?? d().episodeId}</strong>
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textSecondary}>{d().podcastTitle ?? d().feedId}</text>
|
||||||
|
<box flexDirection="row" gap={2}>
|
||||||
|
<Show when={d().pubDate}>
|
||||||
|
<text fg={theme.info}>{formatDate(new Date(d().pubDate!))}</text>
|
||||||
|
</Show>
|
||||||
|
<Show when={props.downloadLabel()}>
|
||||||
|
<text fg={props.downloadColor()}>
|
||||||
|
{props.downloadLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<text fg={muted()}>
|
||||||
|
Downloaded from episode search — the show is not subscribed.
|
||||||
|
</text>
|
||||||
|
<box height={1} />
|
||||||
|
<text fg={muted()}>enter: play · D: delete download · h: back</text>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const MyShowsPaneCount = 1;
|
export const MyShowsPaneCount = 1;
|
||||||
|
|
||||||
export function MyShowsPage() {
|
export function MyShowsPage() {
|
||||||
@@ -62,9 +254,27 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
const shows = () => feedStore.getFilteredFeeds();
|
const shows = () => feedStore.getFilteredFeeds();
|
||||||
|
|
||||||
|
// Downloads of shows that are NOT subscribed (made from episode search) —
|
||||||
|
// listed as their own section under the shows list. Reads feeds() so an
|
||||||
|
// entry drops out the moment the user subscribes to its show.
|
||||||
|
const unsubs = () => downloadStore.getUnsubscribedDownloads();
|
||||||
|
|
||||||
|
const depth0Count = () => shows().length + unsubs().length;
|
||||||
|
|
||||||
const focusedShowIdx = () =>
|
const focusedShowIdx = () =>
|
||||||
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
shows().length === 0 ? 0 : Math.min(focus(0), shows().length - 1);
|
||||||
const selectedShow = (): Feed | undefined => shows()[focusedShowIdx()];
|
/** True when the depth-0 cursor sits on an unsubscribed-show download
|
||||||
|
* row (past the shows list). */
|
||||||
|
const focusedOnUnsub = () =>
|
||||||
|
depth() === 0 && focus(0) >= shows().length && unsubs().length > 0;
|
||||||
|
const focusedUnsub = (): DownloadedEpisode | undefined => {
|
||||||
|
if (!focusedOnUnsub()) return undefined;
|
||||||
|
return unsubs()[Math.min(focus(0) - shows().length, unsubs().length - 1)];
|
||||||
|
};
|
||||||
|
const selectedShow = (): Feed | undefined => {
|
||||||
|
if (focusedOnUnsub()) return undefined;
|
||||||
|
return shows()[focusedShowIdx()];
|
||||||
|
};
|
||||||
|
|
||||||
// depth-1 frame ctx = the drilled feed id
|
// depth-1 frame ctx = the drilled feed id
|
||||||
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
const drilledShowId = (): string => stack()[1]?.ctx ?? "";
|
||||||
@@ -83,12 +293,11 @@ export function MyShowsPage() {
|
|||||||
// counterpart to the Feed page's row (which loads every feed). manual
|
// counterpart to the Feed page's row (which loads every feed). manual
|
||||||
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
// mode: Enter on the row. auto mode: reaching the bottom row fetches
|
||||||
// automatically (see the effect below).
|
// automatically (see the effect below).
|
||||||
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "manual";
|
const fetchMoreMode = () => app.state().preferences.fetchMoreMode ?? "auto";
|
||||||
const showFetchMore = () =>
|
const showFetchMore = () =>
|
||||||
depth() >= 1 &&
|
depth() >= 1 &&
|
||||||
!!drilledShowId() &&
|
!!drilledShowId() &&
|
||||||
feedStore.hasMoreEpisodes(drilledShowId());
|
feedStore.hasMoreEpisodes(drilledShowId());
|
||||||
// Total navigable rows at depth 1: episodes + the optional Fetch More row.
|
|
||||||
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
const rowCount = () => episodes().length + (showFetchMore() ? 1 : 0);
|
||||||
const focusedRow = () =>
|
const focusedRow = () =>
|
||||||
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
rowCount() === 0 ? 0 : Math.min(focus(1), rowCount() - 1);
|
||||||
@@ -102,13 +311,12 @@ export function MyShowsPage() {
|
|||||||
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
: Math.min(focusedRow(), Math.max(episodes().length - 1, 0));
|
||||||
const focusedEpisode = () =>
|
const focusedEpisode = () =>
|
||||||
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
focusedOnMore() ? undefined : episodes()[focusedEpIdx()];
|
||||||
const moreRef = useScrollIntoView(() => focusedOnMore());
|
|
||||||
|
|
||||||
const curLen = () => (depth() === 0 ? shows().length : rowCount());
|
const curLen = () => (depth() === 0 ? depth0Count() : rowCount());
|
||||||
|
|
||||||
const ensureFocus = () => {
|
const ensureFocus = () => {
|
||||||
if (shows().length > 0 && focus(0) >= shows().length)
|
if (depth() === 0 && depth0Count() > 0 && focus(0) >= depth0Count())
|
||||||
nav.setDepthFocus(shows().length - 1, 0);
|
nav.setDepthFocus(depth0Count() - 1, 0);
|
||||||
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
if (depth() >= 1 && rowCount() > 0 && focus(1) >= rowCount())
|
||||||
nav.setDepthFocus(rowCount() - 1, 1);
|
nav.setDepthFocus(rowCount() - 1, 1);
|
||||||
};
|
};
|
||||||
@@ -116,7 +324,10 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
nav.registerResolver(`${nav.activeTab()}:${DEPTH_CENTER_PANE}`, (i) => {
|
||||||
if (depth() === 0) return shows()[i]?.id;
|
if (depth() === 0) {
|
||||||
|
if (i < shows().length) return shows()[i]?.id;
|
||||||
|
return unsubs()[i - shows().length]?.episodeId;
|
||||||
|
}
|
||||||
return episodes()[i]?.id;
|
return episodes()[i]?.id;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -133,12 +344,6 @@ export function MyShowsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
|
||||||
const formatDuration = (s: number) => {
|
|
||||||
const mins = Math.floor(s / 60);
|
|
||||||
const hrs = Math.floor(mins / 60);
|
|
||||||
return hrs > 0 ? `${hrs}h ${mins % 60}m` : `${mins}m`;
|
|
||||||
};
|
|
||||||
const downloadLabel = (id: string) => {
|
const downloadLabel = (id: string) => {
|
||||||
switch (downloadStore.getDownloadStatus(id)) {
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
case DownloadStatus.QUEUED:
|
case DownloadStatus.QUEUED:
|
||||||
@@ -172,9 +377,31 @@ export function MyShowsPage() {
|
|||||||
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
audioNav.setSource(AudioSource.MY_SHOWS, selectedShow()?.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stream an unsubscribed-show download. The record carries only what was
|
||||||
|
* persisted at download time, so a minimal Episode is reconstructed. */
|
||||||
|
const playUnsubscribedDownload = (d: DownloadedEpisode) => {
|
||||||
|
audio
|
||||||
|
.play({
|
||||||
|
id: d.episodeId,
|
||||||
|
podcastId: d.feedId,
|
||||||
|
title: d.episodeTitle ?? d.episodeId,
|
||||||
|
description: "",
|
||||||
|
audioUrl: d.audioUrl ?? "",
|
||||||
|
duration: 0,
|
||||||
|
pubDate: d.pubDate ? new Date(d.pubDate) : new Date(),
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, d.feedId);
|
||||||
|
};
|
||||||
|
|
||||||
// ── drill / open ───────────────────────────────────────────────────────────
|
// ── drill / open ───────────────────────────────────────────────────────────
|
||||||
function open() {
|
function open() {
|
||||||
if (depth() === 0) {
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
playUnsubscribedDownload(d);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const show = selectedShow();
|
const show = selectedShow();
|
||||||
if (!show) return;
|
if (!show) return;
|
||||||
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
nav.pushDepth({ kind: "episodes", ctx: show.id, focus: 0 } as DepthFrame);
|
||||||
@@ -215,6 +442,14 @@ export function MyShowsPage() {
|
|||||||
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
if (ep) downloadStore.startDownload(ep, drilledShowId());
|
||||||
},
|
},
|
||||||
"delete-download": () => {
|
"delete-download": () => {
|
||||||
|
if (depth() === 0) {
|
||||||
|
const d = focusedUnsub();
|
||||||
|
if (d) {
|
||||||
|
downloadStore.cancelDownload(d.episodeId);
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (depth() < 1) return;
|
if (depth() < 1) return;
|
||||||
const ep = focusedEpisode();
|
const ep = focusedEpisode();
|
||||||
if (!ep) return;
|
if (!ep) return;
|
||||||
@@ -271,19 +506,13 @@ export function MyShowsPage() {
|
|||||||
|
|
||||||
// ── render ──────────────────────────────────────────────────────────────────
|
// ── render ──────────────────────────────────────────────────────────────────
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
const focusBg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active ? theme.primary : i === lf ? theme.border : undefined;
|
|
||||||
const focusFg = (i: number, lf: number, active: boolean) =>
|
|
||||||
i === lf && active
|
|
||||||
? theme.surface
|
|
||||||
: i === lf
|
|
||||||
? theme.selectedListItemText ?? theme.text
|
|
||||||
: theme.text;
|
|
||||||
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
const showTitle = (f: Feed) => f.customName || f.podcast.title;
|
||||||
|
|
||||||
const currentLabel = () =>
|
const currentLabel = () =>
|
||||||
depth() === 0
|
depth() === 0
|
||||||
? `Shows (${shows().length})`
|
? `Shows (${shows().length})${
|
||||||
|
unsubs().length > 0 ? ` · Unsub DL (${unsubs().length})` : ""
|
||||||
|
}`
|
||||||
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
: `${selectedShow() ? showTitle(selectedShow()!) : "Episodes"} · ${episodes().length}`;
|
||||||
|
|
||||||
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
// ── parent pane: previous-depth list (muted/blank at depth 0) ─────────────
|
||||||
@@ -295,18 +524,21 @@ export function MyShowsPage() {
|
|||||||
{(feed, index) => {
|
{(feed, index) => {
|
||||||
const lf = () => nav.depthFocus(0);
|
const lf = () => nav.depthFocus(0);
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
const ref = useScrollIntoView(() => index() === lf());
|
||||||
|
const focused = () => index() === lf();
|
||||||
|
const fg = () =>
|
||||||
|
focused()
|
||||||
|
? theme.selectedListItemText ?? theme.text
|
||||||
|
: theme.text;
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={1}
|
gap={1}
|
||||||
paddingRight={1}
|
paddingRight={1}
|
||||||
backgroundColor={focusBg(index(), lf(), false)}
|
backgroundColor={focused() ? theme.border : undefined}
|
||||||
>
|
>
|
||||||
<text fg={focusFg(index(), lf(), false)}>
|
<text fg={fg()}>{focused() ? marker() : " "}</text>
|
||||||
{index() === lf() ? marker() : " "}
|
<text fg={fg()}>{showTitle(feed)}</text>
|
||||||
</text>
|
|
||||||
<text fg={focusFg(index(), lf(), false)}>{showTitle(feed)}</text>
|
|
||||||
<text fg={muted()}>({feed.episodes.length})</text>
|
<text fg={muted()}>({feed.episodes.length})</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
@@ -321,7 +553,7 @@ export function MyShowsPage() {
|
|||||||
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
{/* depth 0: shows — stable sibling <Show> so the swap disposes cleanly */}
|
||||||
<Show when={depth() === 0}>
|
<Show when={depth() === 0}>
|
||||||
<Show
|
<Show
|
||||||
when={shows().length > 0}
|
when={depth0Count() > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<box padding={1}>
|
||||||
<text fg={muted()}>
|
<text fg={muted()}>
|
||||||
@@ -331,52 +563,53 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={shows()}>
|
<For each={shows()}>
|
||||||
{(feed, index) => {
|
{(feed, index) => (
|
||||||
const lf = () => focusedShowIdx();
|
<ShowRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
feed={feed}
|
||||||
const wlScope =
|
title={showTitle(feed)}
|
||||||
app.state().preferences.autoDownloadScope === "whitelist";
|
index={index}
|
||||||
const wlInList = (
|
focused={focusedShowIdx}
|
||||||
app.state().preferences.autoDownloadWhitelist ?? []
|
active={isActive}
|
||||||
).includes(feed.id);
|
marker={marker}
|
||||||
return (
|
wlScope={() =>
|
||||||
<box
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
ref={ref}
|
}
|
||||||
flexDirection="row"
|
wlInList={() =>
|
||||||
gap={1}
|
(app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
paddingRight={1}
|
feed.id,
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
)
|
||||||
|
}
|
||||||
|
onMouseDown={() => {
|
||||||
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
|
nav.setDepthFocus(index(), 0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Show when={unsubs().length > 0}>
|
||||||
|
<box paddingLeft={1} paddingTop={1}>
|
||||||
|
<text fg={theme.textSecondary}>
|
||||||
|
Unsubscribed Show Downloads
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<For each={unsubs()}>
|
||||||
|
{(d, index) => (
|
||||||
|
<UnsubscribedRow
|
||||||
|
d={d}
|
||||||
|
index={() => shows().length + index()}
|
||||||
|
focused={() => nav.depthFocus(0)}
|
||||||
|
active={isActive}
|
||||||
|
marker={marker}
|
||||||
|
downloadLabel={() => downloadLabel(d.episodeId)}
|
||||||
|
downloadColor={() => downloadColor(d.episodeId)}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 0);
|
nav.setDepthFocus(shows().length + index(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
)}
|
||||||
{index() === lf() ? marker() : " "}
|
</For>
|
||||||
</text>
|
</Show>
|
||||||
<text fg={focusFg(index(), lf(), isActive())}>
|
|
||||||
{showTitle(feed)}
|
|
||||||
</text>
|
|
||||||
<text fg={index() === lf() ? theme.surface : muted()}>
|
|
||||||
({feed.episodes.length})
|
|
||||||
</text>
|
|
||||||
<Show when={wlScope}>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
index() === lf()
|
|
||||||
? theme.surface
|
|
||||||
: wlInList
|
|
||||||
? theme.warning
|
|
||||||
: muted()
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{wlInList ? "●" : "○"}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
{/* depth ≥1: episodes */}
|
{/* depth ≥1: episodes */}
|
||||||
@@ -390,98 +623,37 @@ export function MyShowsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<For each={episodes()}>
|
<For each={episodes()}>
|
||||||
{(ep, index) => {
|
{(ep, index) => (
|
||||||
const lf = () => focusedEpIdx();
|
<EpisodeRow
|
||||||
const ref = useScrollIntoView(() => index() === lf());
|
episode={ep}
|
||||||
return (
|
index={index}
|
||||||
<box
|
focused={focusedEpIdx}
|
||||||
ref={ref}
|
active={isActive}
|
||||||
flexDirection="column"
|
selected={() => nav.isSelected(ep.id)}
|
||||||
gap={0}
|
downloadLabel={() => downloadLabel(ep.id)}
|
||||||
paddingRight={1}
|
downloadColor={() => downloadColor(ep.id)}
|
||||||
backgroundColor={focusBg(index(), lf(), isActive())}
|
marker={marker}
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(index(), 1);
|
nav.setDepthFocus(index(), 1);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<box flexDirection="row" gap={1}>
|
)}
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
fg={focusFg(index(), lf(), isActive())}
|
|
||||||
>
|
|
||||||
{index() === lf() ? marker() : " "}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
wrapMode="none"
|
|
||||||
truncate
|
|
||||||
fg={focusFg(index(), lf(), isActive())}
|
|
||||||
>
|
|
||||||
{ep.episodeNumber ? `#${ep.episodeNumber} ` : ""}
|
|
||||||
{ep.title}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
fg={index() === lf() ? theme.surface : theme.info}
|
|
||||||
>
|
|
||||||
{formatDate(ep.pubDate)}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
flexShrink={0}
|
|
||||||
fg={index() === lf() ? theme.surface : muted()}
|
|
||||||
>
|
|
||||||
{formatDuration(ep.duration)}
|
|
||||||
</text>
|
|
||||||
<Show when={nav.isSelected(ep.id)}>
|
|
||||||
<text flexShrink={0} fg={theme.warning}>
|
|
||||||
●
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
<Show when={downloadLabel(ep.id)}>
|
|
||||||
<text flexShrink={0} fg={downloadColor(ep.id)}>
|
|
||||||
{downloadLabel(ep.id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
<Show when={showFetchMore()}>
|
<Show when={showFetchMore()}>
|
||||||
<box
|
<FetchMoreRow
|
||||||
ref={moreRef}
|
index={() => episodes().length}
|
||||||
flexDirection="row"
|
focused={focusedRow}
|
||||||
gap={1}
|
onMore={focusedOnMore}
|
||||||
paddingRight={1}
|
active={isActive}
|
||||||
backgroundColor={focusBg(
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
episodes().length,
|
nerd={nerd}
|
||||||
focusedRow(),
|
marker={marker}
|
||||||
isActive(),
|
|
||||||
)}
|
|
||||||
onMouseDown={() => {
|
onMouseDown={() => {
|
||||||
nav.setActivePane(DEPTH_CENTER_PANE);
|
nav.setActivePane(DEPTH_CENTER_PANE);
|
||||||
nav.setDepthFocus(episodes().length, 1);
|
nav.setDepthFocus(episodes().length, 1);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
{focusedOnMore() ? marker() : " "}
|
|
||||||
</text>
|
|
||||||
{nerd && (
|
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
{NF_ICONS.more}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
<Show
|
|
||||||
when={!feedStore.isLoadingMore()}
|
|
||||||
fallback={<LoadingIndicator label="Fetching…" />}
|
|
||||||
>
|
|
||||||
<text fg={focusFg(episodes().length, focusedRow(), isActive())}>
|
|
||||||
[Fetch More]
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -489,63 +661,74 @@ export function MyShowsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ── preview pane ───────────────────────────────────────────────────────────
|
// ── preview pane ───────────────────────────────────────────────────────────
|
||||||
|
const episodeHint = (epId: string) =>
|
||||||
|
`enter: play · d: download${
|
||||||
|
downloadStore.getDownloadStatus(epId) !== DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""
|
||||||
|
}${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
||||||
|
drilledShowId(),
|
||||||
|
)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
} · space: select · h: back`;
|
||||||
|
|
||||||
|
const showHint = (show: Feed) =>
|
||||||
|
`enter/l: open · h: back · x: unsubscribe${
|
||||||
|
app.state().preferences.autoDownloadScope === "whitelist"
|
||||||
|
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(show.id)
|
||||||
|
? " · w: un-whitelist"
|
||||||
|
: " · w: whitelist"
|
||||||
|
: ""
|
||||||
|
}`;
|
||||||
|
|
||||||
const previewContent = () =>
|
const previewContent = () =>
|
||||||
depth() === 0 ? (
|
depth() === 0 ? (
|
||||||
// depth 0 preview: hovered show
|
// depth 0 preview: hovered unsubscribed-show download, else the
|
||||||
|
// hovered show.
|
||||||
<Show
|
<Show
|
||||||
when={selectedShow()}
|
when={focusedUnsub()}
|
||||||
fallback={
|
fallback={
|
||||||
<box padding={1}>
|
<Show
|
||||||
<text fg={muted()}>No show focused</text>
|
when={selectedShow()}
|
||||||
</box>
|
fallback={
|
||||||
|
<box padding={1}>
|
||||||
|
<text fg={muted()}>No show focused</text>
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(show) => (
|
||||||
|
<ShowPreview
|
||||||
|
show={() => show()}
|
||||||
|
title={() => showTitle(show())}
|
||||||
|
hint={() => showHint(show())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(show) => (
|
{(d) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<UnsubscribedPreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
d={() => d()}
|
||||||
<strong>{showTitle(show())}</strong>
|
downloadLabel={() => downloadLabel(d().episodeId)}
|
||||||
</text>
|
downloadColor={() => downloadColor(d().episodeId)}
|
||||||
<Show when={show().podcast.author}>
|
/>
|
||||||
<text fg={muted()}>by {show().podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{show().episodes.length} episodes
|
|
||||||
</text>
|
|
||||||
<text fg={muted()}>
|
|
||||||
{show().podcast.description?.slice(0, 400) ?? "No description."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter/l: open · h: back · x: unsubscribe
|
|
||||||
{app.state().preferences.autoDownloadScope === "whitelist"
|
|
||||||
? (app.state().preferences.autoDownloadWhitelist ??
|
|
||||||
[]
|
|
||||||
).includes(show().id)
|
|
||||||
? " · w: un-whitelist"
|
|
||||||
: " · w: whitelist"
|
|
||||||
: ""}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
) : (
|
) : (
|
||||||
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
// depth ≥1 preview: hovered episode (or the Fetch More row)
|
||||||
<>
|
<>
|
||||||
<Show when={focusedOnMore()}>
|
<Show when={focusedOnMore()}>
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<FetchMorePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
isLoadingMore={() => feedStore.isLoadingMore()}
|
||||||
<strong>[Fetch More]</strong>
|
fetchMoreMode={fetchMoreMode}
|
||||||
</text>
|
manualText={() =>
|
||||||
<text fg={muted()}>
|
"Load the next batch of older episodes for this show (Enter)."
|
||||||
{feedStore.isLoadingMore()
|
}
|
||||||
? "Loading the next batch of episodes…"
|
/>
|
||||||
: fetchMoreMode() === "auto"
|
|
||||||
? "Auto mode: the next batch loads automatically at the bottom of the list."
|
|
||||||
: "Load the next batch of older episodes for this show (Enter)."}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>enter: load more · h back</text>
|
|
||||||
</box>
|
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={!focusedOnMore()}>
|
<Show when={!focusedOnMore()}>
|
||||||
<Show
|
<Show
|
||||||
@@ -556,53 +739,19 @@ export function MyShowsPage() {
|
|||||||
</box>
|
</box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(ep) => (
|
{(ep) => (
|
||||||
<box flexDirection="column" gap={1} padding={1}>
|
<EpisodePreview
|
||||||
<text fg={theme.textPrimary ?? theme.text}>
|
episode={() => ep()}
|
||||||
<strong>
|
author={() => selectedShow()?.podcast.author}
|
||||||
{ep().episodeNumber ? `#${ep().episodeNumber} ` : ""}
|
downloadLabel={() => downloadLabel(ep().id)}
|
||||||
{ep().title}
|
downloadColor={() => downloadColor(ep().id)}
|
||||||
</strong>
|
hint={() => episodeHint(ep().id)}
|
||||||
</text>
|
/>
|
||||||
<box flexDirection="row" gap={2}>
|
)}
|
||||||
<text fg={theme.info}>{formatDate(ep().pubDate)}</text>
|
</Show>
|
||||||
<text fg={muted()}>{formatDuration(ep().duration)}</text>
|
|
||||||
<Show when={downloadLabel(ep().id)}>
|
|
||||||
<text fg={downloadColor(ep().id)}>
|
|
||||||
{downloadLabel(ep().id)}
|
|
||||||
</text>
|
|
||||||
</Show>
|
|
||||||
</box>
|
|
||||||
<Show when={selectedShow()?.podcast.author}>
|
|
||||||
<text fg={muted()}>by {selectedShow()!.podcast.author}</text>
|
|
||||||
</Show>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={theme.textSecondary}>
|
|
||||||
{ep().description?.slice(0, 400) ?? "No description available."}
|
|
||||||
{(ep().description?.length ?? 0) > 400 ? "…" : ""}
|
|
||||||
</text>
|
|
||||||
<box height={1} />
|
|
||||||
<text fg={muted()}>
|
|
||||||
enter: play · d: download
|
|
||||||
{downloadStore.getDownloadStatus(ep().id) !==
|
|
||||||
DownloadStatus.NONE
|
|
||||||
? " · D: delete"
|
|
||||||
: ""}
|
|
||||||
{app.state().preferences.autoDownloadScope === "whitelist"
|
|
||||||
? (app.state().preferences.autoDownloadWhitelist ?? []).includes(
|
|
||||||
drilledShowId(),
|
|
||||||
)
|
|
||||||
? " · w: un-whitelist"
|
|
||||||
: " · w: whitelist"
|
|
||||||
: ""}{" "}
|
|
||||||
· space: select · h: back
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
)}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaneRow
|
<PaneRow
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
|||||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||||
<text fg={theme.textMuted}>Speed</text>
|
<text fg={theme.textMuted}>Speed</text>
|
||||||
<text fg={theme.text}>{props.speed}x</text>
|
<text fg={theme.text}>{props.speed}x</text>
|
||||||
<text fg={theme.textMuted}>s</text>
|
<text fg={theme.textMuted}>S</text>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
{/* audio warnings — wrap to their own (3rd) line when the row is tight */}
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
* tab root.
|
* tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show, onMount, onCleanup } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
@@ -27,7 +28,19 @@ export function PlayerPage() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const viz = useVisualizer();
|
||||||
|
const app = useAppStore();
|
||||||
const muted = () => theme.muted || theme.text;
|
const muted = () => theme.muted || theme.text;
|
||||||
|
// Settings master switch: off hides the waveform entirely (the store
|
||||||
|
// also stops the decode+FFT pipeline, see stores/visualizer.ts).
|
||||||
|
const vizEnabled = () => app.state().settings.visualizer.enabled;
|
||||||
|
|
||||||
|
// The page is mounted exactly while the Player tab is in focus (Shell
|
||||||
|
// renders only the active tab), so mount ⇔ focused. Report it to the
|
||||||
|
// visualizer store: losing focus starts the unload grace timer instead
|
||||||
|
// of killing the pipeline with the page; regaining focus restarts it.
|
||||||
|
onMount(() => viz.setFocused(true));
|
||||||
|
onCleanup(() => viz.setFocused(false));
|
||||||
|
|
||||||
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
const isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
@@ -82,18 +95,9 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
<ProgressBar />
|
<ProgressBar />
|
||||||
|
|
||||||
<RealtimeWaveform
|
<Show when={vizEnabled()}>
|
||||||
visualizerConfig={(() => {
|
<RealtimeWaveform />
|
||||||
const viz = useAppStore().state().settings.visualizer;
|
</Show>
|
||||||
// bars is width-derived in RealtimeWaveform; pass only the
|
|
||||||
// audio-processing params here.
|
|
||||||
return {
|
|
||||||
noiseReduction: viz.noiseReduction,
|
|
||||||
lowCutOff: viz.lowCutOff,
|
|
||||||
highCutOff: viz.highCutOff,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -129,6 +133,7 @@ export function PlayerPage() {
|
|||||||
currentLabel="Player"
|
currentLabel="Player"
|
||||||
panes={2}
|
panes={2}
|
||||||
focused={isActive}
|
focused={isActive}
|
||||||
|
currentBorder={["left"]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ export function ProgressBar() {
|
|||||||
padding={0}
|
padding={0}
|
||||||
flexDirection="row"
|
flexDirection="row"
|
||||||
gap={0}
|
gap={0}
|
||||||
|
// The bar's block-char texts are non-selectable below: a drag
|
||||||
|
// over the bar is a seek gesture, not a text selection — otherwise
|
||||||
|
// mouse-up would copy █/░ to the clipboard via the global
|
||||||
|
// selection handler.
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
bar = el;
|
bar = el;
|
||||||
}}
|
}}
|
||||||
@@ -58,9 +62,11 @@ export function ProgressBar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playedChars() > 0 && (
|
{playedChars() > 0 && (
|
||||||
<text fg={theme.primary}>{"\u2588".repeat(playedChars())}</text>
|
<text fg={theme.primary} selectable={false}>
|
||||||
|
{"\u2588".repeat(playedChars())}
|
||||||
|
</text>
|
||||||
)}
|
)}
|
||||||
<text fg={remainingColor}>
|
<text fg={remainingColor} selectable={false}>
|
||||||
{"\u2591".repeat(width() - playedChars())}
|
{"\u2591".repeat(width() - playedChars())}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|||||||
@@ -1,55 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||||
*
|
*
|
||||||
* Spawns an independent ffmpeg
|
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||||
* for FFT analysis, and renders frequency bars as colored terminal
|
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||||
* characters at ~30fps.
|
* warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down.
|
||||||
|
*
|
||||||
|
* This component only subscribes to store state, reports the width-derived
|
||||||
|
* bar count (terminal resize re-inits the running pipeline), and renders:
|
||||||
|
* a braille spinner while the pipeline is loading its first frames, the
|
||||||
|
* frequency bars once frames arrive, and a dotted placeholder when idle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
|
import { createEffect, on } from "solid-js";
|
||||||
import { useTerminalDimensions } from "@opentui/solid";
|
import { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
loadCavaCore,
|
|
||||||
type CavaCore,
|
|
||||||
type CavaCoreConfig,
|
|
||||||
} from "@/utils/cavacore";
|
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
|
||||||
import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Target frame interval in ms (~30 fps) */
|
|
||||||
const FRAME_INTERVAL = 33;
|
|
||||||
|
|
||||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
|
||||||
const SAMPLES_PER_FRAME = 512;
|
|
||||||
|
|
||||||
// ── Component ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const viz = useVisualizer();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
|
||||||
|
|
||||||
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
|
|
||||||
// frame against the running peak so a loud start can't pin every bar
|
|
||||||
// at full height and quiet content still gets normalized up.
|
|
||||||
const scaler = createBarScaler();
|
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
|
||||||
let reader: AudioStreamReader | null = null;
|
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
let sampleBuffer: Float64Array | null = null;
|
|
||||||
|
|
||||||
// Bar count scales with terminal width so the waveform fills its pane.
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
// The player is a 2-pane row: current column = (current+preview) of
|
// The player is a 2-pane row: current column = (current+preview) of
|
||||||
@@ -68,181 +43,25 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
// Keep the store's bar count in sync with the terminal width; the store
|
||||||
|
// re-inits the running pipeline when it changes (terminal resize).
|
||||||
const initCava = () => {
|
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||||
if (cava) return true;
|
|
||||||
|
|
||||||
cava = loadCavaCore();
|
|
||||||
if (!cava) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Smooth position clock ──────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
|
||||||
// polls, interpolate the position from wall time so the FFT window
|
|
||||||
// tracks the audio continuously instead of stepping. The 0.5s cap
|
|
||||||
// prevents extrapolating far beyond reality when the player stalls
|
|
||||||
// (e.g. network re-buffering).
|
|
||||||
|
|
||||||
let lastPolledPosition = 0;
|
|
||||||
let lastPolledAt = 0;
|
|
||||||
const smoothPosition = () => {
|
|
||||||
const pos = audio.position();
|
|
||||||
const now = performance.now();
|
|
||||||
if (pos !== lastPolledPosition) {
|
|
||||||
lastPolledPosition = pos;
|
|
||||||
lastPolledAt = now;
|
|
||||||
return pos;
|
|
||||||
}
|
|
||||||
if (lastPolledAt === 0) return pos;
|
|
||||||
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
|
|
||||||
return lastPolledPosition + elapsed * (audio.speed() ?? 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
|
||||||
stopVisualization();
|
|
||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
|
||||||
|
|
||||||
// Initialize cavacore with current resolution + any overrides.
|
|
||||||
// bars is width-derived (see numBars); visualizerConfig supplies the
|
|
||||||
// audio-processing params (noise reduction, cutoffs, etc.).
|
|
||||||
// autosens is disabled (after the spread so it always wins): cava's
|
|
||||||
// autosens gain-ramps during silence then clips everything to 1.0
|
|
||||||
// when audio arrives — the JS peak scaler handles dynamics instead.
|
|
||||||
const config: CavaCoreConfig = {
|
|
||||||
bars: numBars(),
|
|
||||||
sampleRate: 44100,
|
|
||||||
channels: 1,
|
|
||||||
...props.visualizerConfig,
|
|
||||||
autosens: 0,
|
|
||||||
};
|
|
||||||
cava.init(config);
|
|
||||||
|
|
||||||
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
|
||||||
// uninitialized, so the first real frame would FFT garbage and
|
|
||||||
// render full-scale bars. One zero frame the size of the whole
|
|
||||||
// input buffer clears it (at 44.1kHz mono the window is 8192
|
|
||||||
// samples — FFTbassbufferSize × channels; a 512-sample frame would
|
|
||||||
// leave the tail garbage).
|
|
||||||
cava.execute(new Float64Array(8192));
|
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
|
||||||
if (!reader || reader.url !== url) {
|
|
||||||
if (reader) reader.stop();
|
|
||||||
reader = new AudioStreamReader({ url });
|
|
||||||
}
|
|
||||||
reader.start(position, speed);
|
|
||||||
|
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopVisualization = () => {
|
|
||||||
if (frameTimer) {
|
|
||||||
clearInterval(frameTimer);
|
|
||||||
frameTimer = null;
|
|
||||||
}
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
|
||||||
}
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
sampleBuffer = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
|
||||||
|
|
||||||
const renderFrame = () => {
|
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
|
||||||
|
|
||||||
// Sample the FFT window at the player's position, not the decode
|
|
||||||
// head — the reader decodes independently (paced at the player's
|
|
||||||
// clock rate with a LEAD_SECONDS burst head start) and only the
|
|
||||||
// position clock ties the bars to what's actually playing.
|
|
||||||
const target = smoothPosition();
|
|
||||||
const count = reader.read(sampleBuffer, target);
|
|
||||||
// Never feed a partial FFT window to cava.
|
|
||||||
if (count < sampleBuffer.length) return;
|
|
||||||
|
|
||||||
const output = cava.execute(sampleBuffer);
|
|
||||||
|
|
||||||
// Normalize against the running peak and copy to a new array
|
|
||||||
setBarData(scaler(output));
|
|
||||||
};
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
[
|
|
||||||
audio.isPlaying,
|
|
||||||
() => audio.currentEpisode()?.audioUrl ?? "",
|
|
||||||
audio.speed,
|
|
||||||
numBars,
|
|
||||||
],
|
|
||||||
([playing, url, speed]) => {
|
|
||||||
if (playing && url) {
|
|
||||||
const pos = untrack(audio.position);
|
|
||||||
startVisualization(url, pos, speed);
|
|
||||||
} else {
|
|
||||||
stopVisualization();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
|
||||||
//
|
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
|
||||||
createEffect(
|
|
||||||
on(audio.position, (pos) => {
|
|
||||||
if (!audio.isPlaying || !reader?.running) {
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const delta = Math.abs(pos - lastSyncPosition);
|
|
||||||
lastSyncPosition = pos;
|
|
||||||
|
|
||||||
if (delta > 2) {
|
|
||||||
reader.restart(pos, audio.speed() ?? 1);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
stopVisualization();
|
|
||||||
if (reader) {
|
|
||||||
reader.stop();
|
|
||||||
reader = null;
|
|
||||||
}
|
|
||||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
|
||||||
if (cava?.isReady) {
|
|
||||||
cava.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = viz.barData();
|
||||||
const count = numBars();
|
const count = numBars();
|
||||||
|
|
||||||
|
// Loading state: the braille spinner shows while the pipeline warms
|
||||||
|
// up — but only when there are no bars to render yet (first play /
|
||||||
|
// after an unload). On resume/seek the last bars stay on screen
|
||||||
|
// until fresh frames arrive, so the waveform never blanks out for
|
||||||
|
// the (multi-second, network-bound) cold start.
|
||||||
|
if (bars.length === 0 && viz.isLoading()) {
|
||||||
|
return <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import {
|
|||||||
} from "solid-js";
|
} from "solid-js";
|
||||||
import { useSearchStore } from "@/stores/search";
|
import { useSearchStore } from "@/stores/search";
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
|
import { useDownloadStore } from "@/stores/download";
|
||||||
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
|
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
|
||||||
|
import { DownloadStatus } from "@/types/episode";
|
||||||
import { useToast } from "@/ui/toast";
|
import { useToast } from "@/ui/toast";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
@@ -55,6 +59,9 @@ export const SearchPaneCount = 1;
|
|||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const searchStore = useSearchStore();
|
const searchStore = useSearchStore();
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
|
const downloadStore = useDownloadStore();
|
||||||
|
const audio = useAudio();
|
||||||
|
const audioNav = useAudioNavStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [inputValue, setInputValue] = createSignal("");
|
const [inputValue, setInputValue] = createSignal("");
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
@@ -70,30 +77,15 @@ function SearchPage() {
|
|||||||
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
const submittedQuery = (): string => stack()[1]?.ctx ?? searchStore.query();
|
||||||
|
|
||||||
// ── input focusing ────────────────────────────────────────────────────────
|
// ── input focusing ────────────────────────────────────────────────────────
|
||||||
// `inputFocused` is true while the query input is being typed in. The Shell
|
// `inputFocused` tells the Shell router to yield keys to the query input.
|
||||||
// router yields keys to the <input> while this is true; Escape (in Shell)
|
// The input's REAL focus is the source of truth: useInputFocusNav flips
|
||||||
// sets it false so navigation resumes; `s` (search action) sets it true.
|
// the flag from the input's FOCUSED/BLURRED events, so clicking off the
|
||||||
//
|
// input drops it and the router resumes j/k/h — no stuck "typing" state.
|
||||||
// The input's REAL focus is the source of truth for the flag:
|
// The depth stack only SEEDS it on transitions (re-entering depth 0
|
||||||
// useInputFocusNav (the same hook the Settings forms use) flips
|
// focuses the input; mounting at depth 1 stays list-nav), gated on the
|
||||||
// `inputFocused` from the input's FOCUSED/BLURRED events, keeping the flag
|
// depth VALUE via a memo because setDepthFocus also writes the stack
|
||||||
// and the renderable in lockstep. That matters when the user clicks OFF the
|
// signal — without the memo every j/k at query depth re-focuses the input
|
||||||
// input: opentui's mouse dispatch auto-focuses the clicked target's nearest
|
// and strands the recents list.
|
||||||
// focusable ancestor (a pane scrollbox), blurring the input. The BLURRED
|
|
||||||
// event drops the flag, so the Shell router immediately resumes j/k/h
|
|
||||||
// instead of swallowing keys with no input to receive them — no more
|
|
||||||
// stuck "typing" state where Esc/j/k/s all do nothing.
|
|
||||||
//
|
|
||||||
// The depth stack still SEEDS the flag on transitions, since the query
|
|
||||||
// depth defaults to typing: re-entering depth 0 (h back from results, or a
|
|
||||||
// fresh mount) focuses the input; mounting at depth 1 (returning to the
|
|
||||||
// tab after a search) stays list-navigation — a stuck-on flag there would
|
|
||||||
// have the Shell yield j/k to a non-existent input. The depth STACK signal
|
|
||||||
// is also written by focus moves (setDepthFocus), so gate the seed on the
|
|
||||||
// depth VALUE via a memo: the effect must re-run only on an actual depth
|
|
||||||
// transition. Without the memo every j/k at the query depth re-focuses the
|
|
||||||
// input (undoing Escape), which keeps the recents list unreachable by
|
|
||||||
// keyboard.
|
|
||||||
onMount(() => nav.setInputFocused(depth() === 0));
|
onMount(() => nav.setInputFocused(depth() === 0));
|
||||||
onCleanup(() => nav.setInputFocused(false));
|
onCleanup(() => nav.setInputFocused(false));
|
||||||
const focusNavRef = useInputFocusNav();
|
const focusNavRef = useInputFocusNav();
|
||||||
@@ -134,6 +126,35 @@ function SearchPage() {
|
|||||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
const formatDate = (d: Date) => format(d, "MMM d, yyyy");
|
||||||
|
|
||||||
|
const downloadLabel = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return "[Q]";
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return `[${downloadStore.getDownloadProgress(id)}%]`;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return "[DL]";
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return "[ERR]";
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const downloadColor = (id: string) => {
|
||||||
|
switch (downloadStore.getDownloadStatus(id)) {
|
||||||
|
case DownloadStatus.QUEUED:
|
||||||
|
return theme.warning;
|
||||||
|
case DownloadStatus.DOWNLOADING:
|
||||||
|
return theme.primary;
|
||||||
|
case DownloadStatus.COMPLETED:
|
||||||
|
return theme.success;
|
||||||
|
case DownloadStatus.FAILED:
|
||||||
|
return theme.error;
|
||||||
|
default:
|
||||||
|
return muted();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const runSearch = (query: string) => {
|
const runSearch = (query: string) => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
@@ -185,6 +206,59 @@ function SearchPage() {
|
|||||||
if (feed) searchStore.markSubscribed(result.podcast.id);
|
if (feed) searchStore.markSubscribed(result.podcast.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The subscribed feed backing a search result, if any (matched by
|
||||||
|
* directory id or feed URL). */
|
||||||
|
const feedForResult = (r: SearchResult) =>
|
||||||
|
feedStore.feeds().find(
|
||||||
|
(f) =>
|
||||||
|
f.podcast.id === r.podcast.id ||
|
||||||
|
(!!r.podcast.feedUrl && f.podcast.feedUrl === r.podcast.feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Download the focused episode: under its subscribed feed when the show
|
||||||
|
* is subscribed, otherwise as an "unsubscribed show" download (listed
|
||||||
|
* under Unsubscribed Show Downloads in My Shows / the download manager). */
|
||||||
|
const downloadFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) downloadStore.startDownload(r.episode, feed.id);
|
||||||
|
else downloadStore.startUnsubscribedDownload(r.episode, r.podcast);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playFocusedEpisode = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
audio.play(r.episode).catch(() => {});
|
||||||
|
audioNav.setSource(AudioSource.SEARCH, r.podcast.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || !r.podcast.isSubscribed) return;
|
||||||
|
const feed = feedForResult(r);
|
||||||
|
if (feed) {
|
||||||
|
feedStore.removeFeed(feed.id);
|
||||||
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feed.id, feed.podcast.feedUrl || undefined)
|
||||||
|
.catch(() => {});
|
||||||
|
searchStore.markUnsubscribed(r.podcast.id, r.podcast.feedUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Subscribe the focused result's show in place (episode or podcast
|
||||||
|
* result). `enter` plays episodes regardless of subscription, so an
|
||||||
|
* unsubscribed show's episode needs this explicit path. */
|
||||||
|
const subscribeFocused = () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.podcast.isSubscribed) return;
|
||||||
|
handleSubscribe(r);
|
||||||
|
};
|
||||||
|
|
||||||
// ── nav.action handler ──────────────────────────────────────────────────────
|
// ── nav.action handler ──────────────────────────────────────────────────────
|
||||||
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
const PAGE_ACTIONS: Partial<Record<KeybindActionName, () => void>> = {
|
||||||
"move-down": () => step(1),
|
"move-down": () => step(1),
|
||||||
@@ -205,6 +279,18 @@ function SearchPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
download: () => downloadFocusedEpisode(),
|
||||||
|
"delete-download": () => {
|
||||||
|
if (depth() !== 1) return;
|
||||||
|
const r = focusedResult();
|
||||||
|
if (!r || r.kind !== "episode") return;
|
||||||
|
const id = r.episode.id;
|
||||||
|
if (downloadStore.getDownloadStatus(id) === DownloadStatus.NONE) return;
|
||||||
|
downloadStore.cancelDownload(id);
|
||||||
|
downloadStore.removeDownload(id).catch(() => {});
|
||||||
|
},
|
||||||
|
unsubscribe: () => unsubscribeFocused(),
|
||||||
|
subscribe: () => subscribeFocused(),
|
||||||
search: () => {
|
search: () => {
|
||||||
// `s` refocuses the query input (typing mode) when on the query depth.
|
// `s` refocuses the query input (typing mode) when on the query depth.
|
||||||
if (depth() === 0) nav.setInputFocused(true);
|
if (depth() === 0) nav.setInputFocused(true);
|
||||||
@@ -230,7 +316,15 @@ function SearchPage() {
|
|||||||
}
|
}
|
||||||
if (depth() === 1) {
|
if (depth() === 1) {
|
||||||
const r = focusedResult();
|
const r = focusedResult();
|
||||||
if (r) handleSubscribe(r);
|
if (!r) return;
|
||||||
|
if (r.kind === "episode") {
|
||||||
|
// Any episode result streams directly — subscribed or not
|
||||||
|
// (matches Feed/My Shows). `a` subscribes an unsubscribed
|
||||||
|
// show's episode in place.
|
||||||
|
playFocusedEpisode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSubscribe(r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +568,13 @@ function SearchPage() {
|
|||||||
{(result, index) => {
|
{(result, index) => {
|
||||||
const fi = () => focusedResultIdx();
|
const fi = () => focusedResultIdx();
|
||||||
const ref = useScrollIntoView(() => index() === fi());
|
const ref = useScrollIntoView(() => index() === fi());
|
||||||
|
// Episode download status badge ("" when absent).
|
||||||
|
const dlLabel = () =>
|
||||||
|
result.kind === "episode"
|
||||||
|
? downloadLabel(result.episode.id)
|
||||||
|
: "";
|
||||||
|
const dlEpId = () =>
|
||||||
|
result.kind === "episode" ? result.episode.id : "";
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -495,6 +596,11 @@ function SearchPage() {
|
|||||||
? result.episode.title
|
? result.episode.title
|
||||||
: result.podcast.title}
|
: result.podcast.title}
|
||||||
</text>
|
</text>
|
||||||
|
<Show when={dlLabel()}>
|
||||||
|
<text fg={downloadColor(dlEpId())}>
|
||||||
|
{dlLabel()}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
<Show when={result.podcast.isSubscribed}>
|
<Show when={result.podcast.isSubscribed}>
|
||||||
<text
|
<text
|
||||||
fg={index() === fi() ? theme.surface : theme.success}
|
fg={index() === fi() ? theme.surface : theme.success}
|
||||||
@@ -576,9 +682,16 @@ function SearchPage() {
|
|||||||
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
{(r.episode.description?.length ?? 0) > 400 ? "…" : ""}
|
||||||
</text>
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<text fg={muted()}>
|
<box flexDirection="row" gap={2}>
|
||||||
Published: {formatDate(r.episode.pubDate)}
|
<text fg={muted()}>
|
||||||
</text>
|
Published: {formatDate(r.episode.pubDate)}
|
||||||
|
</text>
|
||||||
|
<Show when={downloadLabel(r.episode.id)}>
|
||||||
|
<text fg={downloadColor(r.episode.id)}>
|
||||||
|
{downloadLabel(r.episode.id)}
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
<Show when={(r.podcast.categories ?? []).length > 0}>
|
<Show when={(r.podcast.categories ?? []).length > 0}>
|
||||||
<box flexDirection="row" gap={1}>
|
<box flexDirection="row" gap={1}>
|
||||||
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
<For each={(r.podcast.categories ?? []).slice(0, 4)}>
|
||||||
@@ -591,15 +704,31 @@ function SearchPage() {
|
|||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<Show when={!r.podcast.isSubscribed}>
|
<Show when={!r.podcast.isSubscribed}>
|
||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>[+] Subscribe (a)</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={r.podcast.isSubscribed}>
|
<Show when={r.podcast.isSubscribed}>
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>
|
<Show
|
||||||
enter: subscribe to show · h: back to query
|
when={r.podcast.isSubscribed}
|
||||||
</text>
|
fallback={
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · a: subscribe · d: download · h: back to query
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={muted()}>
|
||||||
|
enter: play · d: download · x: unsubscribe
|
||||||
|
{downloadStore.getDownloadStatus(r.episode.id) !==
|
||||||
|
DownloadStatus.NONE
|
||||||
|
? " · D: delete"
|
||||||
|
: ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -640,10 +769,16 @@ function SearchPage() {
|
|||||||
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
<text fg={theme.primary}>[+] Subscribe (enter)</text>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={r.podcast.isSubscribed}>
|
<Show when={r.podcast.isSubscribed}>
|
||||||
<text fg={theme.success}>Already subscribed</text>
|
<text fg={theme.success}>
|
||||||
|
Subscribed · x: unsubscribe
|
||||||
|
</text>
|
||||||
</Show>
|
</Show>
|
||||||
<box height={1} />
|
<box height={1} />
|
||||||
<text fg={muted()}>enter: subscribe · h: back to query</text>
|
<text fg={muted()}>
|
||||||
|
enter: subscribe
|
||||||
|
{r.podcast.isSubscribed ? " · x: unsubscribe" : ""}{" "}
|
||||||
|
· h: back to query
|
||||||
|
</text>
|
||||||
</box>
|
</box>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
* DownloadManager — exposes downloads as SettingItems for the depth-stack.
|
||||||
*
|
*
|
||||||
* • "Delete All Downloads" — action item; Enter wipes every download.
|
* • "Delete All Downloads" — action item; Enter wipes every download.
|
||||||
* • one item per show — action item; Enter deletes all that show's
|
* • one item per subscribed show — action item; Enter deletes all that
|
||||||
* downloads (file + metadata, aborts in-flight).
|
* show's downloads (file + metadata, aborts
|
||||||
* • one item per episode — action item; Enter deletes a single download.
|
* in-flight).
|
||||||
|
* • "Unsubscribed Show Downloads" — downloads made from episode search for
|
||||||
|
* shows that aren't subscribed, grouped
|
||||||
|
* under their own header.
|
||||||
|
* • one item per episode — action item; Enter deletes a single download.
|
||||||
*
|
*
|
||||||
* Titles resolve from the feed store at render time (reactive), falling back
|
* Titles resolve from the feed store at render time (reactive), falling back
|
||||||
* to the episode id when the feed is no longer loaded. Movement flows through
|
* to the persisted episode/show titles for unsubscribed-show downloads.
|
||||||
* nav.action — no own useKeyboard (matches the other panels).
|
* Movement flows through nav.action — no own useKeyboard (matches the other
|
||||||
|
* panels).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useFeedStore } from "@/stores/feed";
|
import { useFeedStore } from "@/stores/feed";
|
||||||
@@ -40,23 +45,26 @@ function statusLabel(s: DownloadStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Episode title for a download, resolved from the feed store (reactive). */
|
/** Episode title for a download, resolved from the feed store (reactive);
|
||||||
|
* falls back to the persisted title (kept for unsubscribed-show downloads). */
|
||||||
function episodeTitle(
|
function episodeTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
d: DownloadedEpisode,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(d.feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
const ep = feed?.episodes.find((e) => e.id === d.episodeId);
|
||||||
return ep?.title ?? d.episodeId;
|
return ep?.title ?? d.episodeTitle ?? d.episodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show title for a download's feed id. */
|
/** Show title for a download's feed id; falls back to the persisted show
|
||||||
|
* title (unsubscribed-show downloads have no feed to resolve from). */
|
||||||
function feedTitle(
|
function feedTitle(
|
||||||
feedStore: ReturnType<typeof useFeedStore>,
|
feedStore: ReturnType<typeof useFeedStore>,
|
||||||
feedId: string,
|
d: DownloadedEpisode,
|
||||||
): string {
|
): string {
|
||||||
const feed = feedStore.getFeed(feedId);
|
const feed = feedStore.getFeed(d.feedId);
|
||||||
return feed ? feed.customName || feed.podcast.title : feedId;
|
if (feed) return feed.customName || feed.podcast.title;
|
||||||
|
return d.podcastTitle ?? d.feedId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDownloadItems(): SettingItem[] {
|
export function useDownloadItems(): SettingItem[] {
|
||||||
@@ -82,9 +90,15 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Group downloads by feed so each show gets a delete-by-show item.
|
// Group downloads by feed so each subscribed show gets a delete-by-show
|
||||||
|
// item. Unsubscribed-show downloads (search downloads, synthetic feed
|
||||||
|
// ids) are kept out of these groups and listed under their own section
|
||||||
|
// below.
|
||||||
|
const unsubscribed = downloadStore.getUnsubscribedDownloads();
|
||||||
|
const unsubscribedIds = new Set(unsubscribed.map((d) => d.episodeId));
|
||||||
const byFeed = new Map<string, DownloadedEpisode[]>();
|
const byFeed = new Map<string, DownloadedEpisode[]>();
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
const arr = byFeed.get(d.feedId) ?? [];
|
const arr = byFeed.get(d.feedId) ?? [];
|
||||||
arr.push(d);
|
arr.push(d);
|
||||||
byFeed.set(d.feedId, arr);
|
byFeed.set(d.feedId, arr);
|
||||||
@@ -93,25 +107,54 @@ export function useDownloadItems(): SettingItem[] {
|
|||||||
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
const size = eps.reduce((s, e) => s + e.fileSize, 0);
|
||||||
items.push({
|
items.push({
|
||||||
id: `feed:${feedId}`,
|
id: `feed:${feedId}`,
|
||||||
label: `Show: ${feedTitle(feedStore, feedId)}`,
|
label: `Show: ${feedTitle(feedStore, eps[0])}`,
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
display: () => `${eps.length} · ${fmtBytes(size)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
`Delete all ${eps.length} downloads for this show (files + metadata,\naborts any in-flight transfers). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
downloadStore.removeDownloadsForFeed(feedId).catch(() => {});
|
downloadStore
|
||||||
|
.removeDownloadsForFeed(feedId, eps[0].podcastFeedUrl)
|
||||||
|
.catch(() => {});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// One item per individual episode download.
|
// Unsubscribed-show downloads: a section header + one item per episode.
|
||||||
|
if (unsubscribed.length > 0) {
|
||||||
|
items.push({
|
||||||
|
id: "unsubscribed-header",
|
||||||
|
label: "Unsubscribed Show Downloads",
|
||||||
|
kind: "info",
|
||||||
|
display: () => `${unsubscribed.length} files`,
|
||||||
|
help: () =>
|
||||||
|
`Downloads made from episode search for shows that are not\nsubscribed. Subscribe to a show and these move into its group.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const d of unsubscribed) {
|
||||||
|
items.push({
|
||||||
|
id: `unsub:${d.episodeId}`,
|
||||||
|
label: episodeTitle(feedStore, d),
|
||||||
|
kind: "action",
|
||||||
|
display: () =>
|
||||||
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
|
help: () =>
|
||||||
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
|
run: () => {
|
||||||
|
downloadStore.removeDownload(d.episodeId).catch(() => {});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One item per individual (subscribed-show) episode download.
|
||||||
for (const d of downloads()) {
|
for (const d of downloads()) {
|
||||||
|
if (unsubscribedIds.has(d.episodeId)) continue;
|
||||||
items.push({
|
items.push({
|
||||||
id: `ep:${d.episodeId}`,
|
id: `ep:${d.episodeId}`,
|
||||||
label: episodeTitle(feedStore, d),
|
label: episodeTitle(feedStore, d),
|
||||||
kind: "action",
|
kind: "action",
|
||||||
display: () =>
|
display: () =>
|
||||||
`${feedTitle(feedStore, d.feedId)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
`${feedTitle(feedStore, d)} · ${statusLabel(d.status)} · ${fmtBytes(d.fileSize)}`,
|
||||||
help: () =>
|
help: () =>
|
||||||
`Delete this single download (file + metadata). Enter to run.`,
|
`Delete this single download (file + metadata). Enter to run.`,
|
||||||
run: () => {
|
run: () => {
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
* driven by the Shell router via nav.action.
|
* driven by the Shell router via nav.action.
|
||||||
*
|
*
|
||||||
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
* Auto-download (global setting, see stores/feed.ts runAutoDownload):
|
||||||
* • Auto Download — master toggle (default: off)
|
|
||||||
* • Auto Download Count — X most recent episodes per show (default: 2,
|
|
||||||
* any positive integer — type it in the editor)
|
|
||||||
* • Auto Download Scope — which shows: all / none / whitelist (default: all)
|
|
||||||
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
* • Auto Download Whitelist — shown only when scope is "whitelist": search
|
||||||
* field over subscribed shows; suggestions toggle
|
* field over subscribed shows; suggestions toggle
|
||||||
* in/out with Space (j/k to move, Esc to browse).
|
* in/out with Space (j/k to move, Esc to browse).
|
||||||
|
* • Episode Cache Mode — date or count bound for the episode list
|
||||||
|
* (default: date)
|
||||||
|
* • Episode Cache Count — N most recent episodes when mode is count
|
||||||
|
* (default: 25)
|
||||||
|
* • Episode Cache Days — rolling N-day window when mode is date
|
||||||
|
* (default: 60)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
import { createSignal, Show, For, onMount, onCleanup } from "solid-js";
|
||||||
@@ -30,7 +32,7 @@ import {
|
|||||||
import { on } from "@/utils/event-bus";
|
import { on } from "@/utils/event-bus";
|
||||||
import type { KeybindActionName } from "@/context/KeybindContext";
|
import type { KeybindActionName } from "@/context/KeybindContext";
|
||||||
import { TABS } from "@/utils/navigation";
|
import { TABS } from "@/utils/navigation";
|
||||||
import type { AutoDownloadScope, ThemeName } from "@/types/settings";
|
import type { AutoDownloadScope, EpisodeCacheMode, ThemeName } from "@/types/settings";
|
||||||
import type { Feed } from "@/types/feed";
|
import type { Feed } from "@/types/feed";
|
||||||
import type { SettingItem } from "./types";
|
import type { SettingItem } from "./types";
|
||||||
|
|
||||||
@@ -48,6 +50,14 @@ const SCOPE_LABELS: Array<{ value: AutoDownloadScope; label: string }> = [
|
|||||||
{ value: "none", label: "None" },
|
{ value: "none", label: "None" },
|
||||||
{ value: "whitelist", label: "Whitelist" },
|
{ value: "whitelist", label: "Whitelist" },
|
||||||
];
|
];
|
||||||
|
const CACHE_MODE_LABELS: Array<{ value: EpisodeCacheMode; label: string }> = [
|
||||||
|
{ value: "date", label: "Date" },
|
||||||
|
{ value: "count", label: "Count" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function cacheModeLabel(mode: EpisodeCacheMode): string {
|
||||||
|
return CACHE_MODE_LABELS.find((s) => s.value === mode)?.label ?? mode;
|
||||||
|
}
|
||||||
|
|
||||||
function scopeLabel(scope: AutoDownloadScope): string {
|
function scopeLabel(scope: AutoDownloadScope): string {
|
||||||
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
return SCOPE_LABELS.find((s) => s.value === scope)?.label ?? scope;
|
||||||
@@ -205,20 +215,119 @@ export function usePreferencesItems(): SettingItem[] {
|
|||||||
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
autoJumpToPlayer: !prefs().autoJumpToPlayer,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheMode",
|
||||||
|
label: "Episode Cache Mode",
|
||||||
|
kind: "select",
|
||||||
|
display: () => cacheModeLabel(prefs().episodeCacheMode),
|
||||||
|
help: () =>
|
||||||
|
`How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const idx = CACHE_MODE_LABELS.findIndex(
|
||||||
|
(s) => s.value === prefs().episodeCacheMode,
|
||||||
|
);
|
||||||
|
const next =
|
||||||
|
CACHE_MODE_LABELS[
|
||||||
|
(idx + dir + CACHE_MODE_LABELS.length) % CACHE_MODE_LABELS.length
|
||||||
|
].value;
|
||||||
|
app.updatePreferences({ episodeCacheMode: next });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheCount",
|
||||||
|
label: "Episode Cache Count",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "count"
|
||||||
|
? `${prefs().episodeCacheCount} eps`
|
||||||
|
: "(date mode)",
|
||||||
|
help: () =>
|
||||||
|
`Number of most-recent episodes to keep in the Feed/My Shows lists when mode is Count.\nType: number (any positive integer)\nDefault: 25\nCurrent: ${prefs().episodeCacheCount}\nj/k to −/+1 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.max(1, prefs().episodeCacheCount + dir);
|
||||||
|
app.updatePreferences({ episodeCacheCount: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Count"
|
||||||
|
value={() => prefs().episodeCacheCount}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheCount: Math.max(1, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodeCacheDays",
|
||||||
|
label: "Episode Cache Days",
|
||||||
|
kind: "number",
|
||||||
|
display: () =>
|
||||||
|
prefs().episodeCacheMode === "date"
|
||||||
|
? `${prefs().episodeCacheDays} days`
|
||||||
|
: "(count mode)",
|
||||||
|
help: () =>
|
||||||
|
`Rolling window in days for the Feed/My Shows episode lists when mode is Date.\nType: number (1–365)\nDefault: 60\nCurrent: ${prefs().episodeCacheDays} days\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
365,
|
||||||
|
Math.max(1, prefs().episodeCacheDays + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ episodeCacheDays: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Episode Cache Days"
|
||||||
|
value={() => prefs().episodeCacheDays}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
episodeCacheDays: Math.min(365, Math.max(1, n)),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "fetchMore",
|
id: "fetchMore",
|
||||||
label: "Fetch More",
|
label: "Fetch More",
|
||||||
kind: "select",
|
kind: "select",
|
||||||
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
display: () => (prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"),
|
||||||
help: () =>
|
help: () =>
|
||||||
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: manual\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
`How the Feed and per-show episode lists load older episodes.\nManual: a "[Fetch More]" button at the bottom of the list.\nAuto: fetches automatically when reaching the bottom.\nType: select\nDefault: auto\nCurrent: ${prefs().fetchMoreMode === "auto" ? "Auto" : "Manual"}\nCycle with j/k; Enter to apply.`,
|
||||||
cycle: (dir) => {
|
cycle: (dir) => {
|
||||||
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
const modes: Array<"manual" | "auto"> = ["manual", "auto"];
|
||||||
const idx = modes.indexOf(prefs().fetchMoreMode ?? "manual");
|
const idx = modes.indexOf(prefs().fetchMoreMode ?? "auto");
|
||||||
const next = modes[(idx + dir + modes.length) % modes.length];
|
const next = modes[(idx + dir + modes.length) % modes.length];
|
||||||
app.updatePreferences({ fetchMoreMode: next });
|
app.updatePreferences({ fetchMoreMode: next });
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "refreshInterval",
|
||||||
|
label: "Feed Refresh Interval",
|
||||||
|
kind: "number",
|
||||||
|
display: () => `${prefs().refreshIntervalMinutes} min`,
|
||||||
|
help: () =>
|
||||||
|
`How often subscribed feeds are re-fetched in the background, so new episodes appear without a restart or manual refresh (r).\nType: number (1–120 minutes)\nDefault: 30\nCurrent: ${prefs().refreshIntervalMinutes} min\nj/k to −/+5 · Enter to type a value.`,
|
||||||
|
cycle: (dir) => {
|
||||||
|
const next = Math.min(
|
||||||
|
120,
|
||||||
|
Math.max(1, prefs().refreshIntervalMinutes + dir * 5),
|
||||||
|
);
|
||||||
|
app.updatePreferences({ refreshIntervalMinutes: next });
|
||||||
|
},
|
||||||
|
renderEditor: () => (
|
||||||
|
<NumberInputEditor
|
||||||
|
label="Feed Refresh Interval (minutes)"
|
||||||
|
value={() => prefs().refreshIntervalMinutes}
|
||||||
|
commit={(n) => {
|
||||||
|
app.updatePreferences({
|
||||||
|
refreshIntervalMinutes: Math.min(120, n),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Whitelist management only appears while scope is set to "whitelist".
|
// Whitelist management only appears while scope is set to "whitelist".
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
label: "Visualizer",
|
label: "Visualizer",
|
||||||
description: "Audio visualizer: bars, sensitivity, cutoffs.",
|
description: "Audio visualizer: on/off, bars, sensitivity, cutoffs.",
|
||||||
icon: NF_ICONS.visualizer,
|
icon: NF_ICONS.visualizer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -140,7 +140,6 @@ export function SettingsPage() {
|
|||||||
function open() {
|
function open() {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 0) {
|
if (d === 0) {
|
||||||
// drill into the focused section's items
|
|
||||||
const id = focusedSection().id;
|
const id = focusedSection().id;
|
||||||
nav.pushDepth({
|
nav.pushDepth({
|
||||||
kind: `settings:${id}`,
|
kind: `settings:${id}`,
|
||||||
@@ -206,7 +205,6 @@ export function SettingsPage() {
|
|||||||
function step(delta: number) {
|
function step(delta: number) {
|
||||||
const d = depth();
|
const d = depth();
|
||||||
if (d === 2) {
|
if (d === 2) {
|
||||||
// editor: j/k nudges the value
|
|
||||||
const it = editorItem();
|
const it = editorItem();
|
||||||
if (it?.kind === "number" || it?.kind === "select")
|
if (it?.kind === "number" || it?.kind === "select")
|
||||||
it.cycle?.(delta as -1 | 1);
|
it.cycle?.(delta as -1 | 1);
|
||||||
@@ -220,7 +218,6 @@ export function SettingsPage() {
|
|||||||
pane: PaneId;
|
pane: PaneId;
|
||||||
mode: NavMode;
|
mode: NavMode;
|
||||||
}) => {
|
}) => {
|
||||||
// ignore actions meant for non-center panes
|
|
||||||
if (data.pane !== DEPTH_CENTER_PANE) return;
|
if (data.pane !== DEPTH_CENTER_PANE) return;
|
||||||
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
if (nav.activePane() !== DEPTH_CENTER_PANE) return;
|
||||||
const handler = PAGE_ACTIONS[data.action];
|
const handler = PAGE_ACTIONS[data.action];
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ export function useVisualizerItems(): SettingItem[] {
|
|||||||
const viz = () => app.state().settings.visualizer;
|
const viz = () => app.state().settings.visualizer;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
label: "Waveform",
|
||||||
|
kind: "toggle",
|
||||||
|
display: () => (viz().enabled ? "On" : "Off"),
|
||||||
|
help: () =>
|
||||||
|
`Realtime waveform visualizer in the player.\nType: toggle\nDefault: on\nCurrent: ${viz().enabled ? "on" : "off"}\nSpace/Enter to toggle.`,
|
||||||
|
toggle: () => app.updateVisualizer({ enabled: !viz().enabled }),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "bars",
|
id: "bars",
|
||||||
label: "Bars",
|
label: "Bars",
|
||||||
|
|||||||
73
src/stores/activity.ts
Normal file
73
src/stores/activity.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Activity store for PodTUI
|
||||||
|
*
|
||||||
|
* Shared leak-proof activity counter: any store can surface "something is
|
||||||
|
* loading/downloading" to the global top-right indicator. beginActivity
|
||||||
|
* returns an end token that removes exactly THAT instance, so concurrent
|
||||||
|
* overlapping activities compose correctly; prefer track() so callers
|
||||||
|
* cannot strand the counter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
|
||||||
|
function createActivityStore() {
|
||||||
|
const [count, setCount] = createSignal(0);
|
||||||
|
const [labels, setLabels] = createSignal<string[]>([]);
|
||||||
|
|
||||||
|
/** Begin a tracked activity and return its end function. Every begin
|
||||||
|
* MUST be paired with exactly one call of the returned end (via the
|
||||||
|
* token); prefer track() so the pairing is automatic. Duplicate labels
|
||||||
|
* are allowed — each end removes exactly one instance (found by
|
||||||
|
* indexOf). */
|
||||||
|
const beginActivity = (label: string): (() => void) => {
|
||||||
|
setLabels((prev) => [...prev, label]);
|
||||||
|
setCount((c) => c + 1);
|
||||||
|
let ended = false;
|
||||||
|
return () => {
|
||||||
|
if (ended) return;
|
||||||
|
ended = true;
|
||||||
|
setLabels((prev) => {
|
||||||
|
const idx = prev.indexOf(label);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(idx, 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setCount((c) => Math.max(0, c - 1));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Track a promise: begin an activity, auto-end when it settles, and
|
||||||
|
* re-throw on rejection so the caller's error handling is untouched. */
|
||||||
|
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
|
||||||
|
const end = beginActivity(label);
|
||||||
|
try {
|
||||||
|
return await p;
|
||||||
|
} finally {
|
||||||
|
end();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True while at least one activity is in flight */
|
||||||
|
const isActive = (): boolean => count() > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
count,
|
||||||
|
labels,
|
||||||
|
// Actions
|
||||||
|
beginActivity,
|
||||||
|
track,
|
||||||
|
// Getters
|
||||||
|
isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||||
|
|
||||||
|
export function useActivityStore() {
|
||||||
|
if (!activityStoreInstance) {
|
||||||
|
activityStoreInstance = createActivityStore();
|
||||||
|
}
|
||||||
|
return activityStoreInstance;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
|
enabled: true,
|
||||||
bars: 64,
|
bars: 64,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
@@ -28,6 +29,7 @@ const defaultSettings: AppSettings = {
|
|||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
|
volume: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
transparentBackground: false,
|
transparentBackground: false,
|
||||||
showSelectionMarker: false,
|
showSelectionMarker: false,
|
||||||
@@ -41,7 +43,11 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadScope: "all",
|
autoDownloadScope: "all",
|
||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -54,12 +60,14 @@ function createAppStore() {
|
|||||||
// Start with defaults; async load will update once ready
|
// Start with defaults; async load will update once ready
|
||||||
const [state, setState] = createSignal<AppState>(defaultState);
|
const [state, setState] = createSignal<AppState>(defaultState);
|
||||||
|
|
||||||
// Fire-and-forget async initialisation
|
// Fire-and-forget async initialisation; the promise is exposed via
|
||||||
|
// whenReady() so boot-time consumers (audio-level restore) can await
|
||||||
|
// the config read before reading settings.
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const loaded = await loadAppStateFromFile();
|
const loaded = await loadAppStateFromFile();
|
||||||
setState(loaded);
|
setState(loaded);
|
||||||
};
|
};
|
||||||
init();
|
const appInit = init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next);
|
saveAppStateToFile(next);
|
||||||
@@ -118,6 +126,8 @@ function createAppStore() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
|
/** Resolves once persisted settings are loaded from disk. */
|
||||||
|
whenReady: () => appInit,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
updateCustomTheme,
|
updateCustomTheme,
|
||||||
|
|||||||
@@ -9,14 +9,12 @@ import {
|
|||||||
saveAudioNavToFile,
|
saveAudioNavToFile,
|
||||||
} from "../utils/app-persistence";
|
} from "../utils/app-persistence";
|
||||||
|
|
||||||
/** Source type for audio navigation */
|
|
||||||
export enum AudioSource {
|
export enum AudioSource {
|
||||||
FEED = "feed",
|
FEED = "feed",
|
||||||
MY_SHOWS = "my_shows",
|
MY_SHOWS = "my_shows",
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Audio navigation state */
|
|
||||||
export interface AudioNavState {
|
export interface AudioNavState {
|
||||||
/** Current source type */
|
/** Current source type */
|
||||||
source: AudioSource;
|
source: AudioSource;
|
||||||
@@ -28,14 +26,12 @@ export interface AudioNavState {
|
|||||||
lastUpdated: Date;
|
lastUpdated: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default navigation state */
|
|
||||||
const defaultNavState: AudioNavState = {
|
const defaultNavState: AudioNavState = {
|
||||||
source: AudioSource.FEED,
|
source: AudioSource.FEED,
|
||||||
currentIndex: 0,
|
currentIndex: 0,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Create audio navigation store */
|
|
||||||
function createAudioNavStore() {
|
function createAudioNavStore() {
|
||||||
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
|
||||||
|
|
||||||
@@ -56,12 +52,10 @@ function createAudioNavStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Get current navigation state */
|
|
||||||
get state(): AudioNavState {
|
get state(): AudioNavState {
|
||||||
return navState();
|
return navState();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Update source type */
|
|
||||||
setSource: (source: AudioSource, podcastId?: string) => {
|
setSource: (source: AudioSource, podcastId?: string) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -72,7 +66,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to next episode */
|
|
||||||
next: (currentIndex: number) => {
|
next: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -82,7 +75,6 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Move to previous episode */
|
|
||||||
prev: (currentIndex: number) => {
|
prev: (currentIndex: number) => {
|
||||||
setNavState((prev) => ({
|
setNavState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -92,23 +84,19 @@ function createAudioNavStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Reset to default state */
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
setNavState(defaultNavState);
|
setNavState(defaultNavState);
|
||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current index */
|
|
||||||
getCurrentIndex: (): number => {
|
getCurrentIndex: (): number => {
|
||||||
return navState().currentIndex;
|
return navState().currentIndex;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current source */
|
|
||||||
getSource: (): AudioSource => {
|
getSource: (): AudioSource => {
|
||||||
return navState().source;
|
return navState().source;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get current podcast ID */
|
|
||||||
getPodcastId: (): string | undefined => {
|
getPodcastId: (): string | undefined => {
|
||||||
return navState().podcastId;
|
return navState().podcastId;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ function syncSubscriptionState(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create discover store */
|
|
||||||
export function createDiscoverStore() {
|
export function createDiscoverStore() {
|
||||||
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
const [selectedCategory, setSelectedCategory] = createSignal<string>("all");
|
||||||
const [isLoading, setIsLoading] = createSignal(false);
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
@@ -107,7 +106,6 @@ export function createDiscoverStore() {
|
|||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// Skip if cache is still fresh
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
if (now - cachedAt < FEATURED_CACHE_TTL_MS) {
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
@@ -131,7 +129,6 @@ export function createDiscoverStore() {
|
|||||||
cachedAt = now;
|
cachedAt = now;
|
||||||
setPodcasts(fetched);
|
setPodcasts(fetched);
|
||||||
|
|
||||||
// Reflect current feed-store subscriptions
|
|
||||||
syncSubscriptions();
|
syncSubscriptions();
|
||||||
} catch {
|
} catch {
|
||||||
// Network failure — keep whatever we have (stale or empty)
|
// Network failure — keep whatever we have (stale or empty)
|
||||||
@@ -140,7 +137,6 @@ export function createDiscoverStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get filtered podcasts by category */
|
|
||||||
const filteredPodcasts = () => {
|
const filteredPodcasts = () => {
|
||||||
const category = selectedCategory();
|
const category = selectedCategory();
|
||||||
if (category === "all") {
|
if (category === "all") {
|
||||||
@@ -155,7 +151,6 @@ export function createDiscoverStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Subscribe to a podcast */
|
|
||||||
const subscribe = (podcastId: string) => {
|
const subscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -168,7 +163,6 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Unsubscribe from a podcast */
|
|
||||||
const unsubscribe = (podcastId: string) => {
|
const unsubscribe = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast) {
|
if (podcast) {
|
||||||
@@ -180,7 +174,6 @@ export function createDiscoverStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle subscription */
|
|
||||||
const toggleSubscription = (podcastId: string) => {
|
const toggleSubscription = (podcastId: string) => {
|
||||||
const podcast = podcasts().find((p) => p.id === podcastId);
|
const podcast = podcasts().find((p) => p.id === podcastId);
|
||||||
if (podcast?.isSubscribed) {
|
if (podcast?.isSubscribed) {
|
||||||
@@ -207,7 +200,6 @@ export function createDiscoverStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton discover store */
|
|
||||||
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null;
|
||||||
|
|
||||||
export function useDiscoverStore() {
|
export function useDiscoverStore() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createSignal } from "solid-js";
|
|||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
import type { DownloadedEpisode } from "../types/episode";
|
import type { DownloadedEpisode } from "../types/episode";
|
||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
|
import type { Podcast } from "../types/podcast";
|
||||||
import { downloadEpisode } from "../utils/episode-downloader";
|
import { downloadEpisode } from "../utils/episode-downloader";
|
||||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
@@ -17,6 +18,24 @@ import { useFeedStore } from "./feed";
|
|||||||
const DOWNLOADS_FILE = "downloads.json";
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2;
|
const MAX_CONCURRENT = 2;
|
||||||
|
|
||||||
|
/** Prefix for synthetic feed ids of unsubscribed-show downloads (search
|
||||||
|
* downloads). The id doubles as the file subdirectory name, so it must be
|
||||||
|
* filesystem-safe. */
|
||||||
|
const UNSUBSCRIBED_FEED_PREFIX = "unsub-";
|
||||||
|
|
||||||
|
/** Deterministic synthetic feed id for a show that isn't subscribed: groups
|
||||||
|
* its search downloads together (and names their file subdirectory) without
|
||||||
|
* colliding with real feed ids (UUIDs). */
|
||||||
|
function unsubscribedFeedId(podcast: Pick<Podcast, "feedUrl" | "title">): string {
|
||||||
|
const base = podcast.feedUrl || podcast.title;
|
||||||
|
const slug = base
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 48);
|
||||||
|
return `${UNSUBSCRIBED_FEED_PREFIX}${slug || "podcast"}`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializable download record for persistence */
|
/** Serializable download record for persistence */
|
||||||
interface DownloadRecord {
|
interface DownloadRecord {
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
@@ -28,6 +47,12 @@ interface DownloadRecord {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
audioUrl: string;
|
audioUrl: string;
|
||||||
episodeTitle: string;
|
episodeTitle: string;
|
||||||
|
/** ISO publication date, for unsubscribed-show downloads. */
|
||||||
|
pubDate?: string;
|
||||||
|
/** Show title, for downloads whose show isn't subscribed. */
|
||||||
|
podcastTitle?: string;
|
||||||
|
/** The show's RSS feed URL (re-classifies the download once subscribed). */
|
||||||
|
podcastFeedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Queue item for pending downloads */
|
/** Queue item for pending downloads */
|
||||||
@@ -38,7 +63,62 @@ interface QueueItem {
|
|||||||
episodeTitle: string;
|
episodeTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create download store */
|
// ── post-download decoration ─────────────────────────────────────────────────
|
||||||
|
/** Write the podcast cover beside the audio so mpv's --cover-art-auto=exact
|
||||||
|
* picks it up for Now Playing art when the local file plays (same basename,
|
||||||
|
* .jpg extension — verified against mpv 0.41). curl, NOT fetch: Bun's fetch
|
||||||
|
* hangs in compiled binaries, so the shipped app never wrote this file. */
|
||||||
|
function writeCoverArt(filePath: string, coverUrl: string): void {
|
||||||
|
const dot = filePath.lastIndexOf(".");
|
||||||
|
if (dot <= 0) return;
|
||||||
|
const coverPath = filePath.slice(0, dot) + ".jpg";
|
||||||
|
Bun.spawn([
|
||||||
|
"curl",
|
||||||
|
"-sS",
|
||||||
|
"--fail",
|
||||||
|
"-m",
|
||||||
|
"8",
|
||||||
|
"--max-filesize",
|
||||||
|
"2097152",
|
||||||
|
"-o",
|
||||||
|
coverPath,
|
||||||
|
coverUrl,
|
||||||
|
])
|
||||||
|
.exited.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tag the local file (codec-copy, no re-encode) so mpv's Now Playing
|
||||||
|
* metadata for local playback is title=episode, artist=podcast — the source
|
||||||
|
* streams carry no usable tags and macOS composes "title - artist" from
|
||||||
|
* exactly these fields. Atomic: ffmpeg writes a temp file, then renames
|
||||||
|
* into place. */
|
||||||
|
function tagLocalFile(
|
||||||
|
filePath: string,
|
||||||
|
episode: Episode,
|
||||||
|
podcastTitle: string,
|
||||||
|
): void {
|
||||||
|
const tmp = `${filePath}.tag.mp3`;
|
||||||
|
Bun.spawn([
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
filePath,
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-metadata",
|
||||||
|
`title=${episode.title}`,
|
||||||
|
"-metadata",
|
||||||
|
`artist=${podcastTitle}`,
|
||||||
|
tmp,
|
||||||
|
])
|
||||||
|
.exited.then(async (code) => {
|
||||||
|
if (code !== 0) return;
|
||||||
|
const { renameSync } = await import("node:fs");
|
||||||
|
renameSync(tmp, filePath);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function createDownloadStore() {
|
function createDownloadStore() {
|
||||||
const [downloads, setDownloads] = createSignal<
|
const [downloads, setDownloads] = createSignal<
|
||||||
Map<string, DownloadedEpisode>
|
Map<string, DownloadedEpisode>
|
||||||
@@ -81,6 +161,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: rec.fileSize,
|
fileSize: rec.fileSize,
|
||||||
error: rec.error,
|
error: rec.error,
|
||||||
|
episodeTitle: rec.episodeTitle || undefined,
|
||||||
|
audioUrl: rec.audioUrl || undefined,
|
||||||
|
pubDate: rec.pubDate || undefined,
|
||||||
|
podcastTitle: rec.podcastTitle || undefined,
|
||||||
|
podcastFeedUrl: rec.podcastFeedUrl || undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -106,8 +191,11 @@ function createDownloadStore() {
|
|||||||
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
|
||||||
fileSize: dl.fileSize,
|
fileSize: dl.fileSize,
|
||||||
error: dl.error,
|
error: dl.error,
|
||||||
audioUrl: qItem?.audioUrl ?? "",
|
audioUrl: dl.audioUrl ?? qItem?.audioUrl ?? "",
|
||||||
episodeTitle: qItem?.episodeTitle ?? "",
|
episodeTitle: dl.episodeTitle ?? qItem?.episodeTitle ?? "",
|
||||||
|
pubDate: dl.pubDate,
|
||||||
|
podcastTitle: dl.podcastTitle,
|
||||||
|
podcastFeedUrl: dl.podcastFeedUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
const filePath = getConfigFilePath(DOWNLOADS_FILE);
|
||||||
@@ -162,7 +250,6 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execute a single download */
|
|
||||||
async function executeDownload(item: QueueItem): Promise<void> {
|
async function executeDownload(item: QueueItem): Promise<void> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortControllers.set(item.episodeId, controller);
|
abortControllers.set(item.episodeId, controller);
|
||||||
@@ -203,24 +290,23 @@ function createDownloadStore() {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Write the podcast cover beside the audio so mpv's
|
// Decorate the local file: cover art + ID3 tags (see the
|
||||||
// --cover-art-auto=exact picks it up for Now Playing art.
|
// module-level helpers above) — the source streams carry neither.
|
||||||
const coverUrl = useFeedStore()
|
// Cover falls back to the episode's own image when the feed has
|
||||||
.feeds()
|
// no channel cover (URL-added feeds).
|
||||||
.find((f) => f.id === item.feedId)?.podcast.coverUrl;
|
const feedStore = useFeedStore();
|
||||||
if (coverUrl && result.filePath) {
|
const episode = feedStore.findEpisode(item.episodeId);
|
||||||
const dot = result.filePath.lastIndexOf(".");
|
const feed = feedStore.feeds().find((f) => f.id === item.feedId);
|
||||||
if (dot > 0) {
|
const coverUrl = feed?.podcast.coverUrl ?? episode?.imageUrl;
|
||||||
const coverPath = result.filePath.slice(0, dot) + ".jpg";
|
if (result.filePath && coverUrl) {
|
||||||
fetch(coverUrl)
|
writeCoverArt(result.filePath, coverUrl);
|
||||||
.then(async (r) => {
|
}
|
||||||
if (!r.ok) return;
|
if (result.filePath && episode) {
|
||||||
await Bun.write(
|
const podcastTitle =
|
||||||
coverPath,
|
feed?.podcast.title ??
|
||||||
new Uint8Array(await r.arrayBuffer()),
|
downloads().get(item.episodeId)?.podcastTitle;
|
||||||
);
|
if (podcastTitle) {
|
||||||
})
|
tagLocalFile(result.filePath, episode, podcastTitle);
|
||||||
.catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -236,22 +322,18 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
processQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get download status for an episode */
|
|
||||||
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
const getDownloadStatus = (episodeId: string): DownloadStatus => {
|
||||||
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get download progress for an episode (0-100) */
|
|
||||||
const getDownloadProgress = (episodeId: string): number => {
|
const getDownloadProgress = (episodeId: string): number => {
|
||||||
return downloads().get(episodeId)?.progress ?? 0;
|
return downloads().get(episodeId)?.progress ?? 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get full download info for an episode */
|
|
||||||
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
|
||||||
return downloads().get(episodeId);
|
return downloads().get(episodeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the local file path for a completed download */
|
|
||||||
const getDownloadedFilePath = (episodeId: string): string | null => {
|
const getDownloadedFilePath = (episodeId: string): string | null => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
|
||||||
@@ -260,8 +342,19 @@ function createDownloadStore() {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Start downloading an episode */
|
/** Optional metadata for a download whose show isn't subscribed (search
|
||||||
const startDownload = (episode: Episode, feedId: string): void => {
|
* downloads) — without it the record cannot render a title or be
|
||||||
|
* re-classified once the show is subscribed. */
|
||||||
|
interface UnsubscribedMeta {
|
||||||
|
podcastTitle: string;
|
||||||
|
podcastFeedUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDownload = (
|
||||||
|
episode: Episode,
|
||||||
|
feedId: string,
|
||||||
|
meta?: UnsubscribedMeta,
|
||||||
|
): void => {
|
||||||
const existing = downloads().get(episode.id);
|
const existing = downloads().get(episode.id);
|
||||||
if (
|
if (
|
||||||
existing?.status === DownloadStatus.DOWNLOADING ||
|
existing?.status === DownloadStatus.DOWNLOADING ||
|
||||||
@@ -280,6 +373,11 @@ function createDownloadStore() {
|
|||||||
speed: 0,
|
speed: 0,
|
||||||
fileSize: episode.fileSize ?? 0,
|
fileSize: episode.fileSize ?? 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
episodeTitle: episode.title,
|
||||||
|
audioUrl: episode.audioUrl,
|
||||||
|
pubDate: episode.pubDate.toISOString(),
|
||||||
|
podcastTitle: meta?.podcastTitle,
|
||||||
|
podcastFeedUrl: meta?.podcastFeedUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
setDownloads((prev) => {
|
setDownloads((prev) => {
|
||||||
@@ -300,7 +398,21 @@ function createDownloadStore() {
|
|||||||
processQueue();
|
processQueue();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Cancel a download */
|
/** Start downloading an episode of a show that is NOT subscribed. The
|
||||||
|
* download gets a deterministic synthetic feed id (also its file
|
||||||
|
* subdirectory) plus the show's metadata so it can render under
|
||||||
|
* "Unsubscribed Show Downloads" and re-classify if the user later
|
||||||
|
* subscribes to the show. */
|
||||||
|
const startUnsubscribedDownload = (
|
||||||
|
episode: Episode,
|
||||||
|
podcast: Podcast,
|
||||||
|
): void => {
|
||||||
|
startDownload(episode, unsubscribedFeedId(podcast), {
|
||||||
|
podcastTitle: podcast.title,
|
||||||
|
podcastFeedUrl: podcast.feedUrl || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const cancelDownload = (episodeId: string): void => {
|
const cancelDownload = (episodeId: string): void => {
|
||||||
// Abort active download
|
// Abort active download
|
||||||
const controller = abortControllers.get(episodeId);
|
const controller = abortControllers.get(episodeId);
|
||||||
@@ -321,7 +433,6 @@ function createDownloadStore() {
|
|||||||
saveDownloads().catch(() => {});
|
saveDownloads().catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a completed download (delete file and metadata) */
|
|
||||||
const removeDownload = async (episodeId: string): Promise<void> => {
|
const removeDownload = async (episodeId: string): Promise<void> => {
|
||||||
const dl = downloads().get(episodeId);
|
const dl = downloads().get(episodeId);
|
||||||
if (dl?.filePath) {
|
if (dl?.filePath) {
|
||||||
@@ -348,10 +459,18 @@ function createDownloadStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Remove every download (active/queued/completed) belonging to a feed —
|
/** Remove every download (active/queued/completed) belonging to a feed —
|
||||||
* abort in-flight transfers, drop queued items, delete files + metadata. */
|
* abort in-flight transfers, drop queued items, delete files + metadata.
|
||||||
const removeDownloadsForFeed = async (feedId: string): Promise<void> => {
|
* Also removes downloads of the same show made while it was unsubscribed
|
||||||
|
* (matched by podcastFeedUrl) so unsubscribing purges search downloads
|
||||||
|
* of that show too. */
|
||||||
|
const removeDownloadsForFeed = async (
|
||||||
|
feedId: string,
|
||||||
|
podcastFeedUrl?: string,
|
||||||
|
): Promise<void> => {
|
||||||
const eps = Array.from(downloads().values()).filter(
|
const eps = Array.from(downloads().values()).filter(
|
||||||
(d) => d.feedId === feedId,
|
(d) =>
|
||||||
|
d.feedId === feedId ||
|
||||||
|
(podcastFeedUrl && d.podcastFeedUrl === podcastFeedUrl),
|
||||||
);
|
);
|
||||||
for (const d of eps) {
|
for (const d of eps) {
|
||||||
cancelDownload(d.episodeId);
|
cancelDownload(d.episodeId);
|
||||||
@@ -359,17 +478,32 @@ function createDownloadStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get all downloads as an array */
|
|
||||||
const getAllDownloads = (): DownloadedEpisode[] => {
|
const getAllDownloads = (): DownloadedEpisode[] => {
|
||||||
return Array.from(downloads().values());
|
return Array.from(downloads().values());
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get the current queue */
|
/** Downloads whose show is not subscribed — the "Unsubscribed Show
|
||||||
|
* Downloads" list shown in My Shows and the settings download manager.
|
||||||
|
* Reads feeds() so the list re-classifies (drops out) the moment the
|
||||||
|
* user subscribes to the show. Matched by feed id, or by the show's
|
||||||
|
* feed URL (covers downloads made before the show was subscribed). */
|
||||||
|
const getUnsubscribedDownloads = (): DownloadedEpisode[] => {
|
||||||
|
const feeds = useFeedStore().feeds();
|
||||||
|
return Array.from(downloads().values()).filter((d) => {
|
||||||
|
if (feeds.some((f) => f.id === d.feedId)) return false;
|
||||||
|
if (d.podcastFeedUrl) {
|
||||||
|
return !feeds.some(
|
||||||
|
(f) => f.podcast.feedUrl === d.podcastFeedUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getQueue = (): QueueItem[] => {
|
const getQueue = (): QueueItem[] => {
|
||||||
return queue();
|
return queue();
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get count of active downloads */
|
|
||||||
const getActiveCount = (): number => {
|
const getActiveCount = (): number => {
|
||||||
return activeCount();
|
return activeCount();
|
||||||
};
|
};
|
||||||
@@ -381,18 +515,19 @@ function createDownloadStore() {
|
|||||||
getDownload,
|
getDownload,
|
||||||
getDownloadedFilePath,
|
getDownloadedFilePath,
|
||||||
getAllDownloads,
|
getAllDownloads,
|
||||||
|
getUnsubscribedDownloads,
|
||||||
getQueue,
|
getQueue,
|
||||||
getActiveCount,
|
getActiveCount,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
startDownload,
|
startDownload,
|
||||||
|
startUnsubscribedDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
removeDownload,
|
removeDownload,
|
||||||
removeDownloadsForFeed,
|
removeDownloadsForFeed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton download store */
|
|
||||||
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null;
|
||||||
|
|
||||||
export function useDownloadStore() {
|
export function useDownloadStore() {
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ import type { Podcast } from "../types/podcast";
|
|||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
import { DEFAULT_SOURCES } from "../types/source";
|
import { DEFAULT_SOURCES } from "../types/source";
|
||||||
import { parseRSSFeed } from "../api/rss-parser";
|
import { getRSSItems, parseRSSItem, parseChannelCoverUrl } from "../api/rss-parser";
|
||||||
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
import { resolveItunesFeedUrl } from "../utils/itunes-feed-resolver";
|
||||||
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
import { savePodcastIndexCredentials } from "../utils/source-credentials";
|
||||||
|
import { mergeEpisodesBounded } from "../utils/episode-merge";
|
||||||
import {
|
import {
|
||||||
|
episodeInWindow,
|
||||||
loadFeedsFromFile,
|
loadFeedsFromFile,
|
||||||
saveFeedsToFile,
|
saveFeedsToFile,
|
||||||
loadSourcesFromFile,
|
loadSourcesFromFile,
|
||||||
saveSourcesToFile,
|
saveSourcesToFile,
|
||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
|
import { useActivityStore } from "./activity";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { useAppStore } from "./app";
|
import { useAppStore } from "./app";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
@@ -29,15 +32,109 @@ const MAX_EPISODES_REFRESH = 50;
|
|||||||
/** Max episodes to fetch on initial subscribe */
|
/** Max episodes to fetch on initial subscribe */
|
||||||
const MAX_EPISODES_SUBSCRIBE = 20;
|
const MAX_EPISODES_SUBSCRIBE = 20;
|
||||||
|
|
||||||
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
|
/** Per-feed fetch timeout — a hung feed must not stall a refresh batch or
|
||||||
|
* the background refresh loop. */
|
||||||
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
/** Bounds simultaneous RSS requests during a refresh batch — a hung feed
|
||||||
|
* burns at most one slot for FETCH_TIMEOUT_MS instead of pinning the whole
|
||||||
|
* batch. */
|
||||||
|
const FETCH_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
/** Default minutes between automatic background feed refreshes. */
|
||||||
|
const DEFAULT_REFRESH_INTERVAL_MINUTES = 30;
|
||||||
|
|
||||||
|
/** Max episodes parsed per chunk before yielding to the event loop — bounds
|
||||||
|
* the synchronous regex work per frame so one huge feed (or a batch of
|
||||||
|
* feeds) can't stall the renderer. */
|
||||||
|
const PARSE_CHUNK_SIZE = 5;
|
||||||
|
|
||||||
|
/** Hard ceiling on the in-memory full-parse cache per feed (newest first).
|
||||||
|
* The cache exists so fetch-more can page deeper without a refetch; without
|
||||||
|
* a ceiling a 5,000-episode archive pins tens of MB of Episode objects in
|
||||||
|
* RAM for the whole session (the old cache held EVERY parsed episode of
|
||||||
|
* every feed, contributing hundreds of MB for archive-heavy
|
||||||
|
* subscriptions). 1000 covers any realistic show's entire history —
|
||||||
|
* beyond it, hasMoreEpisodes flips false and the visible list is bounded
|
||||||
|
* by the user's cache preference as usual. */
|
||||||
|
const MAX_CACHED_EPISODES_PER_FEED = 1000;
|
||||||
|
|
||||||
|
/** Yield to the event loop (task queue) so the renderer can paint between
|
||||||
|
* parse chunks. MessageChannel instead of setTimeout/setImmediate because
|
||||||
|
* bun:test fake timers trap those (feed-refresh/pagination tests run under
|
||||||
|
* vi.useFakeTimers and await refreshes, so a trapped yield would deadlock
|
||||||
|
* them); MessageChannel posts are real task-queue turns that fire in both
|
||||||
|
* environments. */
|
||||||
|
const yieldToUI = (): Promise<void> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const { port1, port2 } = new MessageChannel();
|
||||||
|
port1.onmessage = () => {
|
||||||
|
port1.close();
|
||||||
|
port2.close();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
port2.postMessage(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Parse all episodes from feed XML in bounded chunks, yielding to the event
|
||||||
|
* loop between chunks. The whole-feed sync `parseRSSFeed` would otherwise
|
||||||
|
* block the UI thread for the combined parse time of every feed in a
|
||||||
|
* refresh batch. */
|
||||||
|
const parseEpisodesIncremental = async (
|
||||||
|
xml: string,
|
||||||
|
feedUrl: string,
|
||||||
|
): Promise<Episode[]> => {
|
||||||
|
const items = getRSSItems(xml);
|
||||||
|
// Yield after the item-extraction regex (which scans the full XML
|
||||||
|
// synchronously) so the renderer paints before the first parse chunk.
|
||||||
|
await yieldToUI();
|
||||||
|
const episodes: Episode[] = new Array(items.length);
|
||||||
|
for (let start = 0; start < items.length; start += PARSE_CHUNK_SIZE) {
|
||||||
|
const end = Math.min(start + PARSE_CHUNK_SIZE, items.length);
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
episodes[i] = parseRSSItem(items[i], feedUrl, i);
|
||||||
|
}
|
||||||
|
if (end < items.length) await yieldToUI();
|
||||||
|
}
|
||||||
|
return episodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Cache of ALL parsed episodes per feed (feedId -> Episode[]). Holds the
|
||||||
|
* full parse — the bound (count or date) is applied when reading, not when
|
||||||
|
* writing, so changing the preference takes effect without a refetch.
|
||||||
|
* Fetch-more reads beyond the bound from this cache (volatile only — the
|
||||||
|
* cache itself is never extended by fetch-more). */
|
||||||
const fullEpisodeCache = new Map<string, Episode[]>();
|
const fullEpisodeCache = new Map<string, Episode[]>();
|
||||||
|
|
||||||
/** Track how many episodes are currently loaded per feed */
|
/** Track how many episodes are currently loaded (visible) per feed. The
|
||||||
|
* loaded window grows via fetch-more but never exceeds what the cache
|
||||||
|
* holds — when it reaches the cache length, hasMoreEpisodes flips false. */
|
||||||
const episodeLoadCount = new Map<string, number>();
|
const episodeLoadCount = new Map<string, number>();
|
||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Read the episode cache bound from preferences: a closure that decides
|
||||||
|
* whether the episode at `index` (0 = newest, after sort) is kept. */
|
||||||
|
function episodeKeepFn(prefs: {
|
||||||
|
episodeCacheMode: "date" | "count";
|
||||||
|
episodeCacheCount: number;
|
||||||
|
episodeCacheDays: number;
|
||||||
|
}): (ep: Episode, index: number) => boolean {
|
||||||
|
const now = new Date();
|
||||||
|
if (prefs.episodeCacheMode === "count") {
|
||||||
|
const count = Math.max(1, prefs.episodeCacheCount);
|
||||||
|
return (_ep: Episode, index: number) => index < count;
|
||||||
|
}
|
||||||
|
const days = Math.max(1, prefs.episodeCacheDays);
|
||||||
|
return (ep: Episode) => episodeInWindow(ep, now, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save feeds to file (async, fire-and-forget). */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds);
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const days =
|
||||||
|
prefs.episodeCacheMode === "date"
|
||||||
|
? Math.max(1, prefs.episodeCacheDays)
|
||||||
|
: undefined;
|
||||||
|
saveFeedsToFile(feeds, days);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
@@ -89,18 +186,44 @@ async function migratePlaintextCredentials(
|
|||||||
return changed ? migrated : sources;
|
return changed ? migrated : sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when two episode lists hold the same episodes (id-set equality,
|
/** True when the freshly fetched window matches the corresponding PREFIX of
|
||||||
* order-insensitive). Refreshes compare fetched content against this so an
|
* the existing episode list (id-set equality, order-insensitive). With
|
||||||
* unchanged feed keeps its `lastUpdated` — and therefore its place in the
|
* union semantics the merged list legitimately contains episodes BEYOND the
|
||||||
* "updated" sort — instead of reordering the list on every background
|
* fetched window, so unchanged-detection must compare the fetched window
|
||||||
* refresh. */
|
* against the existing list's prefix — comparing full lists would bump
|
||||||
function sameEpisodes(a: Episode[], b: Episode[]): boolean {
|
* `lastUpdated` on every refresh. */
|
||||||
if (a.length !== b.length) return false;
|
function sameRefreshWindow(existing: Episode[], fetched: Episode[]): boolean {
|
||||||
const ids = new Set(a.map((e) => e.id));
|
if (fetched.length === 0) return true;
|
||||||
return b.every((e) => ids.has(e.id));
|
const prefix = existing.slice(0, fetched.length);
|
||||||
|
const ids = new Set(prefix.map((e) => e.id));
|
||||||
|
return fetched.every((e) => ids.has(e.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run `fn` over every item with at most `limit` executions in flight — a
|
||||||
|
* classic worker pool. Workers pull indexes from a shared counter, so the
|
||||||
|
* first `limit` calls start immediately and each completion frees its slot
|
||||||
|
* for the next item; results are assembled in INPUT order regardless of
|
||||||
|
* completion order. A hung `fn` holds at most one slot. */
|
||||||
|
async function mapWithConcurrency<T, R>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<R>,
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let nextIndex = 0;
|
||||||
|
const workers = Array.from(
|
||||||
|
{ length: Math.min(limit, items.length) },
|
||||||
|
async () => {
|
||||||
|
let i: number;
|
||||||
|
while ((i = nextIndex++) < items.length) {
|
||||||
|
results[i] = await fn(items[i]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
|
||||||
function createFeedStore() {
|
function createFeedStore() {
|
||||||
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
const [feeds, setFeeds] = createSignal<Feed[]>([]);
|
||||||
const [sources, setSources] = createSignal<PodcastSource[]>([
|
const [sources, setSources] = createSignal<PodcastSource[]>([
|
||||||
@@ -115,7 +238,39 @@ function createFeedStore() {
|
|||||||
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
|
||||||
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
|
||||||
|
|
||||||
/** Get filtered and sorted feeds */
|
// ── Debounced persistence ───────────────────────────────────────────────
|
||||||
|
/** Trailing-edge debounce window for config.json writes. */
|
||||||
|
const SAVE_DEBOUNCE_MS = 250;
|
||||||
|
/** True when a save is scheduled but has not flushed yet. */
|
||||||
|
let savePending = false;
|
||||||
|
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Schedule a config.json write (trailing edge) — rapid state changes
|
||||||
|
* (a refresh batch landing feed-by-feed, pin toggles, load-more pages)
|
||||||
|
* collapse into one final write instead of one file rewrite per step. */
|
||||||
|
const scheduleSaveFeeds = (): void => {
|
||||||
|
savePending = true;
|
||||||
|
if (pendingSaveTimer) clearTimeout(pendingSaveTimer);
|
||||||
|
pendingSaveTimer = setTimeout(() => {
|
||||||
|
pendingSaveTimer = null;
|
||||||
|
flushPendingSave();
|
||||||
|
}, SAVE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Persist immediately when anything is dirty; exported for tests and
|
||||||
|
* quit hooks. Cancels a pending debounced save — the state it would
|
||||||
|
* have written is already reflected in feeds(), so writing now is
|
||||||
|
* strictly more current. */
|
||||||
|
const flushPendingSave = (): void => {
|
||||||
|
if (pendingSaveTimer) {
|
||||||
|
clearTimeout(pendingSaveTimer);
|
||||||
|
pendingSaveTimer = null;
|
||||||
|
}
|
||||||
|
if (!savePending) return;
|
||||||
|
savePending = false;
|
||||||
|
saveFeeds(feeds());
|
||||||
|
};
|
||||||
|
|
||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
@@ -173,7 +328,6 @@ function createFeedStore() {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get episodes in reverse chronological order across all feeds */
|
|
||||||
const getAllEpisodesChronological = (): Array<{
|
const getAllEpisodesChronological = (): Array<{
|
||||||
episode: Episode;
|
episode: Episode;
|
||||||
feed: Feed;
|
feed: Feed;
|
||||||
@@ -194,44 +348,80 @@ function createFeedStore() {
|
|||||||
return allEpisodes;
|
return allEpisodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sort episodes in reverse chronological order (newest first) */
|
|
||||||
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
|
||||||
return [...episodes].sort(
|
return [...episodes].sort(
|
||||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
|
/** Fetch latest episodes from an RSS feed URL, caching ALL parsed
|
||||||
|
* episodes in fullEpisodeCache. The visible episodes returned are
|
||||||
|
* bounded by the user's cache preference (count or date); the full
|
||||||
|
* cache survives so fetch-more can page beyond the bound without a
|
||||||
|
* refetch (volatile only — the cache is never extended by fetch-more).
|
||||||
|
* Returns NULL episodes on any failure — a failed fetch must not look
|
||||||
|
* like an empty feed, or the store would wipe a subscribed show's
|
||||||
|
* episodes. Also returns the channel-level artwork so callers can
|
||||||
|
* backfill a feed's coverUrl (subscribe + refresh). */
|
||||||
const fetchEpisodes = async (
|
const fetchEpisodes = async (
|
||||||
feedUrl: string,
|
feedUrl: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
feedId?: string,
|
feedId?: string,
|
||||||
): Promise<Episode[]> => {
|
): Promise<{ episodes: Episode[] | null; coverUrl: string | undefined }> => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(feedUrl, {
|
const response = await fetch(feedUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
"Accept-Encoding": "identity",
|
"Accept-Encoding": "identity",
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
},
|
},
|
||||||
|
// Hung feeds must not stall a refresh batch (or the
|
||||||
|
// background refresh loop) indefinitely.
|
||||||
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!response.ok) return [];
|
if (!response.ok) return { episodes: null, coverUrl: undefined };
|
||||||
const xml = await response.text();
|
const xml = await response.text();
|
||||||
const parsed = parseRSSFeed(xml, feedUrl);
|
// Yield after the network read so the renderer gets a turn
|
||||||
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
|
// before the sync regex + parse work begins.
|
||||||
|
await yieldToUI();
|
||||||
|
const allEpisodes = sortEpisodesReverseChronological(
|
||||||
|
await parseEpisodesIncremental(xml, feedUrl),
|
||||||
|
);
|
||||||
|
|
||||||
// Cache all parsed episodes for pagination
|
|
||||||
if (feedId) {
|
if (feedId) {
|
||||||
fullEpisodeCache.set(feedId, allEpisodes);
|
// Cache the FULL parse — the bound is applied when reading,
|
||||||
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
|
// not when writing, so a preference change takes effect
|
||||||
|
// without a refetch. Capped at MAX_CACHED_EPISODES_PER_FEED
|
||||||
|
// so an archive-heavy feed can't pin its entire history in
|
||||||
|
// RAM for the session (the visible window below is bounded
|
||||||
|
// by the user's preference regardless).
|
||||||
|
fullEpisodeCache.set(
|
||||||
|
feedId,
|
||||||
|
allEpisodes.slice(0, MAX_CACHED_EPISODES_PER_FEED),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return allEpisodes.slice(0, limit);
|
// Bound the visible window by the user's cache preference.
|
||||||
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const keep = episodeKeepFn(prefs);
|
||||||
|
const bounded = allEpisodes.filter((ep, i) => keep(ep, i));
|
||||||
|
const visible = bounded.slice(0, limit);
|
||||||
|
|
||||||
|
if (feedId) {
|
||||||
|
// Track how many episodes are visible — the bounded window,
|
||||||
|
// not the full parse. hasMoreEpisodes compares this to the
|
||||||
|
// full cache length to decide if fetch-more can page deeper.
|
||||||
|
episodeLoadCount.set(feedId, visible.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
episodes: visible,
|
||||||
|
coverUrl: parseChannelCoverUrl(xml),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return { episodes: null, coverUrl: undefined };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Check if a feed with this URL already exists */
|
|
||||||
const hasFeedByUrl = (feedUrl: string): boolean => {
|
const hasFeedByUrl = (feedUrl: string): boolean => {
|
||||||
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
return feeds().some((f) => f.podcast.feedUrl === feedUrl);
|
||||||
};
|
};
|
||||||
@@ -242,45 +432,54 @@ function createFeedStore() {
|
|||||||
sourceId: string,
|
sourceId: string,
|
||||||
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
visibility: FeedVisibility = FeedVisibility.PUBLIC,
|
||||||
): Promise<Feed | null> => {
|
): Promise<Feed | null> => {
|
||||||
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
|
const activity = useActivityStore();
|
||||||
// feed URL; resolve the real feed from its directory page before
|
// The "Subscribing" label covers the directory-resolve + subscribe
|
||||||
// subscribing. Refuse when it can't be resolved rather than adding a
|
// fetch stretch — the gaps no existing signal (isLoadingFeeds,
|
||||||
// broken feed.
|
// per-pane spinners) covers.
|
||||||
if (!podcast.feedUrl) {
|
return activity.track((async () => {
|
||||||
if (!podcast.directoryUrl) return null;
|
// A directory stub (e.g. a show delisted from Apple Podcasts) has no
|
||||||
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
|
// feed URL; resolve the real feed from its directory page before
|
||||||
if (!resolved) return null;
|
// subscribing. Refuse when it can't be resolved rather than adding a
|
||||||
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
|
// broken feed.
|
||||||
}
|
if (!podcast.feedUrl) {
|
||||||
|
if (!podcast.directoryUrl) return null;
|
||||||
|
const resolved = await resolveItunesFeedUrl(podcast.directoryUrl);
|
||||||
|
if (!resolved) return null;
|
||||||
|
podcast = { ...podcast, feedUrl: resolved, directoryUrl: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: don't add a feed we already have (matched by feedUrl)
|
// Guard: don't add a feed we already have (matched by feedUrl)
|
||||||
if (hasFeedByUrl(podcast.feedUrl)) {
|
if (hasFeedByUrl(podcast.feedUrl)) {
|
||||||
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
return feeds().find((f) => f.podcast.feedUrl === podcast.feedUrl) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedId = crypto.randomUUID();
|
const feedId = crypto.randomUUID();
|
||||||
const episodes = await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
podcast.feedUrl,
|
podcast.feedUrl,
|
||||||
MAX_EPISODES_SUBSCRIBE,
|
MAX_EPISODES_SUBSCRIBE,
|
||||||
feedId,
|
feedId,
|
||||||
);
|
);
|
||||||
const newFeed: Feed = {
|
if (!podcast.coverUrl && coverUrl) {
|
||||||
id: feedId,
|
podcast = { ...podcast, coverUrl };
|
||||||
podcast,
|
}
|
||||||
episodes,
|
const newFeed: Feed = {
|
||||||
visibility,
|
id: feedId,
|
||||||
sourceId,
|
podcast,
|
||||||
lastUpdated: new Date(),
|
episodes: episodes ?? [],
|
||||||
isPinned: false,
|
visibility,
|
||||||
};
|
sourceId,
|
||||||
setFeeds((prev) => {
|
lastUpdated: new Date(),
|
||||||
const updated = [...prev, newFeed];
|
isPinned: false,
|
||||||
saveFeeds(updated);
|
};
|
||||||
return updated;
|
setFeeds((prev) => {
|
||||||
});
|
const updated = [...prev, newFeed];
|
||||||
// Global auto-download: newly subscribed shows join the next pass.
|
scheduleSaveFeeds();
|
||||||
runAutoDownload();
|
return updated;
|
||||||
return newFeed;
|
});
|
||||||
|
// Global auto-download: newly subscribed shows join the next pass.
|
||||||
|
runAutoDownload();
|
||||||
|
return newFeed;
|
||||||
|
})(), "Subscribing");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Download the N most recent episodes of every in-scope show, per the
|
/** Download the N most recent episodes of every in-scope show, per the
|
||||||
@@ -318,82 +517,123 @@ function createFeedStore() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
/** Apply a freshly fetched episode list to one feed, bumping `lastUpdated`
|
||||||
* only when the content actually changed (see sameEpisodes). Returns the
|
* only when the content actually changed (see sameRefreshWindow). The
|
||||||
* ORIGINAL array reference when nothing changed so callers skip
|
* fetched window is MERGED into the existing episodes (fetched copy wins
|
||||||
* persistence entirely — a refresh that fetched identical episodes must
|
* on id collision) so a refresh never shrinks the in-memory list; the
|
||||||
* not re-sort the "updated" view. */
|
* union is pruned by the user's cache bound (count or date) so episodes
|
||||||
|
* outside the bound fall out of the visible list on the next refresh.
|
||||||
|
* Returns the ORIGINAL array reference when nothing changed so callers
|
||||||
|
* skip persistence entirely — a refresh that fetched identical episodes
|
||||||
|
* must not re-sort the "updated" view. */
|
||||||
const applyRefreshedEpisodes = (
|
const applyRefreshedEpisodes = (
|
||||||
prev: Feed[],
|
prev: Feed[],
|
||||||
feedId: string,
|
feedId: string,
|
||||||
episodes: Episode[],
|
episodes: Episode[],
|
||||||
): Feed[] => {
|
): Feed[] => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
const prefs = useAppStore().state().preferences;
|
||||||
|
const keep = episodeKeepFn(prefs);
|
||||||
const updated = prev.map((f) => {
|
const updated = prev.map((f) => {
|
||||||
if (f.id !== feedId) return f;
|
if (f.id !== feedId) return f;
|
||||||
if (sameEpisodes(f.episodes, episodes)) return f;
|
const merged = mergeEpisodesBounded(f.episodes, episodes, keep);
|
||||||
|
if (sameRefreshWindow(f.episodes, episodes)) return f;
|
||||||
changed = true;
|
changed = true;
|
||||||
return { ...f, episodes, lastUpdated: new Date() };
|
return { ...f, episodes: merged, lastUpdated: new Date() };
|
||||||
});
|
});
|
||||||
return changed ? updated : prev;
|
return changed ? updated : prev;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh a single feed - re-fetch latest 50 episodes */
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
const refreshFeed = async (feedId: string) => {
|
const refreshFeed = async (feedId: string) => {
|
||||||
const feed = getFeed(feedId);
|
const activity = useActivityStore();
|
||||||
if (!feed) return;
|
return activity.track((async () => {
|
||||||
const episodes = await fetchEpisodes(
|
const feed = getFeed(feedId);
|
||||||
feed.podcast.feedUrl,
|
if (!feed) return;
|
||||||
MAX_EPISODES_REFRESH,
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feedId,
|
feed.podcast.feedUrl,
|
||||||
);
|
MAX_EPISODES_REFRESH,
|
||||||
setFeeds((prev) => {
|
feedId,
|
||||||
const updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
);
|
||||||
if (updated !== prev) saveFeeds(updated);
|
// Fetch failed (null): keep the currently loaded episodes untouched.
|
||||||
return updated;
|
if (!episodes) return;
|
||||||
});
|
setFeeds((prev) => {
|
||||||
|
let updated = applyRefreshedEpisodes(prev, feedId, episodes);
|
||||||
|
if (coverUrl) {
|
||||||
|
updated = updated.map((f) =>
|
||||||
|
f.id === feedId && !f.podcast.coverUrl && coverUrl
|
||||||
|
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||||
|
: f,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
// Global auto-download: ensure the N most recent episodes of in-scope
|
// Global auto-download: ensure the N most recent episodes of in-scope
|
||||||
// shows are available offline after every refresh (idempotent).
|
// shows are available offline after every refresh (idempotent).
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
|
})(), "Refreshing");
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Refresh all feeds — fetch every feed in parallel, then apply ONE
|
/** Refresh all feeds — bounded concurrency (at most FETCH_CONCURRENCY
|
||||||
* atomic update. Per-feed incremental setFeeds re-sorted the list once
|
* in-flight requests), and each feed's refreshed episodes are applied
|
||||||
* per completion (each refresh bumped lastUpdated and the "updated" sort
|
* AS ITS OWN FETCH LANDS (no Promise.all barrier). Per-feed apply is
|
||||||
* re-ran), which showed up as the list order flapping until the batch
|
* safe because applyRefreshedEpisodes keeps unchanged feeds' object
|
||||||
* finished. */
|
* identity and lastUpdated (union merge), so each feed's refreshed
|
||||||
|
* episodes render as its own fetch resolves — the order flapping the
|
||||||
|
* old atomic barrier existed to hide can no longer happen. */
|
||||||
const refreshAllFeeds = async () => {
|
const refreshAllFeeds = async () => {
|
||||||
setIsLoadingFeeds(true);
|
setIsLoadingFeeds(true);
|
||||||
try {
|
try {
|
||||||
const currentFeeds = feeds();
|
await mapWithConcurrency(
|
||||||
const results = await Promise.all(
|
feeds(),
|
||||||
currentFeeds.map(async (feed) => [
|
FETCH_CONCURRENCY,
|
||||||
feed.id,
|
async (feed) => {
|
||||||
await fetchEpisodes(
|
const { episodes, coverUrl } = await fetchEpisodes(
|
||||||
feed.podcast.feedUrl,
|
feed.podcast.feedUrl,
|
||||||
MAX_EPISODES_REFRESH,
|
MAX_EPISODES_REFRESH,
|
||||||
feed.id,
|
feed.id,
|
||||||
),
|
);
|
||||||
] as const),
|
// A failed fetch (null) leaves that feed untouched.
|
||||||
|
if (!episodes) return;
|
||||||
|
setFeeds((prev) => {
|
||||||
|
let updated = applyRefreshedEpisodes(prev, feed.id, episodes);
|
||||||
|
if (coverUrl) {
|
||||||
|
updated = updated.map((f) =>
|
||||||
|
f.id === feed.id && !f.podcast.coverUrl && coverUrl
|
||||||
|
? { ...f, podcast: { ...f.podcast, coverUrl } }
|
||||||
|
: f,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (updated !== prev) scheduleSaveFeeds();
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
);
|
);
|
||||||
setFeeds((prev) => {
|
|
||||||
let updated = prev;
|
|
||||||
for (const [feedId, episodes] of results) {
|
|
||||||
updated = applyRefreshedEpisodes(updated, feedId, episodes);
|
|
||||||
}
|
|
||||||
if (updated !== prev) saveFeeds(updated);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
// Global auto-download: one idempotent pass after the batch.
|
// Global auto-download: one idempotent pass after the batch.
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
|
// A refresh batch always ends with a persisted write when
|
||||||
|
// anything changed — never leave the debounce's trailing edge
|
||||||
|
// pending across a process exit.
|
||||||
|
flushPendingSave();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingFeeds(false);
|
setIsLoadingFeeds(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolves once the persisted feeds are loaded and visible to feeds() —
|
||||||
|
// before the background refresh so boot-time consumers (player-session
|
||||||
|
// restore) don't wait on the network.
|
||||||
|
const { promise: feedsReady, resolve: resolveFeedsReady } =
|
||||||
|
Promise.withResolvers<void>();
|
||||||
(async () => {
|
(async () => {
|
||||||
const loadedFeeds = await loadFeedsFromFile();
|
const loadedFeeds = await loadFeedsFromFile(
|
||||||
|
useAppStore().state().preferences.episodeCacheMode === "date"
|
||||||
|
? Math.max(1, useAppStore().state().preferences.episodeCacheDays)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
|
||||||
|
resolveFeedsReady();
|
||||||
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
const loadedSources = await loadSourcesFromFile<PodcastSource>();
|
||||||
// The default "rss" placeholder source fabricated fake search results
|
// The default "rss" placeholder source fabricated fake search results
|
||||||
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
// and was removed from DEFAULT_SOURCES; drop it from persisted configs
|
||||||
@@ -422,13 +662,39 @@ function createFeedStore() {
|
|||||||
await refreshAllFeeds();
|
await refreshAllFeeds();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/** Remove a feed */
|
// ── Background refresh ──────────────────────────────────────────────────
|
||||||
|
// New episodes only reach the app while it runs if feeds are re-fetched
|
||||||
|
// on a schedule: startup and manual `r` alone leave a subscribed show's
|
||||||
|
// latest episode invisible until the user restarts (or presses r). A
|
||||||
|
// self-rescheduling timer re-reads the interval preference on every tick
|
||||||
|
// so a settings change takes effect without a restart, and skips a tick
|
||||||
|
// that would overlap an in-flight refresh (manual or background).
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const scheduleNextRefresh = () => {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer);
|
||||||
|
const minutes = Math.max(
|
||||||
|
1,
|
||||||
|
useAppStore().state().preferences.refreshIntervalMinutes ??
|
||||||
|
DEFAULT_REFRESH_INTERVAL_MINUTES,
|
||||||
|
);
|
||||||
|
refreshTimer = setTimeout(() => {
|
||||||
|
if (!isLoadingFeeds()) {
|
||||||
|
refreshAllFeeds().catch(() => {});
|
||||||
|
}
|
||||||
|
scheduleNextRefresh();
|
||||||
|
}, minutes * 60_000);
|
||||||
|
};
|
||||||
|
scheduleNextRefresh();
|
||||||
|
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
fullEpisodeCache.delete(feedId);
|
fullEpisodeCache.delete(feedId);
|
||||||
episodeLoadCount.delete(feedId);
|
episodeLoadCount.delete(feedId);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.id !== feedId);
|
const updated = prev.filter((f) => f.id !== feedId);
|
||||||
saveFeeds(updated);
|
// Unsubscribe intent must not sit in the debounce window if the
|
||||||
|
// process exits — persist the removal immediately.
|
||||||
|
scheduleSaveFeeds();
|
||||||
|
flushPendingSave();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -441,35 +707,35 @@ function createFeedStore() {
|
|||||||
episodeLoadCount.delete(feed.id);
|
episodeLoadCount.delete(feed.id);
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
const updated = prev.filter((f) => f.podcast.feedUrl !== feedUrl);
|
||||||
saveFeeds(updated);
|
// Unsubscribe intent must not sit in the debounce window if
|
||||||
|
// the process exits — persist the removal immediately.
|
||||||
|
scheduleSaveFeeds();
|
||||||
|
flushPendingSave();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Update a feed */
|
|
||||||
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle feed pinned status */
|
|
||||||
const togglePinned = (feedId: string) => {
|
const togglePinned = (feedId: string) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add a source */
|
|
||||||
const addSource = (source: Omit<PodcastSource, "id">) => {
|
const addSource = (source: Omit<PodcastSource, "id">) => {
|
||||||
const newSource: PodcastSource = {
|
const newSource: PodcastSource = {
|
||||||
...source,
|
...source,
|
||||||
@@ -483,7 +749,6 @@ function createFeedStore() {
|
|||||||
return newSource;
|
return newSource;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Update a source */
|
|
||||||
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((source) =>
|
const updated = prev.map((source) =>
|
||||||
@@ -494,7 +759,6 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove a source */
|
|
||||||
const removeSource = (sourceId: string) => {
|
const removeSource = (sourceId: string) => {
|
||||||
// Don't remove default sources
|
// Don't remove default sources
|
||||||
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
if (DEFAULT_SOURCES.some((s) => s.id === sourceId)) return false;
|
||||||
@@ -507,7 +771,6 @@ function createFeedStore() {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Toggle source enabled status */
|
|
||||||
const toggleSource = (sourceId: string) => {
|
const toggleSource = (sourceId: string) => {
|
||||||
setSources((prev) => {
|
setSources((prev) => {
|
||||||
const updated = prev.map((s) =>
|
const updated = prev.map((s) =>
|
||||||
@@ -518,18 +781,31 @@ function createFeedStore() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get feed by ID */
|
|
||||||
const getFeed = (feedId: string): Feed | undefined => {
|
const getFeed = (feedId: string): Feed | undefined => {
|
||||||
return feeds().find((f) => f.id === feedId);
|
return feeds().find((f) => f.id === feedId);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Get selected feed */
|
/** Find an episode by ID across all loaded feeds (undefined when the
|
||||||
|
* episode isn't in any loaded window, e.g. an unsubscribed show). */
|
||||||
|
const findEpisode = (episodeId: string): Episode | undefined => {
|
||||||
|
for (const feed of feeds()) {
|
||||||
|
const ep = feed.episodes.find((e) => e.id === episodeId);
|
||||||
|
if (ep) return ep;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const getSelectedFeed = (): Feed | undefined => {
|
const getSelectedFeed = (): Feed | undefined => {
|
||||||
const id = selectedFeedId();
|
const id = selectedFeedId();
|
||||||
return id ? getFeed(id) : undefined;
|
return id ? getFeed(id) : undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Check if a feed has more episodes available beyond what's currently loaded */
|
/** Check if a feed has more episodes available beyond what's currently
|
||||||
|
* loaded. The full parse cache holds ALL episodes (including beyond the
|
||||||
|
* cache bound), so fetch-more can always page deeper — the bound limits
|
||||||
|
* what the Feed/My Shows list shows initially, not what fetch-more can
|
||||||
|
* reach. When the loaded window reaches the cache length, this flips
|
||||||
|
* false. */
|
||||||
const hasMoreEpisodes = (feedId: string): boolean => {
|
const hasMoreEpisodes = (feedId: string): boolean => {
|
||||||
const cached = fullEpisodeCache.get(feedId);
|
const cached = fullEpisodeCache.get(feedId);
|
||||||
if (!cached) return false;
|
if (!cached) return false;
|
||||||
@@ -537,7 +813,13 @@ function createFeedStore() {
|
|||||||
return loaded < cached.length;
|
return loaded < cached.length;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Load the next chunk of episodes for one feed from the cache.
|
/** Load the next chunk of episodes for one feed from the full parse
|
||||||
|
* cache — VOLATILE only: the episodes surfaced beyond the cache bound
|
||||||
|
* are held in the feed's in-memory episode list (so the user can browse
|
||||||
|
* them) but are NOT written back to fullEpisodeCache (the cache keeps
|
||||||
|
* its original bounded shape; these episodes vanish on the next
|
||||||
|
* refresh or restart). The cache is populated by fetchEpisodes/refresh;
|
||||||
|
* a cold cache (post-restart) triggers a refetch here.
|
||||||
* No global guard — callers own the `isLoadingMore` flag so batches
|
* No global guard — callers own the `isLoadingMore` flag so batches
|
||||||
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
* (loadMoreAllFeeds) can loop over multiple feeds in one go. */
|
||||||
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
const loadMoreEpisodesForFeed = async (feedId: string) => {
|
||||||
@@ -546,18 +828,36 @@ function createFeedStore() {
|
|||||||
|
|
||||||
let cached = fullEpisodeCache.get(feedId);
|
let cached = fullEpisodeCache.get(feedId);
|
||||||
|
|
||||||
// If no cache, re-fetch and parse the full feed
|
// If no cache, re-fetch and parse the full feed (cold path after a
|
||||||
|
// restart). The cache holds the FULL parse — no bound applied here.
|
||||||
if (!cached) {
|
if (!cached) {
|
||||||
const response = await fetch(feed.podcast.feedUrl, {
|
try {
|
||||||
headers: {
|
const response = await fetch(feed.podcast.feedUrl, {
|
||||||
"Accept-Encoding": "identity",
|
headers: {
|
||||||
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
"Accept-Encoding": "identity",
|
||||||
},
|
Accept: "application/rss+xml, application/xml, text/xml, */*",
|
||||||
});
|
},
|
||||||
if (!response.ok) return;
|
// A hung feed must not stall the load-more path forever —
|
||||||
const xml = await response.text();
|
// mirror fetchEpisodes' per-feed timeout.
|
||||||
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
|
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||||
cached = parsed.episodes;
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const xml = await response.text();
|
||||||
|
cached = await parseEpisodesIncremental(xml, feed.podcast.feedUrl);
|
||||||
|
} catch {
|
||||||
|
// Failed/hung refetch: leave the feed's loaded episodes
|
||||||
|
// untouched rather than throwing out of loadMoreEpisodes.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cold-refetch parse output is unsorted; sort it newest-first.
|
||||||
|
// Yield before the sync sort (the parse already yielded before
|
||||||
|
// this point, but the sort of potentially hundreds of episodes
|
||||||
|
// is its own sync block).
|
||||||
|
await yieldToUI();
|
||||||
|
cached = sortEpisodesReverseChronological(cached);
|
||||||
|
// Same ceiling as fetchEpisodes: the cache (and the paging
|
||||||
|
// window below) never exceeds MAX_CACHED_EPISODES_PER_FEED.
|
||||||
|
cached = cached.slice(0, MAX_CACHED_EPISODES_PER_FEED);
|
||||||
fullEpisodeCache.set(feedId, cached);
|
fullEpisodeCache.set(feedId, cached);
|
||||||
// Set current load count to match what's already displayed
|
// Set current load count to match what's already displayed
|
||||||
episodeLoadCount.set(feedId, feed.episodes.length);
|
episodeLoadCount.set(feedId, feed.episodes.length);
|
||||||
@@ -571,14 +871,24 @@ function createFeedStore() {
|
|||||||
|
|
||||||
if (newCount <= currentCount) return; // nothing more to load
|
if (newCount <= currentCount) return; // nothing more to load
|
||||||
|
|
||||||
|
// Advance the loaded window — volatile: the episodes beyond the cache
|
||||||
|
// bound are held in feed.episodes (visible) but the cache itself is
|
||||||
|
// NOT extended. episodeLoadCount tracks the volatile window size.
|
||||||
episodeLoadCount.set(feedId, newCount);
|
episodeLoadCount.set(feedId, newCount);
|
||||||
const episodes = cached.slice(0, newCount);
|
const episodes = cached.slice(0, newCount);
|
||||||
|
|
||||||
|
// Yield a real macrotask turn before the sync state update so the
|
||||||
|
// renderer paints the spinner and processes keyboard input before the
|
||||||
|
// (potentially large, per-feed in loadMoreAllFeeds) setFeeds + sort
|
||||||
|
// runs. Without this, the whole body executes in one microtask batch
|
||||||
|
// and the UI freezes through every feed in the batch.
|
||||||
|
await yieldToUI();
|
||||||
|
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
const updated = prev.map((f) =>
|
const updated = prev.map((f) =>
|
||||||
f.id === feedId ? { ...f, episodes } : f,
|
f.id === feedId ? { ...f, episodes } : f,
|
||||||
);
|
);
|
||||||
saveFeeds(updated);
|
scheduleSaveFeeds();
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -595,7 +905,6 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** True if any feed still has cached episodes beyond its loaded window. */
|
|
||||||
const hasMoreAcrossAll = (): boolean => {
|
const hasMoreAcrossAll = (): boolean => {
|
||||||
return feeds().some((f) => hasMoreEpisodes(f.id));
|
return feeds().some((f) => hasMoreEpisodes(f.id));
|
||||||
};
|
};
|
||||||
@@ -615,7 +924,6 @@ function createFeedStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Run the global auto-download pass (see runAutoDownload above). */
|
|
||||||
const runAutoDownloadNow = (): void => {
|
const runAutoDownloadNow = (): void => {
|
||||||
runAutoDownload();
|
runAutoDownload();
|
||||||
};
|
};
|
||||||
@@ -628,10 +936,15 @@ function createFeedStore() {
|
|||||||
selectedFeedId,
|
selectedFeedId,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
|
|
||||||
|
/** Resolves once persisted feeds are loaded from disk (before the
|
||||||
|
* background refresh). */
|
||||||
|
whenReady: () => feedsReady,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
getFilteredFeeds,
|
getFilteredFeeds,
|
||||||
getAllEpisodesChronological,
|
getAllEpisodesChronological,
|
||||||
getFeed,
|
getFeed,
|
||||||
|
findEpisode,
|
||||||
getSelectedFeed,
|
getSelectedFeed,
|
||||||
hasMoreEpisodes,
|
hasMoreEpisodes,
|
||||||
isLoadingFeeds,
|
isLoadingFeeds,
|
||||||
@@ -650,6 +963,7 @@ function createFeedStore() {
|
|||||||
loadMoreEpisodes,
|
loadMoreEpisodes,
|
||||||
loadMoreAllFeeds,
|
loadMoreAllFeeds,
|
||||||
hasMoreAcrossAll,
|
hasMoreAcrossAll,
|
||||||
|
flushPendingSave,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
@@ -658,7 +972,6 @@ function createFeedStore() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton feed store */
|
|
||||||
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
|
||||||
|
|
||||||
export function useFeedStore() {
|
export function useFeedStore() {
|
||||||
|
|||||||
@@ -53,21 +53,21 @@ async function initProgress(): Promise<void> {
|
|||||||
setProgressMap(parsed);
|
setProgressMap(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget init
|
// Fire-and-forget init; the promise is exposed via whenReady() so boot-time
|
||||||
initProgress();
|
// consumers (e.g. player-session restore) can await the file load.
|
||||||
|
const progressInit = initProgress();
|
||||||
|
|
||||||
function createProgressStore() {
|
function createProgressStore() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
* Get progress for a specific episode.
|
* Resolves once the persisted progress map has been loaded from disk.
|
||||||
*/
|
*/
|
||||||
|
whenReady: () => progressInit,
|
||||||
|
|
||||||
get(episodeId: string): Progress | undefined {
|
get(episodeId: string): Progress | undefined {
|
||||||
return progressMap()[episodeId];
|
return progressMap()[episodeId];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all progress entries.
|
|
||||||
*/
|
|
||||||
all(): Record<string, Progress> {
|
all(): Record<string, Progress> {
|
||||||
return progressMap();
|
return progressMap();
|
||||||
},
|
},
|
||||||
@@ -96,18 +96,12 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an episode is completed.
|
|
||||||
*/
|
|
||||||
isCompleted(episodeId: string): boolean {
|
isCompleted(episodeId: string): boolean {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return false;
|
if (!p || p.duration <= 0) return false;
|
||||||
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
return p.position / p.duration >= COMPLETION_THRESHOLD;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get progress percentage (0-100) for an episode.
|
|
||||||
*/
|
|
||||||
getPercent(episodeId: string): number {
|
getPercent(episodeId: string): number {
|
||||||
const p = progressMap()[episodeId];
|
const p = progressMap()[episodeId];
|
||||||
if (!p || p.duration <= 0) return 0;
|
if (!p || p.duration <= 0) return 0;
|
||||||
@@ -145,9 +139,6 @@ function createProgressStore() {
|
|||||||
persist();
|
persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all progress data.
|
|
||||||
*/
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
setProgressMap({});
|
setProgressMap({});
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -5,12 +5,15 @@
|
|||||||
|
|
||||||
import { createSignal } from "solid-js";
|
import { createSignal } from "solid-js";
|
||||||
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
import { searchPodcasts, searchEpisodes, searchByFeedUrl } from "../utils/search";
|
||||||
|
import {
|
||||||
|
loadSearchHistoryFromFile,
|
||||||
|
saveSearchHistoryToFile,
|
||||||
|
} from "../utils/app-persistence";
|
||||||
import { useFeedStore } from "./feed";
|
import { useFeedStore } from "./feed";
|
||||||
import type { SearchResult, SearchScope } from "../types/source";
|
import type { SearchResult, SearchScope } from "../types/source";
|
||||||
|
|
||||||
const STORAGE_KEY = "podtui_search_history";
|
|
||||||
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
const STORAGE_SCOPE_KEY = "podtui_search_scope";
|
||||||
const MAX_HISTORY = 20;
|
const MAX_HISTORY = 10;
|
||||||
|
|
||||||
export interface SearchState {
|
export interface SearchState {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -21,25 +24,19 @@ export interface SearchState {
|
|||||||
|
|
||||||
const CACHE_TTL = 1000 * 60 * 5;
|
const CACHE_TTL = 1000 * 60 * 5;
|
||||||
|
|
||||||
/** Load search history from localStorage */
|
/** Normalize raw history: drop blanks, dedupe case-insensitively (newest
|
||||||
function loadHistory(): string[] {
|
* wins), cap at MAX_HISTORY. */
|
||||||
if (typeof localStorage === "undefined") return [];
|
function sanitizeHistory(items: string[]): string[] {
|
||||||
try {
|
const seen = new Set<string>();
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const cleaned: string[] = [];
|
||||||
return stored ? JSON.parse(stored) : [];
|
for (const item of items) {
|
||||||
} catch {
|
const trimmed = item.trim();
|
||||||
return [];
|
const key = trimmed.toLowerCase();
|
||||||
}
|
if (!key || seen.has(key)) continue;
|
||||||
}
|
seen.add(key);
|
||||||
|
cleaned.push(trimmed);
|
||||||
/** Save search history to localStorage */
|
|
||||||
function saveHistory(history: string[]): void {
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(history));
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
}
|
||||||
|
return cleaned.slice(0, MAX_HISTORY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
/** Load persisted search scope ("podcast" | "episode"), defaulting to shows. */
|
||||||
@@ -63,17 +60,25 @@ function saveScope(scope: SearchScope): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create search store */
|
|
||||||
export function createSearchStore() {
|
export function createSearchStore() {
|
||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const [query, setQuery] = createSignal("");
|
const [query, setQuery] = createSignal("");
|
||||||
const [isSearching, setIsSearching] = createSignal(false);
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
const [results, setResults] = createSignal<SearchResult[]>([]);
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
const [history, setHistory] = createSignal<string[]>(loadHistory());
|
const [history, setHistory] = createSignal<string[]>([]);
|
||||||
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
const [selectedSources, setSelectedSources] = createSignal<string[]>([]);
|
||||||
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
const [scope, setScopeState] = createSignal<SearchScope>(loadScope());
|
||||||
|
|
||||||
|
/** Load search history from file (fire-and-forget; recents appear as
|
||||||
|
* soon as the file is read). */
|
||||||
|
async function init(): Promise<void> {
|
||||||
|
const loaded = await loadSearchHistoryFromFile();
|
||||||
|
if (loaded.length > 0) setHistory(sanitizeHistory(loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
|
|
||||||
/** Set the search scope (shows vs episodes) and persist it. */
|
/** Set the search scope (shows vs episodes) and persist it. */
|
||||||
const setScope = (next: SearchScope) => {
|
const setScope = (next: SearchScope) => {
|
||||||
setScopeState(next);
|
setScopeState(next);
|
||||||
@@ -161,39 +166,33 @@ export function createSearchStore() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Add query to history */
|
|
||||||
const addToHistory = (q: string) => {
|
const addToHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase());
|
const updated = sanitizeHistory([q, ...prev]);
|
||||||
const updated = [q, ...filtered].slice(0, MAX_HISTORY);
|
saveSearchHistoryToFile(updated);
|
||||||
saveHistory(updated);
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear search history */
|
|
||||||
const clearHistory = () => {
|
const clearHistory = () => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
saveHistory([]);
|
saveSearchHistoryToFile([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove single history item */
|
|
||||||
const removeFromHistory = (q: string) => {
|
const removeFromHistory = (q: string) => {
|
||||||
setHistory((prev) => {
|
setHistory((prev) => {
|
||||||
const updated = prev.filter((h) => h !== q);
|
const updated = prev.filter((h) => h !== q);
|
||||||
saveHistory(updated);
|
saveSearchHistoryToFile(updated);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Clear results */
|
|
||||||
const clearResults = () => {
|
const clearResults = () => {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Mark a podcast as subscribed in results */
|
|
||||||
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
const markSubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
setResults((prev) =>
|
setResults((prev) =>
|
||||||
prev.map((result) => {
|
prev.map((result) => {
|
||||||
@@ -213,6 +212,27 @@ export function createSearchStore() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Mark a podcast as unsubscribed in results (after an in-place
|
||||||
|
* unsubscribe from the results list). */
|
||||||
|
const markUnsubscribed = (podcastId: string, feedUrl?: string) => {
|
||||||
|
setResults((prev) =>
|
||||||
|
prev.map((result) => {
|
||||||
|
const matchesId = result.podcast.id === podcastId;
|
||||||
|
const matchesUrl = feedUrl ? result.podcast.feedUrl === feedUrl : false;
|
||||||
|
if (matchesId || matchesUrl) {
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
podcast: {
|
||||||
|
...result.podcast,
|
||||||
|
isSubscribed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
query,
|
query,
|
||||||
@@ -232,10 +252,10 @@ export function createSearchStore() {
|
|||||||
setSelectedSources,
|
setSelectedSources,
|
||||||
setScope,
|
setScope,
|
||||||
markSubscribed,
|
markSubscribed,
|
||||||
|
markUnsubscribed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Singleton search store */
|
|
||||||
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null;
|
||||||
|
|
||||||
export function useSearchStore() {
|
export function useSearchStore() {
|
||||||
|
|||||||
450
src/stores/visualizer.ts
Normal file
450
src/stores/visualizer.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* visualizer-store — module-level singleton owning the realtime waveform
|
||||||
|
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
|
||||||
|
*
|
||||||
|
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
|
||||||
|
* an ffmpeg process decodes the episode at full speed into a
|
||||||
|
* position-indexed PCM cache; the render loop reads the window ending at
|
||||||
|
* the player's current position from that cache. Because reads are
|
||||||
|
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
|
||||||
|
* choreography at all — and cannot desync:
|
||||||
|
*
|
||||||
|
* - Pause: stop the render loop and the decode pass; the PCM cache stays
|
||||||
|
* resident. Bars freeze on the last rendered frame.
|
||||||
|
* - Resume: re-arm the render loop — bars render instantly from the cache
|
||||||
|
* — and continue the tail decode in the background. No cold start, no
|
||||||
|
* coverage guessing, no clamped-buffer freeze (the old bug: resume
|
||||||
|
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
|
||||||
|
* buffer, then froze on a repeated stale window forever).
|
||||||
|
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
|
||||||
|
* decode segment there; the last frame holds until data arrives.
|
||||||
|
* - Speed changes: nothing. The cache is position-indexed raw PCM.
|
||||||
|
*
|
||||||
|
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
|
||||||
|
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
|
||||||
|
* after the Player tab stops being focused it tears down. Reads outside
|
||||||
|
* decoded coverage return empty — the renderer simply holds the last frame
|
||||||
|
* until the decode frontier arrives.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createSignal,
|
||||||
|
createEffect,
|
||||||
|
createRoot,
|
||||||
|
on,
|
||||||
|
untrack,
|
||||||
|
} from "solid-js";
|
||||||
|
import {
|
||||||
|
loadCavaCore,
|
||||||
|
type CavaCore,
|
||||||
|
type CavaCoreConfig,
|
||||||
|
} from "@/utils/cavacore";
|
||||||
|
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
|
||||||
|
import { createBarScaler } from "@/utils/bar-mapping";
|
||||||
|
import { audioPlaybackSignals } from "@/utils/audio-signals";
|
||||||
|
import { useAppStore } from "@/stores/app";
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** How long the pipeline keeps running after the Player tab loses focus. */
|
||||||
|
export const VISUALIZER_UNLOAD_DELAY_MS = 30_000;
|
||||||
|
|
||||||
|
/** Target frame interval in ms (~30 fps) */
|
||||||
|
const FRAME_INTERVAL = 33;
|
||||||
|
|
||||||
|
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||||
|
const SAMPLES_PER_FRAME = 512;
|
||||||
|
|
||||||
|
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
|
||||||
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface VisualizerStore {
|
||||||
|
/** Frequency bar values (0.0–1.0 per bar), empty until the first frame. */
|
||||||
|
barData: () => number[];
|
||||||
|
/** True from pipeline start until the first complete FFT frame renders. */
|
||||||
|
isLoading: () => boolean;
|
||||||
|
/** True while the ~30fps render loop is armed. */
|
||||||
|
isRunning: () => boolean;
|
||||||
|
/** Report whether the Player tab is the visible tab. */
|
||||||
|
setFocused: (focused: boolean) => void;
|
||||||
|
/** Report the terminal-width-derived bar count (resize re-inits). */
|
||||||
|
setBarCount: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Store factory ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createVisualizerStore(): VisualizerStore {
|
||||||
|
// Frequency bar values (0.0–1.0 per bar)
|
||||||
|
const [barData, setBarData] = createSignal<number[]>([]);
|
||||||
|
|
||||||
|
// True from pipeline start until the first complete FFT frame renders.
|
||||||
|
const [isLoading, setIsLoading] = createSignal(false);
|
||||||
|
|
||||||
|
// Whether the Player tab is the visible tab (fed by PlayerPage).
|
||||||
|
const [focused, setFocused] = createSignal(false);
|
||||||
|
|
||||||
|
// Width-derived bar count (fed by RealtimeWaveform; default before the
|
||||||
|
// renderer reports a real size).
|
||||||
|
const [barCount, setBarCount] = createSignal(64);
|
||||||
|
|
||||||
|
// Peak-follower scaler replaces cava's autosens: normalizes each FFT
|
||||||
|
// frame against the running peak so a loud start can't pin every bar
|
||||||
|
// at full height and quiet content still gets normalized up.
|
||||||
|
const scaler = createBarScaler();
|
||||||
|
|
||||||
|
let cava: CavaCore | null = null;
|
||||||
|
// Position-indexed PCM cache for the current episode. Kept across
|
||||||
|
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||||
|
// dropped only on episode change, stop, disable, or unload.
|
||||||
|
let pcm: EpisodePcmCache | null = null;
|
||||||
|
let frameTimer: TimerHandle | null = null;
|
||||||
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
let unloadTimer: TimerHandle | null = null;
|
||||||
|
|
||||||
|
// What the running pipeline was started with — lets the playback effect
|
||||||
|
// tell "nothing changed, stay warm" from "must restart".
|
||||||
|
let activeUrl = "";
|
||||||
|
let activeBars = 64;
|
||||||
|
|
||||||
|
// ── Lifecycle helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const clearUnloadTimer = () => {
|
||||||
|
if (unloadTimer) {
|
||||||
|
clearTimeout(unloadTimer);
|
||||||
|
unloadTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCava = () => {
|
||||||
|
if (cava) return true;
|
||||||
|
|
||||||
|
cava = loadCavaCore();
|
||||||
|
if (!cava) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Smooth position clock ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// audio.position() updates at the useAudio poll rate (~150ms). Between
|
||||||
|
// polls, interpolate the position from wall time so the FFT window
|
||||||
|
// tracks the audio continuously instead of stepping. The 0.5s cap
|
||||||
|
// prevents extrapolating far beyond reality when the player stalls
|
||||||
|
// (e.g. network re-buffering).
|
||||||
|
|
||||||
|
let lastPolledPosition = 0;
|
||||||
|
let lastPolledAt = 0;
|
||||||
|
const smoothPosition = () => {
|
||||||
|
const pos = audioPlaybackSignals.position();
|
||||||
|
const now = performance.now();
|
||||||
|
if (pos !== lastPolledPosition) {
|
||||||
|
lastPolledPosition = pos;
|
||||||
|
lastPolledAt = now;
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
if (lastPolledAt === 0) return pos;
|
||||||
|
const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5);
|
||||||
|
return lastPolledPosition + elapsed * (audioPlaybackSignals.speed() ?? 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
|
|
||||||
|
const startVisualization = (url: string, position: number) => {
|
||||||
|
stopVisualization();
|
||||||
|
|
||||||
|
if (!url || !initCava() || !cava) return;
|
||||||
|
|
||||||
|
// Initialize cavacore with current resolution + the user's
|
||||||
|
// audio-processing params (noise reduction, cutoffs, etc.).
|
||||||
|
// autosens is disabled (after the spread so it always wins): cava's
|
||||||
|
// autosens gain-ramps during silence then clips everything to 1.0
|
||||||
|
// when audio arrives — the JS peak scaler handles dynamics instead.
|
||||||
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
|
const config: CavaCoreConfig = {
|
||||||
|
bars: barCount(),
|
||||||
|
sampleRate: PCM_SAMPLE_RATE,
|
||||||
|
channels: 1,
|
||||||
|
noiseReduction: viz.noiseReduction,
|
||||||
|
lowCutOff: viz.lowCutOff,
|
||||||
|
highCutOff: viz.highCutOff,
|
||||||
|
autosens: 0,
|
||||||
|
};
|
||||||
|
cava.init(config);
|
||||||
|
|
||||||
|
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
||||||
|
// uninitialized, so the first real frame would FFT garbage and
|
||||||
|
// render full-scale bars. One zero frame the size of the whole
|
||||||
|
// input buffer clears it.
|
||||||
|
cava.execute(new Float64Array(8192));
|
||||||
|
|
||||||
|
// Pre-allocate sample read buffer
|
||||||
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
|
|
||||||
|
// PCM cache per episode (reuse when the episode is unchanged)
|
||||||
|
if (!pcm || pcm.url !== url) {
|
||||||
|
if (pcm) pcm.stop();
|
||||||
|
pcm = new EpisodePcmCache({ url });
|
||||||
|
}
|
||||||
|
// Decode from 1s before the position so the window ENDING at the
|
||||||
|
// position is covered as soon as the first PCM lands.
|
||||||
|
pcm.startDecode(Math.max(0, position - 1));
|
||||||
|
|
||||||
|
// Seed the smooth position clock with the start position. Without
|
||||||
|
// this, a fresh play at position 0 would sample the window ending at
|
||||||
|
// exactly 0 — a 1-sample slice — so bars would be starved until the
|
||||||
|
// first mpv poll advanced the position clock.
|
||||||
|
lastPolledPosition = position;
|
||||||
|
lastPolledAt = performance.now();
|
||||||
|
|
||||||
|
activeUrl = url;
|
||||||
|
activeBars = barCount();
|
||||||
|
setIsLoading(true);
|
||||||
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopVisualization = () => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
if (pcm) {
|
||||||
|
pcm.stop();
|
||||||
|
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||||
|
// same episode reuses it; segments re-decode in seconds at 80x.
|
||||||
|
}
|
||||||
|
if (cava?.isReady) {
|
||||||
|
cava.destroy();
|
||||||
|
}
|
||||||
|
sampleBuffer = null;
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Pause: freeze the loop, keep the cache ──────────────────────────
|
||||||
|
//
|
||||||
|
// The render loop stops (bars hold their last frame) and the ffmpeg
|
||||||
|
// pass dies (no background CPU), but the decoded PCM stays: resume
|
||||||
|
// serves it instantly.
|
||||||
|
|
||||||
|
const suspendVisualization = () => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
// Cancel any debounced seek-decode: it would restart ffmpeg while
|
||||||
|
// paused, defeating the "no background CPU while paused" contract.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
if (pcm) pcm.pauseDecode();
|
||||||
|
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||||
|
// Clear the loading spinner: if the pipeline never produced bars
|
||||||
|
// (still cold-starting when paused), the component should fall back
|
||||||
|
// to the placeholder, not freeze on a spinner.
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Resume: re-arm the render loop, top up the cache ───────────────
|
||||||
|
//
|
||||||
|
// Returns true if the pipeline resumed, false if there was nothing to
|
||||||
|
// resume (no prior pipeline).
|
||||||
|
|
||||||
|
const resumeVisualization = (): boolean => {
|
||||||
|
// Already running — nothing to do.
|
||||||
|
if (frameTimer !== null) return true;
|
||||||
|
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
|
||||||
|
|
||||||
|
const pos = untrack(audioPlaybackSignals.position);
|
||||||
|
|
||||||
|
// Bars come from the cache on the next frame tick (~33ms) whenever
|
||||||
|
// the position is covered; any gap (uncached region) restarts the
|
||||||
|
// decode pass in the background with the last frame holding.
|
||||||
|
pcm.ensureDecodeAround(pos);
|
||||||
|
|
||||||
|
lastPolledPosition = pos;
|
||||||
|
lastPolledAt = performance.now();
|
||||||
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||||
|
|
||||||
|
const renderFrame = () => {
|
||||||
|
if (!cava?.isReady || !sampleBuffer || !pcm) return;
|
||||||
|
|
||||||
|
// Sample the FFT window at the player's position. Outside decoded
|
||||||
|
// coverage (decode cold start, seek into a hole) the read is empty
|
||||||
|
// and the LAST FRAME simply holds — never clamped/repeated junk.
|
||||||
|
const target = smoothPosition();
|
||||||
|
const count = pcm.readWindow(sampleBuffer, target);
|
||||||
|
// Never feed a partial FFT window to cava.
|
||||||
|
if (count < sampleBuffer.length) return;
|
||||||
|
|
||||||
|
const output = cava.execute(sampleBuffer);
|
||||||
|
|
||||||
|
// Normalize against the running peak and copy to a new array
|
||||||
|
setBarData(scaler(output));
|
||||||
|
if (isLoading()) setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Playback subscription ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Keeps the pipeline matched to playback. Pause suspends (render loop +
|
||||||
|
// decode pass die, cache survives) so resume is instant. Stop/track-end/
|
||||||
|
// disable fully tears down. `focused` is a dep so focus regain
|
||||||
|
// re-evaluates; the guards make a focus flip on an already-correct warm
|
||||||
|
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
|
||||||
|
// position-indexed, so playback-rate changes need no pipeline restart.
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
[
|
||||||
|
audioPlaybackSignals.isPlaying,
|
||||||
|
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
|
||||||
|
barCount,
|
||||||
|
focused,
|
||||||
|
() => useAppStore().state().settings.visualizer.enabled,
|
||||||
|
],
|
||||||
|
([playing, url, , , enabled]) => {
|
||||||
|
if (!url || !enabled) {
|
||||||
|
stopVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!playing) {
|
||||||
|
// Pause: freeze the loop, keep the cache. Only if the
|
||||||
|
// pipeline is actually running — otherwise no-op.
|
||||||
|
if (frameTimer !== null) suspendVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing — try a fast resume first. If it succeeds and the
|
||||||
|
// pipeline matches, done.
|
||||||
|
if (
|
||||||
|
frameTimer === null &&
|
||||||
|
pcm &&
|
||||||
|
cava?.isReady &&
|
||||||
|
url === activeUrl &&
|
||||||
|
barCount() === activeBars
|
||||||
|
) {
|
||||||
|
if (resumeVisualization()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm and already correct — nothing to do (e.g. focus
|
||||||
|
// regained within the unload delay while still playing).
|
||||||
|
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!focused()) return; // playing away: stay warm; unload timer decides
|
||||||
|
startVisualization(url, untrack(audioPlaybackSignals.position));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Focus subscription: unload after the grace delay ───────────────
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(focused, (f) => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (f) {
|
||||||
|
// Pipeline was unloaded (or never started) but playback is
|
||||||
|
// still going — restart from the current position. When the
|
||||||
|
// pipeline is warm the playback effect above is the one that
|
||||||
|
// acts (guard: no-op for an unchanged warm pipeline).
|
||||||
|
if (
|
||||||
|
audioPlaybackSignals.isPlaying() &&
|
||||||
|
audioPlaybackSignals.currentEpisode()?.audioUrl &&
|
||||||
|
useAppStore().state().settings.visualizer.enabled &&
|
||||||
|
frameTimer === null
|
||||||
|
) {
|
||||||
|
startVisualization(
|
||||||
|
audioPlaybackSignals.currentEpisode()!.audioUrl,
|
||||||
|
untrack(audioPlaybackSignals.position),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (frameTimer !== null) {
|
||||||
|
unloadTimer = setTimeout(() => {
|
||||||
|
unloadTimer = null;
|
||||||
|
stopVisualization();
|
||||||
|
}, VISUALIZER_UNLOAD_DELAY_MS);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Seek detection: jump coverage, not pipeline restarts ───────────
|
||||||
|
//
|
||||||
|
// Watches position for significant jumps (>2s = user seek). Decoded
|
||||||
|
// audio at the new position is served instantly with zero action; a
|
||||||
|
// jump into an undecoded hole kicks a background segment decode there
|
||||||
|
// while the last frame holds.
|
||||||
|
|
||||||
|
let lastSyncPosition = 0;
|
||||||
|
let seekDecodeTimer: TimerHandle | undefined;
|
||||||
|
createEffect(
|
||||||
|
on(audioPlaybackSignals.position, (pos) => {
|
||||||
|
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||||
|
lastSyncPosition = pos;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = Math.abs(pos - lastSyncPosition);
|
||||||
|
lastSyncPosition = pos;
|
||||||
|
|
||||||
|
if (delta > 2) {
|
||||||
|
// Debounce: holding the seek key fires a jump per poll tick —
|
||||||
|
// without debounce each one restarts ffmpeg, spamming network
|
||||||
|
// reconnects against the stream's server. Wait for the user to
|
||||||
|
// settle, then decode at the final position.
|
||||||
|
clearTimeout(seekDecodeTimer);
|
||||||
|
const target = pcm; // capture for the timer
|
||||||
|
seekDecodeTimer = setTimeout(() => {
|
||||||
|
seekDecodeTimer = undefined;
|
||||||
|
target.ensureDecodeAround(untrack(audioPlaybackSignals.position));
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// ── Process-exit teardown ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The pipeline lives in a detached createRoot that is never disposed,
|
||||||
|
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
|
||||||
|
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
|
||||||
|
// handler. This handler runs synchronously on `exit` and kills the
|
||||||
|
// ffmpeg child + destroys the cava plan so they don't outlive the host.
|
||||||
|
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
|
||||||
|
process.on("exit", () => {
|
||||||
|
stopVisualization();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
barData,
|
||||||
|
isLoading,
|
||||||
|
isRunning: () => frameTimer !== null,
|
||||||
|
// inputs
|
||||||
|
setFocused,
|
||||||
|
setBarCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Singleton ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let visualizerStoreInstance: VisualizerStore | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessor for the shared visualizer store. Created once inside a
|
||||||
|
* `createRoot` so its effects are owned by a detached root — not by
|
||||||
|
* whichever component happens to call first (PlayerPage unmounts would
|
||||||
|
* otherwise dispose the pipeline effects with it).
|
||||||
|
*/
|
||||||
|
export function useVisualizer(): VisualizerStore {
|
||||||
|
if (!visualizerStoreInstance) {
|
||||||
|
visualizerStoreInstance = createRoot(() => createVisualizerStore());
|
||||||
|
}
|
||||||
|
return visualizerStoreInstance;
|
||||||
|
}
|
||||||
@@ -98,7 +98,9 @@ export enum DownloadStatus {
|
|||||||
export interface DownloadedEpisode {
|
export interface DownloadedEpisode {
|
||||||
/** Episode ID */
|
/** Episode ID */
|
||||||
episodeId: string
|
episodeId: string
|
||||||
/** Feed ID the episode belongs to */
|
/** Feed ID the episode belongs to. For downloads of shows that aren't
|
||||||
|
* subscribed (search downloads) this is a deterministic synthetic id
|
||||||
|
* ("unsub-<slug>") that also names the file subdirectory. */
|
||||||
feedId: string
|
feedId: string
|
||||||
/** Current download status */
|
/** Current download status */
|
||||||
status: DownloadStatus
|
status: DownloadStatus
|
||||||
@@ -114,4 +116,16 @@ export interface DownloadedEpisode {
|
|||||||
fileSize: number
|
fileSize: number
|
||||||
/** Error message if failed */
|
/** Error message if failed */
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** Episode title, persisted so unsubscribed-show downloads render without
|
||||||
|
* a loaded feed. */
|
||||||
|
episodeTitle?: string
|
||||||
|
/** Audio URL, persisted so queued downloads survive a restart. */
|
||||||
|
audioUrl?: string
|
||||||
|
/** Publication date (ISO), for display of unsubscribed-show downloads. */
|
||||||
|
pubDate?: string
|
||||||
|
/** Show title, kept for downloads whose show isn't subscribed. */
|
||||||
|
podcastTitle?: string
|
||||||
|
/** The show's RSS feed URL, used to re-classify a download as subscribed
|
||||||
|
* once the user subscribes to its show. */
|
||||||
|
podcastFeedUrl?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export type DesktopTheme = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type VisualizerSettings = {
|
export type VisualizerSettings = {
|
||||||
|
/** Master on/off switch for the player's realtime waveform (default: on). */
|
||||||
|
enabled: boolean;
|
||||||
/** Number of frequency bars (8–128, default: 64) */
|
/** Number of frequency bars (8–128, default: 64) */
|
||||||
bars: number;
|
bars: number;
|
||||||
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
@@ -78,6 +80,8 @@ export type AppSettings = {
|
|||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
playbackSpeed: number;
|
playbackSpeed: number;
|
||||||
|
/** Playback volume 0–1 (default: 1 = 100%). */
|
||||||
|
volume: number;
|
||||||
downloadPath: string;
|
downloadPath: string;
|
||||||
/** Render the app background transparent (let the terminal's own bg show). */
|
/** Render the app background transparent (let the terminal's own bg show). */
|
||||||
transparentBackground: boolean;
|
transparentBackground: boolean;
|
||||||
@@ -86,12 +90,17 @@ export type AppSettings = {
|
|||||||
visualizer: VisualizerSettings;
|
visualizer: VisualizerSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** How the Feed and per-show episode lists load older episodes (default: manual "[Fetch More]"). */
|
/** How the Feed and per-show episode lists load older episodes (default: auto). */
|
||||||
export type FetchMoreMode = "manual" | "auto";
|
export type FetchMoreMode = "manual" | "auto";
|
||||||
|
|
||||||
/** Which shows the auto-download setting applies to (default: all). */
|
/** Which shows the auto-download setting applies to (default: all). */
|
||||||
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
export type AutoDownloadScope = "all" | "none" | "whitelist";
|
||||||
|
|
||||||
|
/** How the episode cache (the Feed / My Shows list + the pagination cache)
|
||||||
|
* is bounded: by a rolling date window or by a count of most-recent
|
||||||
|
* episodes (default: date). */
|
||||||
|
export type EpisodeCacheMode = "date" | "count";
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
showExplicit: boolean;
|
showExplicit: boolean;
|
||||||
autoDownload: boolean;
|
autoDownload: boolean;
|
||||||
@@ -103,8 +112,16 @@ export type UserPreferences = {
|
|||||||
autoDownloadWhitelist: string[];
|
autoDownloadWhitelist: string[];
|
||||||
/** Jump to the Player view automatically when playback starts (default: true) */
|
/** Jump to the Player view automatically when playback starts (default: true) */
|
||||||
autoJumpToPlayer: boolean;
|
autoJumpToPlayer: boolean;
|
||||||
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: manual). */
|
/** Load older episodes from the Feed list: manual button or automatic at the bottom (default: auto). */
|
||||||
fetchMoreMode: FetchMoreMode;
|
fetchMoreMode: FetchMoreMode;
|
||||||
|
/** Minutes between automatic background feed refreshes (default: 30). */
|
||||||
|
refreshIntervalMinutes: number;
|
||||||
|
/** How the episode list cache is bounded — by date or by count (default: date). */
|
||||||
|
episodeCacheMode: EpisodeCacheMode;
|
||||||
|
/** Number of most-recent episodes to keep when mode is "count" (default: 25). */
|
||||||
|
episodeCacheCount: number;
|
||||||
|
/** Rolling window in days for the episode list when mode is "date" (default: 60). */
|
||||||
|
episodeCacheDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AppState = {
|
export type AppState = {
|
||||||
|
|||||||
@@ -156,9 +156,6 @@ function init() {
|
|||||||
setRegistrations((arr) => arr.filter((x) => x !== results));
|
setRegistrations((arr) => arr.filter((x) => x !== results));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
/**
|
|
||||||
* Get all visible options.
|
|
||||||
*/
|
|
||||||
get options() {
|
get options() {
|
||||||
return visibleOptions();
|
return visibleOptions();
|
||||||
},
|
},
|
||||||
@@ -195,9 +192,6 @@ export function CommandProvider(props: ParentProps) {
|
|||||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Command palette dialog component.
|
|
||||||
*/
|
|
||||||
function CommandDialog(props: {
|
function CommandDialog(props: {
|
||||||
options: CommandOption[];
|
options: CommandOption[];
|
||||||
suggestedOptions: CommandOption[];
|
suggestedOptions: CommandOption[];
|
||||||
|
|||||||
@@ -98,9 +98,6 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/**
|
|
||||||
* Clear all dialogs from the stack.
|
|
||||||
*/
|
|
||||||
clear() {
|
clear() {
|
||||||
for (const item of store.stack) {
|
for (const item of store.stack) {
|
||||||
if (item.onClose) item.onClose()
|
if (item.onClose) item.onClose()
|
||||||
@@ -113,9 +110,6 @@ function init() {
|
|||||||
emit("dialog.close", {})
|
emit("dialog.close", {})
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace all dialogs with a new one.
|
|
||||||
*/
|
|
||||||
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -130,9 +124,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a new dialog onto the stack.
|
|
||||||
*/
|
|
||||||
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
|
||||||
if (store.stack.length === 0) {
|
if (store.stack.length === 0) {
|
||||||
focus = renderer.currentFocusedRenderable
|
focus = renderer.currentFocusedRenderable
|
||||||
@@ -143,9 +134,6 @@ function init() {
|
|||||||
emit("dialog.open", { dialogId: "dialog" })
|
emit("dialog.open", { dialogId: "dialog" })
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Pop the top dialog from the stack.
|
|
||||||
*/
|
|
||||||
pop() {
|
pop() {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
const current = store.stack.at(-1)!
|
const current = store.stack.at(-1)!
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
* No backups — writes always overwrite.
|
* No backups — writes always overwrite.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { mkdirSync, writeFileSync } from "fs";
|
||||||
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { loadConfig, updateConfig } from "./config";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
@@ -20,6 +21,7 @@ import { DEFAULT_THEME } from "../constants/themes";
|
|||||||
// --- Defaults ---
|
// --- Defaults ---
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
|
enabled: true,
|
||||||
bars: 32,
|
bars: 32,
|
||||||
sensitivity: 1,
|
sensitivity: 1,
|
||||||
noiseReduction: 0.77,
|
noiseReduction: 0.77,
|
||||||
@@ -31,6 +33,7 @@ const defaultSettings: AppSettings = {
|
|||||||
theme: "system",
|
theme: "system",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
playbackSpeed: 1,
|
playbackSpeed: 1,
|
||||||
|
volume: 1,
|
||||||
downloadPath: "",
|
downloadPath: "",
|
||||||
transparentBackground: false,
|
transparentBackground: false,
|
||||||
showSelectionMarker: false,
|
showSelectionMarker: false,
|
||||||
@@ -44,7 +47,11 @@ const defaultPreferences: UserPreferences = {
|
|||||||
autoDownloadScope: "all",
|
autoDownloadScope: "all",
|
||||||
autoDownloadWhitelist: [],
|
autoDownloadWhitelist: [],
|
||||||
autoJumpToPlayer: true,
|
autoJumpToPlayer: true,
|
||||||
fetchMoreMode: "manual",
|
fetchMoreMode: "auto",
|
||||||
|
refreshIntervalMinutes: 30,
|
||||||
|
episodeCacheMode: "date",
|
||||||
|
episodeCacheCount: 25,
|
||||||
|
episodeCacheDays: 60,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultState: AppState = {
|
const defaultState: AppState = {
|
||||||
@@ -55,13 +62,23 @@ const defaultState: AppState = {
|
|||||||
|
|
||||||
// ── App State (config.json) ─────────────────────────────────────────────────
|
// ── App State (config.json) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Load app state from config.json */
|
|
||||||
export async function loadAppStateFromFile(): Promise<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (!cfg || typeof cfg !== "object") return defaultState;
|
if (!cfg || typeof cfg !== "object") return defaultState;
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings, ...cfg.settings },
|
settings: {
|
||||||
|
...defaultSettings,
|
||||||
|
...cfg.settings,
|
||||||
|
// Visualizer is nested: a plain spread would let a config
|
||||||
|
// saved before a field was added (e.g. `enabled`) clobber
|
||||||
|
// the whole object and leave the new field undefined.
|
||||||
|
// Deep-merge so defaults backfill missing nested keys.
|
||||||
|
visualizer: {
|
||||||
|
...defaultVisualizerSettings,
|
||||||
|
...cfg.settings?.visualizer,
|
||||||
|
},
|
||||||
|
},
|
||||||
preferences: { ...defaultPreferences, ...cfg.preferences },
|
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||||
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||||
};
|
};
|
||||||
@@ -70,7 +87,6 @@ export async function loadAppStateFromFile(): Promise<AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save app state to config.json */
|
|
||||||
export function saveAppStateToFile(state: AppState): void {
|
export function saveAppStateToFile(state: AppState): void {
|
||||||
updateConfig({
|
updateConfig({
|
||||||
settings: state.settings,
|
settings: state.settings,
|
||||||
@@ -91,7 +107,6 @@ interface ProgressEntry {
|
|||||||
playbackSpeed?: number;
|
playbackSpeed?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load progress map from JSON file */
|
|
||||||
export async function loadProgressFromFile(): Promise<
|
export async function loadProgressFromFile(): Promise<
|
||||||
Record<string, ProgressEntry>
|
Record<string, ProgressEntry>
|
||||||
> {
|
> {
|
||||||
@@ -123,11 +138,41 @@ export function saveProgressToFile(data: Record<string, unknown>): void {
|
|||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Search History (separate file — changes on every search) ────────────────
|
||||||
|
|
||||||
|
const SEARCH_HISTORY_FILE = "search-history.json";
|
||||||
|
|
||||||
|
export async function loadSearchHistoryFromFile(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(SEARCH_HISTORY_FILE));
|
||||||
|
if (!(await file.exists())) return [];
|
||||||
|
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.filter((item): item is string => typeof item === "string");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSearchHistoryToFile(history: string[]): void {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(SEARCH_HISTORY_FILE),
|
||||||
|
JSON.stringify(history, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||||
|
|
||||||
const AUDIO_NAV_FILE = "audio-nav.json";
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
|
|
||||||
/** Load audio navigation state from JSON file */
|
|
||||||
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||||
@@ -156,3 +201,70 @@ export function saveAudioNavToFile<T>(data: T): void {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Last Player State (separate file — written on every load/stop) ──────────
|
||||||
|
|
||||||
|
const LAST_PLAYER_FILE = "last-player.json";
|
||||||
|
|
||||||
|
/** Which episode is currently loaded in the player, persisted so the next
|
||||||
|
* launch can restore it paused. `episodeId: null` means the player is empty
|
||||||
|
* (e.g. after Stop). */
|
||||||
|
export interface LastPlayerState {
|
||||||
|
episodeId: string | null;
|
||||||
|
timestamp: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the last-loaded-player marker (null when absent or unreadable) */
|
||||||
|
export async function loadLastPlayerFromFile(): Promise<LastPlayerState | null> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath(LAST_PLAYER_FILE));
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
|
||||||
|
return raw as LastPlayerState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialized marker-write chain: concurrent writes land in submission
|
||||||
|
* order, and callers can await the last one (tests read the file back
|
||||||
|
* deterministically). Mirrors updateConfig's write serialization. */
|
||||||
|
let lastPlayerWriteChain: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/** Save the last-loaded-player marker (fire-and-forget) */
|
||||||
|
export function saveLastPlayerToFile(state: LastPlayerState): void {
|
||||||
|
lastPlayerWriteChain = lastPlayerWriteChain.then(async () => {
|
||||||
|
try {
|
||||||
|
await ensureConfigDir();
|
||||||
|
await Bun.write(
|
||||||
|
getConfigFilePath(LAST_PLAYER_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves once every marker write submitted so far has landed on disk. */
|
||||||
|
export function waitForLastPlayerWrite(): Promise<void> {
|
||||||
|
return lastPlayerWriteChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synchronous variant for the process-exit teardown. `q` quits through
|
||||||
|
* `process.exit(0)`, which runs exit listeners synchronously — an async
|
||||||
|
* write would never land. */
|
||||||
|
export function saveLastPlayerSync(state: LastPlayerState): void {
|
||||||
|
try {
|
||||||
|
mkdirSync(getConfigDir(), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
getConfigFilePath(LAST_PLAYER_FILE),
|
||||||
|
JSON.stringify(state, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Silently ignore write errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
471
src/utils/audio-pcm-cache.ts
Normal file
471
src/utils/audio-pcm-cache.ts
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Position-indexed PCM cache for visualization.
|
||||||
|
*
|
||||||
|
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
|
||||||
|
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
|
||||||
|
* enough that a remote episode isn't ripped at 84x while mpv is trying to
|
||||||
|
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
|
||||||
|
* The renderer then reads the PCM
|
||||||
|
* window ending at the player's current position with zero sync machinery:
|
||||||
|
* there is no pacing (-readrate), no lead-burst, no decode-head/player
|
||||||
|
* drift math, no ring wrap, and nothing that knows or cares about pause,
|
||||||
|
* resume, seek, or playback speed — those all collapse to "read at a
|
||||||
|
* different position in the cache".
|
||||||
|
*
|
||||||
|
* Pause/resume contract (the failure mode of the old design):
|
||||||
|
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
|
||||||
|
* instantly and resumes the tail decode in the background.
|
||||||
|
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
|
||||||
|
* return 0 — the renderer HOLDS the last rendered frame rather than
|
||||||
|
* freezing on a clamped buffer or decaying into junk bars.
|
||||||
|
*
|
||||||
|
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
|
||||||
|
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
||||||
|
* the same file is deterministic so abutting segments agree.
|
||||||
|
*
|
||||||
|
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min. The cache is a
|
||||||
|
* SLIDING WINDOW around the playback position — the decode pass stops
|
||||||
|
* once it is maxAheadSec ahead of the cursor and segments entirely older
|
||||||
|
* than keepBehindSec behind it are dropped (both re-filled/restarted on
|
||||||
|
* demand). Steady state is bounded by (maxAheadSec + keepBehindSec) of
|
||||||
|
* audio (~40 MB at the defaults) INDEPENDENT of episode length; the old
|
||||||
|
* whole-episode cache grew ~160 MB per hour of audio and hit 2.5 GB on
|
||||||
|
* long-form episodes. Fully freed on stop(). 22050 Hz covers Nyquist
|
||||||
|
* 11 kHz, above the default 10 kHz high-cutoff of the visualizer's FFT
|
||||||
|
* config.
|
||||||
|
*
|
||||||
|
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
|
||||||
|
* old reader; local files skip them (ffmpeg rejects http-only options for
|
||||||
|
* file inputs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun";
|
||||||
|
|
||||||
|
/** PCM output format constants */
|
||||||
|
export const PCM_SAMPLE_RATE = 22050;
|
||||||
|
const BYTES_PER_SAMPLE = 2; // s16le
|
||||||
|
|
||||||
|
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
||||||
|
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gap (seconds) a running decode pass may close on its own before a restart
|
||||||
|
* at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded
|
||||||
|
* audio closes in ~4s — about the cost of a network reconnect + range
|
||||||
|
* request for a fresh ffmpeg pass. Beyond the gap, restart at the target.
|
||||||
|
*/
|
||||||
|
const CLOSE_IN_PLACE_GAP_SEC = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default decode-head budget: the ffmpeg pass pauses once it is this far
|
||||||
|
* ahead of the playback cursor. Bounds RAM (~26 MB of s16 at 22050 Hz) AND
|
||||||
|
* the network pull — the old cache decoded the whole episode at 4x, so a
|
||||||
|
* 3h show pinned ~500 MB (2.5 GB+ for long-form) and dragged the entire
|
||||||
|
* remote file even when only the first 10 minutes were listened to. At 4x
|
||||||
|
* pacing a refill costs ~150s of background decode, one ffmpeg spawn per
|
||||||
|
* ~10 min of playback.
|
||||||
|
*/
|
||||||
|
const DEFAULT_DECODE_AHEAD_SEC = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default retention behind the cursor: decoded audio entirely older than
|
||||||
|
* this is dropped. Keeps pause/resume and small backward seeks instant
|
||||||
|
* without letting the window grow with playback time.
|
||||||
|
*/
|
||||||
|
const DEFAULT_KEEP_BEHIND_SEC = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonically increasing generation counter.
|
||||||
|
* Each startDecode() increments this; the read loop checks it to know
|
||||||
|
* if it's been superseded and should bail out.
|
||||||
|
*/
|
||||||
|
let globalGeneration = 0;
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
/** Playback seconds where this segment's first sample sits. */
|
||||||
|
baseSec: number;
|
||||||
|
/** Sample buffer; capacity >= written, doubled on overflow. */
|
||||||
|
samples: Int16Array;
|
||||||
|
/** Samples written so far (== decoded length of the segment). */
|
||||||
|
written: number;
|
||||||
|
/** ffmpeg reached stream EOF while writing this segment — nothing more
|
||||||
|
* will ever arrive after its end. */
|
||||||
|
finished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodePcmCacheOptions {
|
||||||
|
/** Audio URL or file path to decode */
|
||||||
|
url: string;
|
||||||
|
/** Sample rate (default: 22050) */
|
||||||
|
sampleRate?: number;
|
||||||
|
/** Decode-head budget in seconds ahead of the cursor (default: 600). */
|
||||||
|
maxAheadSec?: number;
|
||||||
|
/** Retention in seconds behind the cursor (default: 300). */
|
||||||
|
keepBehindSec?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EpisodePcmCache {
|
||||||
|
private proc: Subprocess | null = null;
|
||||||
|
private segments: Segment[] = [];
|
||||||
|
private generation = 0;
|
||||||
|
private _decoding = false;
|
||||||
|
/** The running pass's segment (base + frontier); null when idle. */
|
||||||
|
private activeSegment: Segment | null = null;
|
||||||
|
readonly url: string;
|
||||||
|
readonly sampleRate: number;
|
||||||
|
/** Sliding-window budgets (see maintainWindow). */
|
||||||
|
readonly maxAheadSec: number;
|
||||||
|
readonly keepBehindSec: number;
|
||||||
|
|
||||||
|
constructor(options: EpisodePcmCacheOptions) {
|
||||||
|
this.url = options.url;
|
||||||
|
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
|
||||||
|
this.maxAheadSec = options.maxAheadSec ?? DEFAULT_DECODE_AHEAD_SEC;
|
||||||
|
this.keepBehindSec = options.keepBehindSec ?? DEFAULT_KEEP_BEHIND_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an ffmpeg decode pass is currently running. */
|
||||||
|
get decoding(): boolean {
|
||||||
|
return this._decoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base (playback seconds) of the running decode pass; null when idle. */
|
||||||
|
get activeDecodeBaseSec(): number | null {
|
||||||
|
return this._decoding && this.activeSegment
|
||||||
|
? this.activeSegment.baseSec
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End (playback seconds) of the furthest-decoded segment. */
|
||||||
|
get coverageEndSec(): number {
|
||||||
|
let end = 0;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > end) end = segEnd;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the furthest segment finished at stream EOF. */
|
||||||
|
get decodeFinished(): boolean {
|
||||||
|
let maxEnd = -1;
|
||||||
|
let finished = false;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > maxEnd) {
|
||||||
|
maxEnd = segEnd;
|
||||||
|
finished = seg.finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start decoding at `fromSec` of playback time into a fresh segment.
|
||||||
|
* Kills any in-flight pass first; existing segments stay readable.
|
||||||
|
*/
|
||||||
|
startDecode(fromSec: number): void {
|
||||||
|
this.killProcess();
|
||||||
|
|
||||||
|
if (!Bun.which("ffmpeg")) {
|
||||||
|
throw new Error("ffmpeg not found — required for audio visualization");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
|
const segment: Segment = {
|
||||||
|
baseSec: Math.max(0, fromSec),
|
||||||
|
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
|
||||||
|
written: 0,
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
this.segments.push(segment);
|
||||||
|
|
||||||
|
const args = ["ffmpeg", "-loglevel", "quiet"];
|
||||||
|
|
||||||
|
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
|
||||||
|
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
|
||||||
|
// ENTIRE episode from the network within the first minute of playback
|
||||||
|
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
|
||||||
|
// still fills the cache 4x faster than playback consumes it, lands a
|
||||||
|
// 75-min episode in ~19 min of background work, and the burst makes
|
||||||
|
// the first bars available immediately.
|
||||||
|
args.push("-readrate", "4", "-readrate_initial_burst", "8");
|
||||||
|
|
||||||
|
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
||||||
|
// input-open when the input is a local file, killing the process
|
||||||
|
// before any PCM is produced. Only pass them for network URLs.
|
||||||
|
if (/^https?:\/\//i.test(this.url)) {
|
||||||
|
args.push(
|
||||||
|
"-reconnect",
|
||||||
|
"1",
|
||||||
|
"-reconnect_streamed",
|
||||||
|
"1",
|
||||||
|
"-reconnect_delay_max",
|
||||||
|
"5",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek before input for network efficiency (container-level skip is
|
||||||
|
// near-instant for mp3/aac; no pre-position decode burn).
|
||||||
|
if (fromSec > 0) {
|
||||||
|
args.push("-ss", String(Math.max(0, fromSec)));
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
"-i",
|
||||||
|
this.url,
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
String(this.sampleRate),
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
});
|
||||||
|
this._decoding = true;
|
||||||
|
this.activeSegment = segment;
|
||||||
|
this.readLoop(myGeneration, segment);
|
||||||
|
|
||||||
|
this.proc.exited
|
||||||
|
.then((code) => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
// Exit 0 == decoded to stream EOF.
|
||||||
|
if (code === 0) segment.finished = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `sec` of playback time has decoded PCM on hand.
|
||||||
|
*/
|
||||||
|
covers(sec: number): boolean {
|
||||||
|
const idx = Math.round(sec * this.sampleRate);
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (idx >= base && idx < base + seg.written) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure decode is progressing toward `sec`: no-op while a pass is
|
||||||
|
* running or the episode is fully decoded; otherwise resumes the tail
|
||||||
|
* decode from the frontier (when `sec` is inside coverage) or starts a
|
||||||
|
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||||
|
*/
|
||||||
|
ensureDecodeAround(sec: number): void {
|
||||||
|
// Enforce the sliding-window budget first (head cap, prune, refill)
|
||||||
|
// so a resume or seek never leaves stale segments behind the cursor.
|
||||||
|
this.maintainWindow(sec);
|
||||||
|
|
||||||
|
// Data already on hand: nothing needed here; only keep the tail
|
||||||
|
// filling if the decode is idle, the episode is unfinished, AND the
|
||||||
|
// head is inside its budget. A head-capped cache ("we're maxAheadSec
|
||||||
|
// ahead, enough decoded") is NOT a stalled decode — restarting it
|
||||||
|
// here would fight maintainWindow's cap on every resume call.
|
||||||
|
if (this.covers(sec)) {
|
||||||
|
if (this._decoding || this.decodeFinished) return;
|
||||||
|
const end = this.coverageEndSec;
|
||||||
|
if (end >= sec + this.maxAheadSec) return;
|
||||||
|
this.startDecode(end > sec ? end : sec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._decoding && this.activeSegment !== null) {
|
||||||
|
// A decode pass fills monotonically FORWARD from its base. Targets
|
||||||
|
// behind the base are unreachable — restart at the target.
|
||||||
|
if (sec < this.activeSegment.baseSec) {
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Target past the pass's frontier: a SMALL gap closes on its own
|
||||||
|
// (4x pacing covers 15s in ~4s — about what a cold restart costs
|
||||||
|
// to reconnect + range-request a network stream), but a FAR-FORWARD
|
||||||
|
// seek would otherwise mean minutes of frozen bars while the pass
|
||||||
|
// chews through the skipped region. Restart at the target.
|
||||||
|
const frontier =
|
||||||
|
this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate;
|
||||||
|
if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return;
|
||||||
|
}
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sliding-window budget for the in-memory cache, driven by the live
|
||||||
|
* playback position. Runs on every read (the render loop is the only
|
||||||
|
* consumer that knows the cursor continuously) and on resume/seek:
|
||||||
|
* - capHead: the decode pass pauses once it is maxAheadSec ahead of the
|
||||||
|
* cursor (pauseDecode keeps the decoded data — a plain startDecode
|
||||||
|
* from the frontier refills it later).
|
||||||
|
* - prune: segments entirely keepBehindSec behind the cursor are
|
||||||
|
* dropped. A backward seek past the window restarts a segment there —
|
||||||
|
* the same mechanism as a seek into an undecoded hole, so no new
|
||||||
|
* failure mode.
|
||||||
|
* - topUp: when the cursor has outrun the head, restart the tail decode
|
||||||
|
* from the frontier (one ffmpeg spawn per maxAheadSec of playback).
|
||||||
|
* Together these bound memory to (maxAheadSec + keepBehindSec) of audio
|
||||||
|
* regardless of episode length.
|
||||||
|
*/
|
||||||
|
private maintainWindow(atSec: number): void {
|
||||||
|
const pos = Math.max(0, atSec);
|
||||||
|
|
||||||
|
if (this._decoding && this.coverageEndSec >= pos + this.maxAheadSec) {
|
||||||
|
this.pauseDecode();
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepFromSec = pos - this.keepBehindSec;
|
||||||
|
if (
|
||||||
|
this.segments.some(
|
||||||
|
(seg) => seg.baseSec + seg.written / this.sampleRate < keepFromSec,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.segments = this.segments.filter(
|
||||||
|
(seg) => seg.baseSec + seg.written / this.sampleRate >= keepFromSec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._decoding && !this.decodeFinished) {
|
||||||
|
const end = this.coverageEndSec;
|
||||||
|
if (end < pos + this.maxAheadSec) {
|
||||||
|
this.startDecode(Math.max(end, pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the PCM window ENDING at `atSec` of playback into `out`
|
||||||
|
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
||||||
|
*
|
||||||
|
* Returns the number of samples written: `out.length` on a full hit, 0
|
||||||
|
* when the window is not (fully) decoded yet — the caller HOLDS the
|
||||||
|
* last rendered frame instead of rendering partial/stale data.
|
||||||
|
*/
|
||||||
|
readWindow(out: Float64Array, atSec: number): number {
|
||||||
|
if (out.length === 0) return 0;
|
||||||
|
this.maintainWindow(atSec);
|
||||||
|
const endIdx = Math.round(atSec * this.sampleRate);
|
||||||
|
const startIdx = endIdx - out.length + 1;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (startIdx < base || endIdx >= base + seg.written) continue;
|
||||||
|
const rel = startIdx - base;
|
||||||
|
const src = seg.samples;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = src[rel + i];
|
||||||
|
}
|
||||||
|
return out.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
|
||||||
|
* Resume later serves bars from the cache instantly.
|
||||||
|
*/
|
||||||
|
pauseDecode(): void {
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeSegment = null;
|
||||||
|
this.killProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the decode pass AND drop all cached audio. */
|
||||||
|
stop(): void {
|
||||||
|
this.pauseDecode();
|
||||||
|
this.segments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the ffmpeg process without touching generation/state. */
|
||||||
|
private killProcess(): void {
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal: continuously reads stdout from ffmpeg and appends samples
|
||||||
|
* to the segment at their absolute playback-time offsets. */
|
||||||
|
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
|
||||||
|
const stdout = this.proc?.stdout;
|
||||||
|
if (!stdout || typeof stdout === "number") return;
|
||||||
|
|
||||||
|
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
|
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
|
||||||
|
// trailing byte into the next chunk (dropping it would byte-flip
|
||||||
|
// every sample that follows).
|
||||||
|
let carry: number | null = null;
|
||||||
|
try {
|
||||||
|
while (this.generation === myGeneration) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || this.generation !== myGeneration) break;
|
||||||
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
|
let view: Uint8Array = value;
|
||||||
|
if (carry !== null) {
|
||||||
|
const merged = new Uint8Array(1 + value.byteLength);
|
||||||
|
merged[0] = carry;
|
||||||
|
merged.set(value, 1);
|
||||||
|
view = merged;
|
||||||
|
carry = null;
|
||||||
|
}
|
||||||
|
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
|
||||||
|
carry = view[view.byteLength - 1];
|
||||||
|
view = view.subarray(0, view.byteLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
|
||||||
|
if (sampleCount === 0) continue;
|
||||||
|
|
||||||
|
if (segment.written + sampleCount > segment.samples.length) {
|
||||||
|
const grown = new Int16Array(
|
||||||
|
Math.max(
|
||||||
|
segment.samples.length * 2,
|
||||||
|
segment.written + sampleCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
grown.set(segment.samples.subarray(0, segment.written));
|
||||||
|
segment.samples = grown;
|
||||||
|
}
|
||||||
|
// Int16Array view over the byte buffer: s16le is the platform's
|
||||||
|
// native endianness on every supported target (arm64/x64 are LE).
|
||||||
|
const src = new Int16Array(
|
||||||
|
view.buffer,
|
||||||
|
view.byteOffset,
|
||||||
|
sampleCount,
|
||||||
|
);
|
||||||
|
segment.samples.set(src, segment.written);
|
||||||
|
segment.written += sampleCount;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended or process killed — expected during stop()
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,30 @@
|
|||||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||||
* surfaces "No audio player found" honestly rather than degrading through
|
* surfaces "No audio player found" honestly rather than degrading through
|
||||||
* players that can't change speed/volume without restarting.
|
* players that can't change speed/volume without restarting.
|
||||||
|
*
|
||||||
|
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
|
||||||
|
* for the app's lifetime instead of spawning a fresh player per episode:
|
||||||
|
*
|
||||||
|
* - Play/pause/seek are IPC commands on a persistent Unix-socket
|
||||||
|
* connection — no process spawn, no socket connect/disconnect churn per
|
||||||
|
* poll, no `waitForSocket` on the play path. Measured command latency is
|
||||||
|
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
|
||||||
|
* network stream.
|
||||||
|
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
|
||||||
|
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
|
||||||
|
* `getPauseState()` read a cache instead of round-tripping the socket on
|
||||||
|
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
|
||||||
|
* Now Playing center) arrive as pause property events with zero polling.
|
||||||
|
* - A restored session can PRELOAD: the episode is loaded paused so mpv
|
||||||
|
* fills its demuxer cache ahead of time; the first real play just flips
|
||||||
|
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||||
|
* user's first Play.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, unlinkSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { dirname, join } from "path";
|
import { join } from "path";
|
||||||
import type { Socket, Subprocess } from "bun";
|
import type { Socket, Subprocess } from "bun";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
@@ -31,6 +49,17 @@ export interface AudioState {
|
|||||||
export interface AudioBackend {
|
export interface AudioBackend {
|
||||||
readonly name: BackendName;
|
readonly name: BackendName;
|
||||||
play(url: string, opts?: PlayOptions): Promise<void>;
|
play(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Load the URL paused WITHOUT starting playback, so the player buffers
|
||||||
|
* ahead of the user's first Play (used for boot session restore).
|
||||||
|
* A subsequent play() of the SAME url flips pause off — near-instant.
|
||||||
|
*/
|
||||||
|
preload(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Attach a cover-art image to the currently-loaded file at runtime
|
||||||
|
* (mpv `video-add`). Lets play() start without waiting on art; the
|
||||||
|
* Now Playing artwork pops in when the download lands.
|
||||||
|
*/
|
||||||
pause(): Promise<void>;
|
pause(): Promise<void>;
|
||||||
resume(): Promise<void>;
|
resume(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
@@ -40,6 +69,15 @@ export interface AudioBackend {
|
|||||||
getPosition(): Promise<number>;
|
getPosition(): Promise<number>;
|
||||||
getDuration(): Promise<number>;
|
getDuration(): Promise<number>;
|
||||||
isPlaying(): boolean;
|
isPlaying(): boolean;
|
||||||
|
/** Live pause state: `true` paused, `false` playing, `undefined` when
|
||||||
|
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
|
||||||
|
* which reflects only commands PodTUI sent — this reflects the player's
|
||||||
|
* real state, including pauses initiated OUTSIDE PodTUI (system
|
||||||
|
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
|
||||||
|
* Playing center). */
|
||||||
|
getPauseState(): Promise<boolean | undefined>;
|
||||||
|
/** True while the player process is running (regardless of pause). */
|
||||||
|
isAlive(): boolean;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,249 +109,521 @@ function which(cmd: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mpvInstance = 0;
|
||||||
function mpvSocketPath(): string {
|
function mpvSocketPath(): string {
|
||||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
// Per-instance, not just per-pid: tests (and backend switching) create
|
||||||
|
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
||||||
|
// every daemon bind the same socket, so later daemons unlink the path
|
||||||
|
// out from under each other.
|
||||||
|
return join(
|
||||||
|
tmpdir(),
|
||||||
|
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
||||||
* mpv executable to use. Prefers a sibling `mpv` inside the app bundle
|
//
|
||||||
* (macOS PodTui.app/Contents/MacOS/mpv): running mpv from inside the bundle
|
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
||||||
* makes macOS attribute its Now Playing session to PodTui — source-app icon
|
// from mpv are either command responses (`request_id` present — correlated
|
||||||
* and name in Control Center — instead of a blank placeholder for an
|
// to the pending promise) or unsolicited traffic (property-change events
|
||||||
* unbundled binary. Falls back to PATH so dev runs and Linux keep working.
|
// from `observe_property`, end-file, ...), dispatched to the event handler.
|
||||||
*/
|
|
||||||
function resolveMpvBinary(): string | null {
|
interface MpvResponse {
|
||||||
try {
|
error?: string;
|
||||||
const bundled = join(dirname(process.execPath), "mpv");
|
data?: unknown;
|
||||||
if (existsSync(bundled)) return bundled;
|
request_id?: number;
|
||||||
} catch {
|
}
|
||||||
/* process.execPath unusable — fall through to PATH */
|
|
||||||
|
interface MpvEvent {
|
||||||
|
event: string;
|
||||||
|
/** Observation id for property-change events. */
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
data?: unknown;
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MpvEventHandler = (msg: MpvEvent) => void;
|
||||||
|
|
||||||
|
class MpvConnection {
|
||||||
|
private sock: Socket | null = null;
|
||||||
|
private buf = "";
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = new Map<number, (msg: MpvResponse) => void>();
|
||||||
|
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
|
||||||
|
onEvent: MpvEventHandler = () => {};
|
||||||
|
|
||||||
|
async connect(path: string): Promise<void> {
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
|
let settled = false;
|
||||||
|
Bun.connect({
|
||||||
|
unix: path,
|
||||||
|
socket: {
|
||||||
|
open: (socket) => {
|
||||||
|
this.sock = socket;
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: (_socket, data) => this.onData(data),
|
||||||
|
error: (_socket, err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
|
},
|
||||||
|
close: () => this.handleTeardown(),
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onData(data: Uint8Array): void {
|
||||||
|
this.buf += Buffer.from(data).toString();
|
||||||
|
let nl = this.buf.indexOf("\n");
|
||||||
|
while (nl !== -1) {
|
||||||
|
const line = this.buf.slice(0, nl);
|
||||||
|
this.buf = this.buf.slice(nl + 1);
|
||||||
|
nl = this.buf.indexOf("\n");
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue; // skip malformed lines
|
||||||
|
}
|
||||||
|
if (msg.request_id !== undefined) {
|
||||||
|
const resolve = this.pending.get(msg.request_id as number);
|
||||||
|
if (resolve) {
|
||||||
|
this.pending.delete(msg.request_id as number);
|
||||||
|
resolve(msg as MpvResponse);
|
||||||
|
}
|
||||||
|
} else if (typeof msg.event === "string") {
|
||||||
|
const event = msg as unknown as MpvEvent;
|
||||||
|
this.onEvent(event);
|
||||||
|
const waiters = this.eventWaiters.get(event.event);
|
||||||
|
if (waiters) {
|
||||||
|
this.eventWaiters.delete(event.event);
|
||||||
|
for (const w of waiters) w(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Socket died / daemon gone: fail all pending commands so no caller
|
||||||
|
* hangs on a dead connection. */
|
||||||
|
private handleTeardown(): void {
|
||||||
|
for (const resolve of this.pending.values()) {
|
||||||
|
resolve({ error: "connection-lost" });
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.sock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a command and await mpv's response (correlated by request_id).
|
||||||
|
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
|
||||||
|
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
|
||||||
|
const sock = this.sock;
|
||||||
|
if (!sock) return Promise.resolve({ error: "not-connected" });
|
||||||
|
const id = this.nextId++;
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.pending.delete(id)) resolve({ error: "timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(id, (msg) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(msg);
|
||||||
|
});
|
||||||
|
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wait for an mpv event by name. Register BEFORE the command
|
||||||
|
* that triggers it. Resolves null on timeout instead of hanging. */
|
||||||
|
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
|
||||||
|
const list = this.eventWaiters.get(name) ?? [];
|
||||||
|
list.push(resolve);
|
||||||
|
this.eventWaiters.set(name, list);
|
||||||
|
setTimeout(() => {
|
||||||
|
const current = this.eventWaiters.get(name);
|
||||||
|
if (current) {
|
||||||
|
this.eventWaiters.set(
|
||||||
|
name,
|
||||||
|
current.filter((w) => w !== resolve),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.sock?.end();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
}
|
}
|
||||||
return which("mpv");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
// One resident daemon for the app's lifetime, controlled over a single
|
||||||
|
// persistent JSON IPC connection with property observation.
|
||||||
|
|
||||||
|
/** Property observation ids (correlate property-change events). */
|
||||||
|
const OBS_TIME_POS = 1;
|
||||||
|
const OBS_PAUSE = 2;
|
||||||
|
const OBS_DURATION = 3;
|
||||||
|
const OBS_EOF = 4;
|
||||||
|
|
||||||
export class MpvBackend implements AudioBackend {
|
export class MpvBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "mpv";
|
readonly name: BackendName = "mpv";
|
||||||
private proc: Subprocess | null = null;
|
private proc: Subprocess | null = null;
|
||||||
private socketPath = mpvSocketPath();
|
private socketPath = mpvSocketPath();
|
||||||
private _playing = false;
|
private conn: MpvConnection | null = null;
|
||||||
|
/** Guarantee daemon startup runs once (concurrent play/preload). */
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Command intent: what PodTUI asked the player to do.
|
||||||
|
private _intentPlaying = false;
|
||||||
|
/** The file currently loaded via loadfile (null = idle). */
|
||||||
|
private _loadedUrl: string | null = null;
|
||||||
|
/** The current file was loadfile'd paused (preload) and not yet played. */
|
||||||
|
private _loadedPaused = false;
|
||||||
|
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
|
||||||
|
private _ended = false;
|
||||||
|
|
||||||
|
// Observed (player-reported) state, pushed by mpv property-change events.
|
||||||
private _position = 0;
|
private _position = 0;
|
||||||
private _duration = 0;
|
private _duration = 0;
|
||||||
|
/** null until the first pause observation arrives. */
|
||||||
|
private _paused: boolean | null = null;
|
||||||
|
|
||||||
private _volume = 100;
|
private _volume = 100;
|
||||||
private _speed = 1;
|
private _speed = 1;
|
||||||
|
private _exited = false;
|
||||||
|
/** Last playback error reported via end-file reason "error". */
|
||||||
|
private _playbackError: string | null = null;
|
||||||
|
|
||||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||||
await this.stop();
|
|
||||||
|
|
||||||
|
private async ensureDaemon(): Promise<void> {
|
||||||
|
if (this.proc && !this._exited && this.conn) return;
|
||||||
|
if (this.startPromise) return this.startPromise;
|
||||||
|
this.startPromise = this.spawnDaemon().finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnDaemon(): Promise<void> {
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
try {
|
try {
|
||||||
if (existsSync(this.socketPath)) {
|
unlinkSync(this.socketPath);
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
this.proc = Bun.spawn(
|
||||||
resolveMpvBinary() ?? "mpv",
|
[
|
||||||
"--no-video",
|
"mpv",
|
||||||
"--no-terminal",
|
"--no-video",
|
||||||
"--really-quiet",
|
"--no-terminal",
|
||||||
`--input-ipc-server=${this.socketPath}`,
|
"--really-quiet",
|
||||||
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
||||||
`--speed=${opts?.speed ?? 1}`,
|
// for its whole session and switches episodes via loadfile.
|
||||||
];
|
"--idle=yes",
|
||||||
|
"--keep-open=yes",
|
||||||
if (opts?.mediaTitle) {
|
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
|
||||||
args.push(`--force-media-title=${opts.mediaTitle}`);
|
// to fill while a preload sits paused — measured 45MB pulled
|
||||||
}
|
// within 12s of a boot-restore preload, saturating the link
|
||||||
|
// exactly when everything else is starting up. ~90s forward
|
||||||
if (opts?.coverArtPath) {
|
// target / 40MiB hard cap is a few MB at podcast bitrates:
|
||||||
// Explicit cover file → albumart track → macOS Now Playing artwork
|
// plenty for instant resume + stall resilience.
|
||||||
// (works for remote streams, not just local downloads).
|
"--cache-secs=90",
|
||||||
args.push(`--cover-art-files=${opts.coverArtPath}`);
|
"--demuxer-max-bytes=40MiB",
|
||||||
}
|
"--demuxer-max-back-bytes=20MiB",
|
||||||
|
`--input-ipc-server=${this.socketPath}`,
|
||||||
if (opts?.startPosition && opts.startPosition > 0) {
|
],
|
||||||
args.push(`--start=${opts.startPosition}`);
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
}
|
);
|
||||||
|
this._exited = false;
|
||||||
args.push(url);
|
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
stdin: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._playing = true;
|
|
||||||
this._position = opts?.startPosition ?? 0;
|
|
||||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
|
||||||
this._speed = opts?.speed ?? 1;
|
|
||||||
|
|
||||||
// Wait for socket to appear (mpv creates it async)
|
|
||||||
await this.waitForSocket(2000);
|
|
||||||
|
|
||||||
// Position is fetched live from mpv on each getPosition() call (see
|
|
||||||
// below) — the UI polls it, so no internal poll timer is needed.
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._playing = false;
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._paused = null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForSocket(timeoutMs: number): Promise<void> {
|
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < 3000) {
|
||||||
if (existsSync(this.socketPath)) return;
|
if (this._exited) break;
|
||||||
|
if (existsSync(this.socketPath)) break;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conn = new MpvConnection();
|
||||||
|
conn.onEvent = (msg) => this.handleEvent(msg);
|
||||||
|
await conn.connect(this.socketPath);
|
||||||
|
this.conn = conn;
|
||||||
|
|
||||||
|
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
|
||||||
|
// while playing and broadcasts external changes (AirPods pull, OS
|
||||||
|
// media keys) with zero polling from our side.
|
||||||
|
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
|
||||||
|
await this.send(["observe_property", OBS_PAUSE, "pause"]);
|
||||||
|
await this.send(["observe_property", OBS_DURATION, "duration"]);
|
||||||
|
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
|
||||||
|
// sets eof-reached=true (and pauses at the last frame) instead. That
|
||||||
|
// property is the track-end signal; end-file only covers unload/error.
|
||||||
|
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a fire-and-forget command (no response needed) */
|
private async send(
|
||||||
private async send(command: unknown[]): Promise<void> {
|
command: unknown[],
|
||||||
try {
|
): Promise<MpvResponse> {
|
||||||
const conn = await Bun.connect({
|
if (!this.conn) return { error: "not-connected" };
|
||||||
unix: this.socketPath,
|
return this.conn.send(command);
|
||||||
socket: {
|
}
|
||||||
data() {},
|
|
||||||
error() {},
|
private handleEvent(msg: MpvEvent): void {
|
||||||
close() {},
|
if (msg.event === "property-change") {
|
||||||
open() {},
|
if (msg.id === OBS_TIME_POS) {
|
||||||
},
|
// `data` is number while playing; unavailable → undefined while
|
||||||
});
|
// idle. Keep last known on transient gaps, reset on idle.
|
||||||
conn.write(JSON.stringify({ command }) + "\n");
|
if (typeof msg.data === "number") this._position = msg.data;
|
||||||
// Don't wait, just schedule a close
|
} else if (msg.id === OBS_PAUSE) {
|
||||||
setTimeout(() => {
|
if (typeof msg.data === "boolean") this._paused = msg.data;
|
||||||
try {
|
} else if (msg.id === OBS_DURATION) {
|
||||||
conn.end();
|
if (typeof msg.data === "number" && msg.data > 0) {
|
||||||
} catch {}
|
this._duration = msg.data;
|
||||||
}, 50);
|
}
|
||||||
} catch {
|
} else if (msg.id === OBS_EOF) {
|
||||||
/* ignore */
|
// Natural end-of-file (or a brand-new load reporting false).
|
||||||
|
this._ended = msg.data === true;
|
||||||
|
if (this._ended) this._intentPlaying = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.event === "end-file") {
|
||||||
|
if (msg.reason === "eof") {
|
||||||
|
this._ended = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
} else if (msg.reason === "error") {
|
||||||
|
this._ended = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
this._playbackError = msg.error ?? "mpv failed to play the stream";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.event === "file-loaded") {
|
||||||
|
this._ended = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File presentation options ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// force-media-title and cover-art-files are set as global properties
|
||||||
|
// BEFORE loadfile (verified: runtime-settable; values containing commas
|
||||||
|
// would corrupt the per-file options string). Numbers (volume, speed,
|
||||||
|
// start, pause) ride as per-file options on loadfile itself so each
|
||||||
|
// loadfile is self-contained.
|
||||||
|
|
||||||
|
private async applyPresentation(opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"force-media-title",
|
||||||
|
opts?.mediaTitle ?? "",
|
||||||
|
]);
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"cover-art-files",
|
||||||
|
opts?.coverArtPath ?? "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
|
||||||
|
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
|
||||||
|
if (opts?.startPosition && opts.startPosition > 0) {
|
||||||
|
parts.push(`start=${Math.max(0, opts.startPosition)}`);
|
||||||
|
}
|
||||||
|
const vol = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
|
||||||
|
const speed = opts?.speed ?? 1;
|
||||||
|
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
|
||||||
|
return parts.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a property value from mpv via IPC.
|
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
|
||||||
*
|
* fires the boot preload unawaited, so without serialization a user
|
||||||
* Resolves the parsed numeric value, or `undefined` when the read fails
|
* pressing Play mid-preload would send loadfile(no-pause) followed by the
|
||||||
* (socket error, timeout, unparseable response, or the property being
|
* in-flight preload's loadfile(pause=yes) — and the stale preload would
|
||||||
* unavailable — e.g. `time-pos` before playback starts). Failure is
|
* pause the file the user just started. The mutex also prevents
|
||||||
* distinct from a legitimate `0` so callers can keep the last known
|
* presentation options (title/cover) of one episode from interleaving
|
||||||
* value instead of snapping the position clock to zero on a transient
|
* with the loadfile of another.
|
||||||
* error; the next poll retries.
|
|
||||||
*
|
|
||||||
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
|
|
||||||
* onto the same connection, so we line-buffer and only settle on the
|
|
||||||
* line that carries the command response (`request_id` set). The socket
|
|
||||||
* is closed once the response is handled — leaving it open leaks an fd
|
|
||||||
* per poll, while closing it before mpv processes the request drops the
|
|
||||||
* reply.
|
|
||||||
*/
|
*/
|
||||||
private async getProperty(name: string): Promise<number | undefined> {
|
private loadMutex: Promise<unknown> = Promise.resolve();
|
||||||
try {
|
|
||||||
return await new Promise<number | undefined>((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
let sock: Socket | null = null;
|
|
||||||
let buf = "";
|
|
||||||
const done = (value: number | undefined) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
try {
|
|
||||||
sock?.end();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
const timeout = setTimeout(() => done(undefined), 300);
|
|
||||||
|
|
||||||
Bun.connect({
|
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
unix: this.socketPath,
|
const result = this.loadMutex.then(fn);
|
||||||
socket: {
|
this.loadMutex = result.catch(() => {});
|
||||||
open(socket) {
|
return result;
|
||||||
sock = socket;
|
}
|
||||||
socket.write(
|
|
||||||
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
private async loadFileLocked(
|
||||||
);
|
url: string,
|
||||||
},
|
opts: PlayOptions | undefined,
|
||||||
data(_socket, data) {
|
paused: boolean,
|
||||||
buf += Buffer.from(data).toString();
|
): Promise<void> {
|
||||||
let nl = buf.indexOf("\n");
|
await this.applyPresentation(opts);
|
||||||
while (nl !== -1) {
|
// Paused preload of a mid-episode restore: pass NO start= option and
|
||||||
const line = buf.slice(0, nl);
|
// seek while paused instead. mpv defers --start stream work (open,
|
||||||
buf = buf.slice(nl + 1);
|
// header probe, demuxer seek) until playback begins — measured: the
|
||||||
nl = buf.indexOf("\n");
|
// demuxer cache stays EMPTY during the whole preload and the eventual
|
||||||
try {
|
// unpause pays 4.3s. A time-pos seek while paused executes at once,
|
||||||
const parsed = JSON.parse(line);
|
// so the stream opens and buffers during the preload, and the first
|
||||||
// Events carry no request_id; only settle on
|
// real Play is a sub-second unpause.
|
||||||
// the actual command response.
|
const pausedSeek =
|
||||||
if (parsed?.request_id === undefined) continue;
|
paused && opts?.startPosition && opts.startPosition > 0
|
||||||
if (parsed?.data !== undefined) {
|
? opts.startPosition
|
||||||
done(Number(parsed.data) || 0);
|
: null;
|
||||||
} else {
|
const loadOpts =
|
||||||
done(undefined);
|
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
|
||||||
}
|
// Register the file-loaded waiter BEFORE loadfile: the event can
|
||||||
return;
|
// arrive between the command response and listener setup otherwise.
|
||||||
} catch {
|
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
|
||||||
/* skip malformed lines */
|
const resp = await this.send([
|
||||||
}
|
"loadfile",
|
||||||
}
|
url,
|
||||||
},
|
"replace",
|
||||||
error() {
|
-1,
|
||||||
done(undefined);
|
this.loadfileOptions(loadOpts, paused),
|
||||||
},
|
]);
|
||||||
close() {
|
if (resp.error && resp.error !== "success") {
|
||||||
done(undefined);
|
throw new Error(`mpv loadfile failed: ${resp.error}`);
|
||||||
},
|
|
||||||
},
|
|
||||||
}).catch(() => done(undefined));
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
if (pausedSeek) {
|
||||||
|
// time-pos sent before file-loaded is silently dropped by mpv
|
||||||
|
// (no file yet) — the preload then parked at 0 and the restore
|
||||||
|
// position was lost. Wait for the open, then seek.
|
||||||
|
await fileLoaded;
|
||||||
|
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||||
|
this._position = pausedSeek;
|
||||||
|
}
|
||||||
|
this._loadedUrl = url;
|
||||||
|
this._loadedPaused = paused;
|
||||||
|
this._ended = false;
|
||||||
|
this._playbackError = null;
|
||||||
|
this._position = opts?.startPosition ?? 0;
|
||||||
|
this._duration = 0;
|
||||||
|
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
this._speed = opts?.speed ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AudioBackend ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
// Mark intent before the mutex: a boot preload queued behind this
|
||||||
|
// play checks it and skips its own stale paused-load.
|
||||||
|
this._intentPlaying = true;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
||||||
|
// mpv has been buffering it since boot, so flipping pause off starts
|
||||||
|
// audio ~instantly. Re-acquire the start position only when it
|
||||||
|
// moved meaningfully since the preload (progress saved meanwhile).
|
||||||
|
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
||||||
|
const target = opts?.startPosition ?? this._position;
|
||||||
|
if (Math.abs(target - this._position) > 2) {
|
||||||
|
await this.send(["set_property", "time-pos", target]);
|
||||||
|
this._position = target;
|
||||||
|
}
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"volume",
|
||||||
|
Math.round((opts?.volume ?? 1) * 100),
|
||||||
|
]);
|
||||||
|
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
||||||
|
if (opts?.mediaTitle) {
|
||||||
|
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
||||||
|
}
|
||||||
|
await this.send(["set_property", "pause", false]);
|
||||||
|
this._loadedPaused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadFileLocked(url, opts, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async preload(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Already loaded (paused park, or actively playing because the
|
||||||
|
// user pressed Play while this preload was queued — either way
|
||||||
|
// the file is in the player and must not be clobbered).
|
||||||
|
if (this._loadedUrl === url) return;
|
||||||
|
await this.loadFileLocked(url, opts, true);
|
||||||
|
this._intentPlaying = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.send(["set_property", "pause", true]);
|
await this.send(["set_property", "pause", true]);
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
|
if (this._ended && this._loadedUrl) {
|
||||||
|
// Play pressed on a finished episode: replay from the top.
|
||||||
|
this._ended = false;
|
||||||
|
const url = this._loadedUrl;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
await this.loadFileLocked(
|
||||||
|
url,
|
||||||
|
{ volume: this._volume / 100, speed: this._speed },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this._intentPlaying = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._loadedPaused && this._loadedUrl) {
|
||||||
|
// Deferred first play of a preloaded file.
|
||||||
|
this._loadedPaused = false;
|
||||||
|
}
|
||||||
|
this._ended = false;
|
||||||
await this.send(["set_property", "pause", false]);
|
await this.send(["set_property", "pause", false]);
|
||||||
this._playing = true;
|
this._intentPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (this.proc) {
|
if (this.conn && this._loadedUrl) {
|
||||||
try {
|
await this.send(["stop"]);
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
}
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
this._position = 0;
|
this._position = 0;
|
||||||
|
this._duration = 0;
|
||||||
// Clean up socket
|
await this.send(["set_property", "cover-art-files", ""]);
|
||||||
try {
|
|
||||||
if (existsSync(this.socketPath)) {
|
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async seek(seconds: number): Promise<void> {
|
async seek(seconds: number): Promise<void> {
|
||||||
@@ -333,30 +643,55 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
// Live-fetch `time-pos` so the position clock is as fresh as the
|
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
|
||||||
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
|
|
||||||
// failure, keep the last known value rather than returning 0.
|
|
||||||
if (this._playing && this.proc) {
|
|
||||||
const pos = await this.getProperty("time-pos");
|
|
||||||
if (pos !== undefined) this._position = pos;
|
|
||||||
}
|
|
||||||
return this._position;
|
return this._position;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDuration(): Promise<number> {
|
async getDuration(): Promise<number> {
|
||||||
if (this._duration <= 0) {
|
|
||||||
const dur = await this.getProperty("duration");
|
|
||||||
if (dur !== undefined && dur > 0) this._duration = dur;
|
|
||||||
}
|
|
||||||
return this._duration;
|
return this._duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlaying(): boolean {
|
isPlaying(): boolean {
|
||||||
return this._playing;
|
return this._intentPlaying && this.isAlive() && !this._ended;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPauseState(): Promise<boolean | undefined> {
|
||||||
|
if (!this.isAlive() || this._paused === null) return undefined;
|
||||||
|
return this._paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
isAlive(): boolean {
|
||||||
|
return this.proc !== null && !this._exited;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last mpv playback failure (end-file reason "error"), if any. */
|
||||||
|
getPlaybackError(): string | null {
|
||||||
|
return this._playbackError;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop();
|
const conn = this.conn;
|
||||||
|
this.conn = null;
|
||||||
|
if (conn) {
|
||||||
|
// Ask nicely, then force: dispose runs inside process-exit
|
||||||
|
// handlers where awaiting is not guaranteed to complete.
|
||||||
|
conn.send(["quit"], 500).catch(() => {});
|
||||||
|
}
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
try {
|
||||||
|
unlinkSync(this.socketPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +700,7 @@ export class MpvBackend implements AudioBackend {
|
|||||||
class NoopBackend implements AudioBackend {
|
class NoopBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "none";
|
readonly name: BackendName = "none";
|
||||||
async play(): Promise<void> {}
|
async play(): Promise<void> {}
|
||||||
|
async preload(): Promise<void> {}
|
||||||
async pause(): Promise<void> {}
|
async pause(): Promise<void> {}
|
||||||
async resume(): Promise<void> {}
|
async resume(): Promise<void> {}
|
||||||
async stop(): Promise<void> {}
|
async stop(): Promise<void> {}
|
||||||
@@ -380,6 +716,13 @@ class NoopBackend implements AudioBackend {
|
|||||||
isPlaying(): boolean {
|
isPlaying(): boolean {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
async getPauseState(): Promise<boolean | undefined> {
|
||||||
|
// Nothing plays on the no-op backend — never externally paused.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
isAlive(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +743,7 @@ export interface DetectedPlayer {
|
|||||||
export function detectPlayers(): DetectedPlayer[] {
|
export function detectPlayers(): DetectedPlayer[] {
|
||||||
const players: DetectedPlayer[] = [];
|
const players: DetectedPlayer[] = [];
|
||||||
|
|
||||||
const mpvPath = resolveMpvBinary();
|
const mpvPath = which("mpv");
|
||||||
if (mpvPath) {
|
if (mpvPath) {
|
||||||
players.push({
|
players.push({
|
||||||
name: "mpv",
|
name: "mpv",
|
||||||
@@ -434,13 +777,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
|||||||
if (backend) return backend;
|
if (backend) return backend;
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveMpvBinary() ? new MpvBackend() : new NoopBackend();
|
return which("mpv") ? new MpvBackend() : new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBackendByName(name: BackendName): AudioBackend | null {
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "mpv":
|
case "mpv":
|
||||||
return resolveMpvBinary() ? new MpvBackend() : null;
|
return which("mpv") ? new MpvBackend() : null;
|
||||||
case "none":
|
case "none":
|
||||||
return new NoopBackend();
|
return new NoopBackend();
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/utils/audio-signals.ts
Normal file
42
src/utils/audio-signals.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* audio-signals — module-level playback state shared by useAudio and
|
||||||
|
* non-component consumers.
|
||||||
|
*
|
||||||
|
* useAudio's playback state is a module-level singleton (signals live at
|
||||||
|
* module scope, every `useAudio()` call shares them). Those signals are
|
||||||
|
* declared here so components that must react to playback WITHOUT mounting
|
||||||
|
* a `useAudio()` owner — the visualizer store — can subscribe directly via
|
||||||
|
* `audioPlaybackSignals` (or the individual accessors/setters), instead of
|
||||||
|
* going through the hook. `useAudio()` re-exports nothing from this module
|
||||||
|
* for callers; it imports the accessors and setters for its own use.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
|
import type { BackendName, DetectedPlayer } from "./audio-player";
|
||||||
|
|
||||||
|
export const [isPlaying, setIsPlaying] = createSignal(false);
|
||||||
|
export const [position, setPosition] = createSignal(0);
|
||||||
|
export const [duration, setDuration] = createSignal(0);
|
||||||
|
export const [volume, setVolume] = createSignal(1);
|
||||||
|
export const [speed, setSpeed] = createSignal(1);
|
||||||
|
export const [backendName, setBackendName] = createSignal<BackendName>("none");
|
||||||
|
export const [error, setError] = createSignal<string | null>(null);
|
||||||
|
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
export const [availablePlayers, setAvailablePlayers] = createSignal<
|
||||||
|
DetectedPlayer[]
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The playback signals the visualizer pipeline reacts to. `useAudio()`
|
||||||
|
* itself remains the component-facing surface; this is for module-level
|
||||||
|
* consumers that must track playback without a component owner.
|
||||||
|
*/
|
||||||
|
export const audioPlaybackSignals = {
|
||||||
|
isPlaying,
|
||||||
|
position,
|
||||||
|
speed,
|
||||||
|
currentEpisode,
|
||||||
|
} as const;
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-time audio stream reader for visualization.
|
|
||||||
*
|
|
||||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
|
||||||
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
|
|
||||||
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
|
|
||||||
* and serves windows *at a requested playback position* to the caller.
|
|
||||||
*
|
|
||||||
* This is independent from the actual playback backend — it's a
|
|
||||||
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
|
|
||||||
* player is maintained by pacing decode at the player's clock rate
|
|
||||||
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
|
|
||||||
* (`-readrate_initial_burst`) so the decode head leads the player
|
|
||||||
* position by a stable lead — read() samples at the exact position the
|
|
||||||
* player reports, never at the decode head.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** PCM output format constants */
|
|
||||||
const SAMPLE_RATE = 44100;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const BYTES_PER_SAMPLE = 2; // s16le
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many samples to buffer (~10 seconds).
|
|
||||||
* Large enough to absorb the gap between mpv's startup latency (0.5–3s,
|
|
||||||
* more for network streams at speed) and the reader's decode head, plus
|
|
||||||
* short player stalls. Samples older than the ring window are never needed
|
|
||||||
* again — the renderer only samples at the current playback position.
|
|
||||||
*/
|
|
||||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode-head lead over the player position, in seconds.
|
|
||||||
*
|
|
||||||
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
|
|
||||||
* immediately on start, then pace at realtime (`-readrate speed`) after.
|
|
||||||
* The decode head thus leads the player by ~LEAD_SECONDS from the very
|
|
||||||
* first frame. read() samples at the player's current position, which is
|
|
||||||
* always behind the head — so it finds freshly decoded samples there
|
|
||||||
* instead of clamping to stale data.
|
|
||||||
*
|
|
||||||
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
|
|
||||||
* + first-packet latency) and, since both advance at the same rate, never
|
|
||||||
* catches up — the bars lag by ε (up to several seconds on network
|
|
||||||
* streams). The burst eliminates that constant offset.
|
|
||||||
*
|
|
||||||
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
|
|
||||||
* lead audio hasn't wrapped out by the time the player reaches it.
|
|
||||||
*/
|
|
||||||
const LEAD_SECONDS = 3;
|
|
||||||
|
|
||||||
export interface AudioStreamReaderOptions {
|
|
||||||
/** Audio URL or file path to decode */
|
|
||||||
url: string;
|
|
||||||
/** Sample rate (default: 44100) */
|
|
||||||
sampleRate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monotonically increasing generation counter.
|
|
||||||
* Each start() increments this; the read loop checks it to know
|
|
||||||
* if it's been superseded and should bail out.
|
|
||||||
*/
|
|
||||||
let globalGeneration = 0;
|
|
||||||
|
|
||||||
import type { Subprocess } from "bun";
|
|
||||||
|
|
||||||
export class AudioStreamReader {
|
|
||||||
private proc: Subprocess | null = null;
|
|
||||||
private ringBuffer: Float64Array;
|
|
||||||
private writePos = 0;
|
|
||||||
private totalSamplesWritten = 0;
|
|
||||||
private startPosition = 0;
|
|
||||||
private _running = false;
|
|
||||||
private generation = 0;
|
|
||||||
readonly url: string;
|
|
||||||
private sampleRate: number;
|
|
||||||
|
|
||||||
constructor(options: AudioStreamReaderOptions) {
|
|
||||||
this.url = options.url;
|
|
||||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
|
||||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the reader is actively reading samples. */
|
|
||||||
get running(): boolean {
|
|
||||||
return this._running;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total number of samples written since start(). */
|
|
||||||
get samplesWritten(): number {
|
|
||||||
return this.totalSamplesWritten;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the ffmpeg decode process and begin reading PCM data.
|
|
||||||
*
|
|
||||||
* If already running, the previous process is killed first.
|
|
||||||
* Uses a generation counter to guarantee that only one read loop
|
|
||||||
* is ever active — stale loops from killed processes bail out
|
|
||||||
* immediately.
|
|
||||||
*
|
|
||||||
* @param startPosition Seek position in seconds (default: 0).
|
|
||||||
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
|
|
||||||
* at the player's advance rate so decode tracks the
|
|
||||||
* player clock; `-readrate_initial_burst` front-loads
|
|
||||||
* a LEAD_SECONDS head start.
|
|
||||||
*/
|
|
||||||
start(startPosition = 0, speed = 1): void {
|
|
||||||
// Always kill the previous process first — no early return on _running
|
|
||||||
this.killProcess();
|
|
||||||
|
|
||||||
if (!Bun.which("ffmpeg")) {
|
|
||||||
throw new Error("ffmpeg not found — required for audio visualization");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment generation so any lingering read loop from a previous
|
|
||||||
// start() will see a mismatch and exit.
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
this.startPosition = Math.max(0, startPosition);
|
|
||||||
|
|
||||||
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-loglevel",
|
|
||||||
"quiet",
|
|
||||||
// Pace input at the player's advance rate (speed× native). Combined
|
|
||||||
// with -readrate_initial_burst below, the decode head starts
|
|
||||||
// LEAD_SECONDS ahead of the player and advances at the same rate —
|
|
||||||
// read() samples at the player position and always finds fresh data.
|
|
||||||
"-readrate",
|
|
||||||
String(readRate),
|
|
||||||
// Front-load LEAD_SECONDS of audio immediately so the decode head
|
|
||||||
// leads the player from the very first frame. Without this, ffmpeg
|
|
||||||
// starts ε behind mpv (input-open + first-packet latency) and,
|
|
||||||
// pacing at the same rate, never catches up — bars lag by ε.
|
|
||||||
"-readrate_initial_burst",
|
|
||||||
String(LEAD_SECONDS),
|
|
||||||
];
|
|
||||||
|
|
||||||
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
|
||||||
// input-open when the input is a local file, killing the process
|
|
||||||
// before any PCM is produced. Only pass them for network URLs.
|
|
||||||
if (/^https?:\/\//i.test(this.url)) {
|
|
||||||
args.push(
|
|
||||||
"-reconnect",
|
|
||||||
"1",
|
|
||||||
"-reconnect_streamed",
|
|
||||||
"1",
|
|
||||||
"-reconnect_delay_max",
|
|
||||||
"5",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek before input for network efficiency
|
|
||||||
if (startPosition > 0) {
|
|
||||||
args.push("-ss", String(startPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("-i", this.url);
|
|
||||||
|
|
||||||
// No atempo filter: the renderer samples the *source* audio at the
|
|
||||||
// player's current position, so output samples map 1:1 to input time
|
|
||||||
// (stream index = (targetSeconds - startPosition) * sampleRate).
|
|
||||||
args.push(
|
|
||||||
"-ac",
|
|
||||||
String(CHANNELS),
|
|
||||||
"-ar",
|
|
||||||
String(this.sampleRate),
|
|
||||||
"-f",
|
|
||||||
"s16le",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-",
|
|
||||||
);
|
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "ignore",
|
|
||||||
stdin: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._running = true;
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
|
|
||||||
const myGeneration = this.generation;
|
|
||||||
|
|
||||||
this.readLoop(myGeneration);
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
|
||||||
.then(() => {
|
|
||||||
// Only clear _running if this is still the current generation
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the visualization window ending at `targetSeconds` of playback.
|
|
||||||
*
|
|
||||||
* The player (mpv) and this decoder are independent processes, so the
|
|
||||||
* decode head and the actual playback position drift apart (startup skew,
|
|
||||||
* stalls, speed changes). Instead of sampling the decode head, we select
|
|
||||||
* the window *at* the position the player reports, clamped to the nearest
|
|
||||||
* available samples when the target hasn't been decoded yet (decode head
|
|
||||||
* behind) or has already wrapped out of the ring (long stall).
|
|
||||||
*
|
|
||||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
|
||||||
* @param targetSeconds - Playback position (input seconds) to sample.
|
|
||||||
* @returns Number of samples written to `out`.
|
|
||||||
*/
|
|
||||||
read(out: Float64Array, targetSeconds: number): number {
|
|
||||||
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
|
|
||||||
|
|
||||||
const headSample = this.totalSamplesWritten - 1;
|
|
||||||
const coveredStart = Math.max(
|
|
||||||
0,
|
|
||||||
this.totalSamplesWritten - this.ringBuffer.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetSample = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Window end: the target, clamped to what's been decoded so far.
|
|
||||||
const endSample = Math.min(targetSample, headSample);
|
|
||||||
// Window start: at most out.length samples back, clamped to what the
|
|
||||||
// ring still holds (target older than the ring -> serve the oldest
|
|
||||||
// available window, which is the closest to the target).
|
|
||||||
const startSample = Math.max(
|
|
||||||
coveredStart,
|
|
||||||
Math.min(endSample, endSample - out.length + 1),
|
|
||||||
);
|
|
||||||
const available = endSample - startSample + 1;
|
|
||||||
if (available <= 0) return 0;
|
|
||||||
|
|
||||||
const ringLen = this.ringBuffer.length;
|
|
||||||
for (let i = 0; i < available; i++) {
|
|
||||||
out[i] = this.ringBuffer[(startSample + i) % ringLen];
|
|
||||||
}
|
|
||||||
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the ffmpeg process and clean up.
|
|
||||||
* Safe to call multiple times. Guarantees the read loop exits.
|
|
||||||
*/
|
|
||||||
stop(): void {
|
|
||||||
// Bump generation to invalidate any running read loop
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
this._running = false;
|
|
||||||
this.killProcess();
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restart the reader at a new position and/or speed.
|
|
||||||
*/
|
|
||||||
restart(startPosition = 0, speed = 1): void {
|
|
||||||
this.start(startPosition, speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kill the ffmpeg process without touching generation/state. */
|
|
||||||
private killProcess(): void {
|
|
||||||
if (this.proc) {
|
|
||||||
try {
|
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
|
||||||
private async readLoop(myGeneration: number): Promise<void> {
|
|
||||||
const stdout = this.proc?.stdout;
|
|
||||||
if (!stdout || typeof stdout === "number") return;
|
|
||||||
|
|
||||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
|
||||||
try {
|
|
||||||
while (this.generation === myGeneration) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done || this.generation !== myGeneration) break;
|
|
||||||
if (!value || value.byteLength === 0) continue;
|
|
||||||
|
|
||||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
|
||||||
if (sampleCount === 0) continue;
|
|
||||||
|
|
||||||
const int16View = new Int16Array(
|
|
||||||
value.buffer,
|
|
||||||
value.byteOffset,
|
|
||||||
sampleCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
|
||||||
this.ringBuffer[this.writePos] = int16View[i];
|
|
||||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
|
||||||
this.totalSamplesWritten++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Stream ended or process killed — expected during stop()
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,6 @@ const DEFAULTS: Required<CavaCoreConfig> = {
|
|||||||
scalingMode: 0,
|
scalingMode: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type CavaLib = {
|
type CavaLib = {
|
||||||
symbols: Record<string, (...args: any[]) => any>;
|
symbols: Record<string, (...args: any[]) => any>;
|
||||||
close(): void;
|
close(): void;
|
||||||
@@ -102,7 +101,6 @@ export class CavaCore {
|
|||||||
this.lib = lib;
|
this.lib = lib;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Number of frequency bars configured. */
|
|
||||||
get bars(): number {
|
get bars(): number {
|
||||||
return this._bars;
|
return this._bars;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* always overwrite — no backup files are created.
|
* always overwrite — no backup files are created.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { mkdir } from "fs/promises";
|
||||||
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -58,21 +59,47 @@ let writeChain: Promise<void> = Promise.resolve();
|
|||||||
|
|
||||||
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
export function updateConfig(patch: Partial<PodTuiConfig>): void {
|
||||||
|
// Capture the target path AND the patch data eagerly, at call time:
|
||||||
|
// the write chain defers execution, and both the config dir (tests
|
||||||
|
// re-point XDG_CONFIG_HOME between ops) and the state object (stores
|
||||||
|
// mutate in place) move under a pending write. Without the capture, a
|
||||||
|
// queued save writes the LATEST state into whatever directory is
|
||||||
|
// current when the chain drains — a cross-directory misdelivery that
|
||||||
|
// was the source of a flaky "enabled:false survives reload" test.
|
||||||
|
const configPath = getConfigFilePath(CONFIG_FILE);
|
||||||
|
const configDir = getConfigDir();
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(patch)) as Partial<PodTuiConfig>;
|
||||||
writeChain = writeChain.then(async () => {
|
writeChain = writeChain.then(async () => {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await migrateOnce();
|
||||||
const current = await loadConfig();
|
await mkdir(configDir, { recursive: true });
|
||||||
const next = { ...current, ...patch };
|
let current: PodTuiConfig = {};
|
||||||
await Bun.write(
|
try {
|
||||||
getConfigFilePath(CONFIG_FILE),
|
const file = Bun.file(configPath);
|
||||||
JSON.stringify(next, null, 2),
|
if (await file.exists()) {
|
||||||
);
|
const raw = await file.json();
|
||||||
|
if (raw && typeof raw === "object") {
|
||||||
|
current = raw as PodTuiConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* unreadable existing config — treat as empty */
|
||||||
|
}
|
||||||
|
const next = { ...current, ...snapshot };
|
||||||
|
await Bun.write(configPath, JSON.stringify(next, null, 2));
|
||||||
} catch {
|
} catch {
|
||||||
// Fire-and-forget persistence — silently ignore write errors.
|
// Fire-and-forget persistence — silently ignore write errors.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve once every queued config write has flushed. Tests await this to
|
||||||
|
* observe the serialized result of pending saveFeedsToFile/updateConfig
|
||||||
|
* calls before asserting on config.json. */
|
||||||
|
export function whenConfigIdle(): Promise<void> {
|
||||||
|
return writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
/** Guards so migration runs exactly once per process. */
|
/** Guards so migration runs exactly once per process. */
|
||||||
let migrationDone = false;
|
let migrationDone = false;
|
||||||
let migrationPromise: Promise<void> | null = null;
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|||||||
@@ -2,34 +2,97 @@
|
|||||||
* Cover-art staging for the system Now Playing session.
|
* Cover-art staging for the system Now Playing session.
|
||||||
*
|
*
|
||||||
* macOS shows the media session's albumart in the audio center (Control
|
* macOS shows the media session's albumart in the audio center (Control
|
||||||
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
|
* Center / lock screen). mpv reads artwork from `--cover-art-files` (loads
|
||||||
* file as an albumart video track), so the podcast cover is staged to a temp
|
* the file as an albumart video track), so the podcast cover must exist on
|
||||||
* file BEFORE playback starts and passed to mpv.
|
* disk before (cover-art-files) or right after (video-add) playback starts.
|
||||||
|
*
|
||||||
|
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
|
||||||
|
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
|
||||||
|
* download happens ONCE per feed — subsequent plays (including the
|
||||||
|
* boot-restored episode) hit the disk cache and never wait on the network.
|
||||||
|
* The play path must never block on art: `cachedCoverPath` is the sync
|
||||||
|
* fast path; `fetchCoverArt` is awaited only by flows where latency does
|
||||||
|
* not matter (CLI play) or fired in the background with the result
|
||||||
|
* applied to a live mpv via `video-add`.
|
||||||
*
|
*
|
||||||
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
||||||
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
||||||
* which would silently drop every cover in shipped builds. curl is present
|
* which would silently drop every cover in shipped builds. curl is present
|
||||||
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
|
* on macOS and Linux. Bounded: a slow cover server must never stall audio.
|
||||||
* so an 8s cap drops the art.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { tmpdir } from "os";
|
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { unlinkSync, statSync } from "fs";
|
|
||||||
|
|
||||||
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
|
/** Resolved once per process; null when no home directory is detectable. */
|
||||||
|
let cacheDir: string | null | undefined;
|
||||||
|
|
||||||
export async function fetchCoverArt(url: string): Promise<string | null> {
|
function coversDir(): string | null {
|
||||||
const path = coverTempPath();
|
if (cacheDir !== undefined) return cacheDir;
|
||||||
|
let dir: string | null = null;
|
||||||
try {
|
try {
|
||||||
unlinkSync(path);
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||||
|
if (home) {
|
||||||
|
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* no stale cover */
|
dir = null;
|
||||||
}
|
}
|
||||||
|
cacheDir = dir;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePathFor(url: string): string | null {
|
||||||
|
const dir = coversDir();
|
||||||
|
if (!dir) return null;
|
||||||
|
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync fast path: the cached cover file for `url`, or null when it has not
|
||||||
|
* been downloaded yet. This is what keeps cover art off the play() critical
|
||||||
|
* path — a cache hit costs one stat() and a miss simply plays without art
|
||||||
|
* (or applies it late via video-add).
|
||||||
|
*/
|
||||||
|
export function cachedCoverPath(url: string): string | null {
|
||||||
|
const path = cachePathFor(url);
|
||||||
|
if (!path) return null;
|
||||||
try {
|
try {
|
||||||
return await Promise.race([
|
return existsSync(path) && statSync(path).size > 0 ? path : null;
|
||||||
(async () => {
|
} catch {
|
||||||
const proc = Bun.spawn([
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-flight downloads keyed by URL — a burst of plays of the same show
|
||||||
|
* shares one curl instead of racing ephemeral files. */
|
||||||
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the cover for `url`, returns its cache path. Cache hits return
|
||||||
|
* immediately. Downloads are single-flight per URL and time-bounded (8s);
|
||||||
|
* failure resolves null and retries on the next call. The file is written
|
||||||
|
* to a temp name and renamed into place so a killed process can never
|
||||||
|
* poison the cache with a truncated file.
|
||||||
|
*/
|
||||||
|
export function fetchCoverArt(url: string): Promise<string | null> {
|
||||||
|
const cached = cachedCoverPath(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
const dest = cachePathFor(url);
|
||||||
|
if (!dest) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const pending = inflight.get(url);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string | null> => {
|
||||||
|
const staging = `${dest}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<string | null>();
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
"curl",
|
"curl",
|
||||||
"-sS",
|
"-sS",
|
||||||
"--fail",
|
"--fail",
|
||||||
@@ -38,20 +101,41 @@ export async function fetchCoverArt(url: string): Promise<string | null> {
|
|||||||
"--max-filesize",
|
"--max-filesize",
|
||||||
"2097152",
|
"2097152",
|
||||||
"-o",
|
"-o",
|
||||||
path,
|
staging,
|
||||||
url,
|
url,
|
||||||
]);
|
],
|
||||||
const code = await proc.exited;
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
if (code !== 0) return null;
|
);
|
||||||
try {
|
proc.exited
|
||||||
return statSync(path).size > 0 ? path : null;
|
.then((code) => {
|
||||||
} catch {
|
if (code !== 0) return resolve(null);
|
||||||
return null;
|
try {
|
||||||
}
|
if (statSync(staging).size <= 0) return resolve(null);
|
||||||
})(),
|
renameSync(staging, dest);
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
|
resolve(dest);
|
||||||
]);
|
} catch {
|
||||||
} catch {
|
resolve(null);
|
||||||
return null;
|
}
|
||||||
}
|
})
|
||||||
|
.catch(() => resolve(null));
|
||||||
|
setTimeout(() => resolve(null), 8000);
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(url);
|
||||||
|
// Best-effort staging cleanup (no-op after a successful rename).
|
||||||
|
try {
|
||||||
|
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(url, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget warm-up used by the boot/restore path. */
|
||||||
|
export function prefetchCoverArt(url: string): void {
|
||||||
|
fetchCoverArt(url).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const PAGE_ACTIONS: ReadonlySet<KeybindActionName> =
|
|||||||
"sort",
|
"sort",
|
||||||
"toggle-hidden",
|
"toggle-hidden",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"subscribe",
|
||||||
"unsubscribe",
|
"unsubscribe",
|
||||||
"download",
|
"download",
|
||||||
"delete-download",
|
"delete-download",
|
||||||
|
|||||||
30
src/utils/episode-merge.ts
Normal file
30
src/utils/episode-merge.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { Episode } from "../types/episode";
|
||||||
|
|
||||||
|
/** Sort key for an episode's pubDate — missing/invalid dates sort as NEWEST
|
||||||
|
* (Infinity) so undated episodes float to the top instead of dropping into
|
||||||
|
* the oldest slot. */
|
||||||
|
const ts = (ep: Episode): number => {
|
||||||
|
const t = ep.pubDate?.getTime()
|
||||||
|
return t === undefined || Number.isNaN(t) ? Infinity : t
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of two episode lists keyed by id — on collision the fetched copy
|
||||||
|
* wins (fresh metadata). Result is sorted newest-first by pubDate and pruned
|
||||||
|
* by the supplied `keep` predicate: episodes outside the configured cache
|
||||||
|
* bound (date window or count) are dropped. Never mutates either input.
|
||||||
|
*
|
||||||
|
* The caller supplies `keep` so this module stays free of the preference
|
||||||
|
* types — the feed store passes a closure bound to the user's mode/count/days.
|
||||||
|
*/
|
||||||
|
export function mergeEpisodesBounded(
|
||||||
|
existing: Episode[],
|
||||||
|
fetched: Episode[],
|
||||||
|
keep: (ep: Episode, index: number) => boolean,
|
||||||
|
): Episode[] {
|
||||||
|
const byId = new Map<string, Episode>()
|
||||||
|
for (const ep of existing) byId.set(ep.id, ep)
|
||||||
|
for (const ep of fetched) byId.set(ep.id, ep)
|
||||||
|
const sorted = [...byId.values()].sort((a, b) => ts(b) - ts(a))
|
||||||
|
return sorted.filter((ep, i) => keep(ep, i))
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ function createEventBus(): EventBusInstance {
|
|||||||
}
|
}
|
||||||
handlers.get(event)!.add(handler as EventHandler);
|
handlers.get(event)!.add(handler as EventHandler);
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
return () => {
|
||||||
this.off(event, handler);
|
this.off(event, handler);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,70 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { loadConfig, updateConfig } from "./config";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
|
import { getConfigFilePath } from "./config-dir";
|
||||||
|
import { DownloadStatus } from "../types/episode";
|
||||||
|
import type { Episode } from "../types/episode";
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
import type { PodcastSource } from "../types/source";
|
import type { PodcastSource } from "../types/source";
|
||||||
|
|
||||||
|
/** Default episode lifecycle window in days — used when no preference is
|
||||||
|
* configured (legacy configs, first launch). The actual bound is the user's
|
||||||
|
* episodeCacheDays preference; this is just the fail-safe default. */
|
||||||
|
export const DEFAULT_EPISODE_WINDOW_DAYS = 60;
|
||||||
|
|
||||||
|
/** True when an episode falls inside a rolling date window of `days` days.
|
||||||
|
* A missing/invalid pubDate is ALWAYS kept (fail-safe: never drop an
|
||||||
|
* undatable episode) — the volatile cache must agree with
|
||||||
|
* episodeIsPersistable so an episode the persistence layer retains can
|
||||||
|
* never be silently pruned from the list. */
|
||||||
|
export function episodeInWindow(
|
||||||
|
ep: Episode,
|
||||||
|
now: Date,
|
||||||
|
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
): boolean {
|
||||||
|
const t = ep.pubDate?.getTime();
|
||||||
|
if (!t || Number.isNaN(t)) return true;
|
||||||
|
return t >= now.getTime() - days * 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when an episode may be persisted: a completed download, or it falls
|
||||||
|
* inside the lifecycle window (undatable episodes always kept). */
|
||||||
|
export function episodeIsPersistable(
|
||||||
|
ep: Episode,
|
||||||
|
downloadedIds: Set<string>,
|
||||||
|
now: Date,
|
||||||
|
days: number = DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
): boolean {
|
||||||
|
return downloadedIds.has(ep.id) || episodeInWindow(ep, now, days);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode ids of completed downloads, read from downloads.json. In-flight
|
||||||
|
* downloads are NOT exempted from the retention window — a just-completed
|
||||||
|
* download is re-included by the next save because the in-memory
|
||||||
|
* feed.episodes still holds it. Missing/unreadable/invalid file → empty set. */
|
||||||
|
async function readDownloadedEpisodeIds(): Promise<Set<string>> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(getConfigFilePath("downloads.json"));
|
||||||
|
if (!(await file.exists())) return new Set();
|
||||||
|
const raw = await file.json();
|
||||||
|
if (!Array.isArray(raw)) return new Set();
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const rec of raw) {
|
||||||
|
if (
|
||||||
|
rec &&
|
||||||
|
typeof rec === "object" &&
|
||||||
|
rec.status === DownloadStatus.COMPLETED &&
|
||||||
|
typeof rec.episodeId === "string"
|
||||||
|
) {
|
||||||
|
ids.add(rec.episodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
return {
|
return {
|
||||||
@@ -22,24 +83,57 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
/** Load feeds from config.json, pruning episodes outside the retention
|
||||||
/** Load feeds from config.json */
|
* window (completed downloads always kept). When anything was pruned, the
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
* pruned list is rewritten to config.json (startup cleanup for legacy
|
||||||
|
* configs). The read path is awaited so the returned value is deterministic. */
|
||||||
|
export async function loadFeedsFromFile(
|
||||||
|
windowDays?: number,
|
||||||
|
): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
if (!Array.isArray(cfg.feeds)) return [];
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
return cfg.feeds.map(reviveDates);
|
const feeds = cfg.feeds.map(reviveDates);
|
||||||
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
|
const now = new Date();
|
||||||
|
let prunedAny = false;
|
||||||
|
const pruned = feeds.map((f) => {
|
||||||
|
const kept = f.episodes.filter((ep) =>
|
||||||
|
episodeIsPersistable(ep, downloadedIds, now, windowDays),
|
||||||
|
);
|
||||||
|
if (kept.length !== f.episodes.length) prunedAny = true;
|
||||||
|
return { ...f, episodes: kept };
|
||||||
|
});
|
||||||
|
if (prunedAny) {
|
||||||
|
// Fire-and-forget cleanup rewrite of the legacy config.
|
||||||
|
saveFeedsToFile(pruned, windowDays);
|
||||||
|
}
|
||||||
|
return pruned;
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to config.json */
|
/** Save feeds to config.json, pruning episodes outside the retention window
|
||||||
export function saveFeedsToFile(feeds: Feed[]): void {
|
* (completed downloads always kept). Fire-and-forget: the prune reads
|
||||||
updateConfig({ feeds });
|
* downloads.json asynchronously, then enqueues the write. On any error the
|
||||||
|
* UNPRUNED feeds are saved instead, so data is never lost. */
|
||||||
|
export function saveFeedsToFile(feeds: Feed[], windowDays?: number): void {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const downloadedIds = await readDownloadedEpisodeIds();
|
||||||
|
const pruned = feeds.map((f) => ({
|
||||||
|
...f,
|
||||||
|
episodes: f.episodes.filter((ep) =>
|
||||||
|
episodeIsPersistable(ep, downloadedIds, new Date(), windowDays),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
updateConfig({ feeds: pruned });
|
||||||
|
} catch {
|
||||||
|
updateConfig({ feeds }); /* never lose data on an error path */
|
||||||
|
}
|
||||||
|
})().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from config.json */
|
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const cfg = await loadConfig();
|
const cfg = await loadConfig();
|
||||||
@@ -49,8 +143,6 @@ export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to config.json */
|
|
||||||
export function saveSourcesToFile<T>(sources: T[]): void {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
* and multi-line comments, which is useful for configuration files.
|
* and multi-line comments, which is useful for configuration files.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove JSONC comments from a string
|
|
||||||
*/
|
|
||||||
function stripComments(jsonString: string): string {
|
function stripComments(jsonString: string): string {
|
||||||
const comments = [
|
const comments = [
|
||||||
{ pattern: /\/\/.*$/gm, replacement: "" },
|
{ pattern: /\/\/.*$/gm, replacement: "" },
|
||||||
@@ -23,9 +20,6 @@ function stripComments(jsonString: string): string {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse JSONC string into a JavaScript object
|
|
||||||
*/
|
|
||||||
export function parseJSONC(jsonString: string): unknown {
|
export function parseJSONC(jsonString: string): unknown {
|
||||||
const stripped = stripComments(jsonString);
|
const stripped = stripComments(jsonString);
|
||||||
return JSON.parse(stripped);
|
return JSON.parse(stripped);
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ const DEFAULT_KEYBINDS: KeybindsResolved = {
|
|||||||
sort: [","],
|
sort: [","],
|
||||||
"toggle-hidden": ["."],
|
"toggle-hidden": ["."],
|
||||||
refresh: ["r"],
|
refresh: ["r"],
|
||||||
|
// a subscribes the focused show/episode result in place (x unsubscribes)
|
||||||
|
subscribe: ["a"],
|
||||||
unsubscribe: ["x"],
|
unsubscribe: ["x"],
|
||||||
// downloads
|
// downloads
|
||||||
download: ["d"],
|
download: ["d"],
|
||||||
@@ -93,7 +95,6 @@ export async function copyKeybindsIfNeeded(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load keybinds from JSONC file */
|
|
||||||
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
export async function loadKeybindsFromFile(): Promise<KeybindsResolved> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
const filePath = getConfigFilePath(KEYBINDS_FILE);
|
||||||
|
|||||||
@@ -9,23 +9,14 @@
|
|||||||
|
|
||||||
import { emit } from "./event-bus"
|
import { emit } from "./event-bus"
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme reload event.
|
|
||||||
*/
|
|
||||||
function emitThemeReload(): void {
|
function emitThemeReload(): void {
|
||||||
emit("theme.reload", {})
|
emit("theme.reload", {})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme changed event.
|
|
||||||
*/
|
|
||||||
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
|
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
|
||||||
emit("theme.changed", { theme, mode })
|
emit("theme.changed", { theme, mode })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit a theme mode changed event.
|
|
||||||
*/
|
|
||||||
export function emitThemeModeChanged(mode: "dark" | "light"): void {
|
export function emitThemeModeChanged(mode: "dark" | "light"): void {
|
||||||
emit("theme.mode.changed", { mode })
|
emit("theme.mode.changed", { mode })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Theme CSS Variable Manager
|
* Terminal Theme Resolver
|
||||||
* Handles dynamic theme switching by updating CSS custom properties
|
* Resolves the active theme (built-in, custom, or system-derived) to colors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TerminalColors } from "@opentui/core";
|
import type { TerminalColors } from "@opentui/core";
|
||||||
import type { ThemeJson } from "../types/theme-schema";
|
import type { ThemeJson } from "../types/theme-schema";
|
||||||
import { THEME_JSON } from "../constants/themes";
|
|
||||||
import { getCustomThemes } from "./custom-themes";
|
|
||||||
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
import { resolveTheme as resolveThemeJson } from "./theme-resolver";
|
||||||
import { generateSystemTheme } from "./system-theme";
|
import { generateSystemTheme } from "./system-theme";
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply CSS variable data-theme attribute
|
|
||||||
*/
|
|
||||||
export function setThemeAttribute(themeName: string) {
|
|
||||||
if (typeof document === "undefined") return;
|
|
||||||
const root = document.documentElement;
|
|
||||||
root.setAttribute("data-theme", themeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadThemes() {
|
|
||||||
return await getCustomThemes();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveTerminalTheme(
|
export function resolveTerminalTheme(
|
||||||
themes: Record<string, ThemeJson>,
|
themes: Record<string, ThemeJson>,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -32,9 +17,5 @@ export function resolveTerminalTheme(
|
|||||||
if (name === "system" && system) {
|
if (name === "system" && system) {
|
||||||
return resolveThemeJson(generateSystemTheme(system, mode), mode);
|
return resolveThemeJson(generateSystemTheme(system, mode), mode);
|
||||||
}
|
}
|
||||||
const theme = themes[name] ?? themes.catppuccin;
|
return resolveThemeJson(themes[name] ?? themes.catppuccin, mode);
|
||||||
if (!theme) {
|
|
||||||
return resolveThemeJson(THEME_JSON.catppuccin, mode);
|
|
||||||
}
|
|
||||||
return resolveThemeJson(theme, mode);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# 01. Persist only a 30-day episode window, keep downloaded episodes, clean up stale data
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-01
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P1
|
||||||
|
depends_on: []
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Bound what the app writes to `config.json`: each persisted feed keeps only episodes published within the last 30 days, plus any episode whose download is completed — everything older lives in volatile memory only (wired up in task 02). Loading an over-window legacy config must prune it automatically (cleanup on first launch).
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- Feeds persist through `src/utils/feeds-persistence.ts`. `saveFeedsToFile(feeds)` is a fire-and-forget wrapper around `updateConfig({ feeds })` in `src/utils/config.ts`, which read-modify-writes the whole `config.json` behind a serialized promise chain (`writeChain`).
|
||||||
|
- Today `saveFeedsToFile` writes every loaded episode, so `config.json` grows forever (the Feed page's "Fetch More" keeps expanding `feed.episodes` and saving).
|
||||||
|
- Downloads persist separately in `downloads.json` (same config dir, see `src/utils/config-dir.ts` `getConfigFilePath("downloads.json")`). Each record has `episodeId`, `status`, `feedId`, etc. The `DownloadStatus` enum lives in `src/types/episode.ts` — read it there for the completed member's string value; do NOT hardcode a guessed string.
|
||||||
|
- `src/stores/feed.ts` calls `saveFeedsToFile` from a module-scope `saveFeeds()` helper. Callers must not change in this task.
|
||||||
|
- Style: match the file you edit. `feeds-persistence.ts` and `config.ts` are tab-indented WITH semicolons (some other repo files aren't — don't "fix" that anywhere).
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/utils/feeds-persistence.ts`:
|
||||||
|
- New exported constant `EPISODE_WINDOW_DAYS = 30` — the lifecycle window: bounds BOTH persistence (here) and the volatile episode list/cache (task 02).
|
||||||
|
- New exported pure function `episodeInWindow(ep: Episode, now: Date): boolean` — returns `true` when `ep.pubDate` is missing/not a valid `Date` (fail-safe: never drop an undatable episode) OR `ep.pubDate.getTime() >= now.getTime() - EPISODE_WINDOW_DAYS * 24 * 3600 * 1000`.
|
||||||
|
- New exported pure function `episodeIsPersistable(ep: Episode, downloadedIds: Set<string>, now: Date): boolean` — returns `true` when `downloadedIds.has(ep.id)` OR `episodeInWindow(ep, now)`.
|
||||||
|
- New (module-private) async helper `readDownloadedEpisodeIds(): Promise<Set<string>>` — reads `getConfigFilePath("downloads.json")` with `Bun.file`, returns the `episodeId`s of records whose `status` equals `DownloadStatus.COMPLETED`; returns an empty set on any error or missing file. Note: an episode whose download is merely in-flight is NOT exempted; it will be re-included by the next save after completion, since the in-memory `feed.episodes` still holds it — document this in the function comment.
|
||||||
|
- `saveFeedsToFile(feeds: Feed[])` — before calling `updateConfig`, map each feed to `{ ...feed, episodes: feed.episodes.filter(ep => episodeIsPersistable(ep, downloadedIds, new Date())) }`. The downloaded-ids lookup is async, so wrap the whole body in a fire-and-forget async IIFE (`.catch(() => {})`) that preserves the existing sync/fire-and-forget signature; on any lookup failure, save the feeds unpruned (never lose data on an error path).
|
||||||
|
- `loadFeedsFromFile()` — after `reviveDates`, apply the same prune to the loaded feeds; if the prune removed at least one episode, call `saveFeedsToFile(pruned)` to rewrite `config.json` (this is the startup cleanup for legacy configs). `await` the prune path deterministically (the function is already async).
|
||||||
|
- `src/utils/config.ts`:
|
||||||
|
- New exported `whenConfigIdle(): Promise<void>` returning the module-internal `writeChain` promise. Tests need a way to await pending serialized writes; today `updateConfig` hides the chain and tests cannot observe when a write lands.
|
||||||
|
- `tests/feed-retention.test.ts` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/types/episode.ts` to confirm `DownloadStatus.COMPLETED`'s runtime value and the `Episode` shape (`id`, `pubDate`).
|
||||||
|
2. Read `src/utils/feeds-persistence.ts` and `src/utils/config.ts` fully (they are short).
|
||||||
|
3. Add `whenConfigIdle()` to `config.ts` next to `updateConfig`.
|
||||||
|
4. In `feeds-persistence.ts`: add imports (`getConfigFilePath` from `./config-dir`, `DownloadStatus` and `type Episode` from `../types/episode`), the constant, `episodeIsPersistable`, `readDownloadedEpisodeIds`, then rework `saveFeedsToFile` and `loadFeedsFromFile` per deliverables. Keep `reviveDates` untouched.
|
||||||
|
5. Ensure `saveFeeds` in `src/stores/feed.ts` still compiles unchanged (signature-compatible).
|
||||||
|
6. Write `tests/feed-retention.test.ts`, run it, then run the full suite and lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Conventions (copy them): `tests/feed-refresh.test.ts` shows the harness — `mkdtempSync` into `process.env.XDG_CONFIG_HOME` **before** importing anything under test (module-level init reads the config dir), `rmSync` in `afterAll`, tabs/no-semicolon style not required but match repo.
|
||||||
|
- New `tests/feed-retention.test.ts`:
|
||||||
|
- Unit (Arrange–Act–Assert) for `episodeIsPersistable`:
|
||||||
|
- episode 40 days old, not downloaded → `false`.
|
||||||
|
- episode 40 days old, id in `downloadedIds` → `true`.
|
||||||
|
- episode 5 days old → `true`.
|
||||||
|
- episode with `pubDate: new Date(NaN)` → `true` (fail-safe).
|
||||||
|
- Save-path integration:
|
||||||
|
- Arrange: write a `downloads.json` in the temp config dir containing one `completed` record for `old-downloaded-id` (include all fields the loader reads in `src/stores/download.ts`'s `DownloadRecord`: at minimum `episodeId`, `feedId`, `status`, `filePath: null`, `downloadedAt: null`, `fileSize: 0`, `error: null`, `audioUrl: ""`, `episodeTitle: ""`).
|
||||||
|
- Act: call `saveFeedsToFile([feed])` where the feed has three episodes — recent, old-not-downloaded (`id: "old-plain-id"`), old-downloaded (`id: "old-downloaded-id"`). Await `whenConfigIdle()` (plus one more microtask/`await Promise.resolve()` round if the async IIFE resolves after the chain call — flush both).
|
||||||
|
- Assert: parse `config.json` raw; the feed's persisted `episodes` contain the recent and `old-downloaded-id` episodes and NOT `old-plain-id`.
|
||||||
|
- Load-path cleanup:
|
||||||
|
- Arrange: seed `config.json` (write it directly with `Bun.write`) with one feed holding only over-window episodes; no `downloads.json`.
|
||||||
|
- Act: `await loadFeedsFromFile()`, then `await whenConfigIdle()`.
|
||||||
|
- Assert: returned feed has zero episodes AND re-reading `config.json` shows the episodes pruned (cleanup rewrite happened).
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- `saveFeedsToFile` never writes an episode older than 30 days unless its id is a completed download in `downloads.json`.
|
||||||
|
- `loadFeedsFromFile` prunes over-window episodes from legacy configs and rewrites `config.json` when it pruned anything.
|
||||||
|
- Undatable episodes (`pubDate` missing/invalid) are always persisted.
|
||||||
|
- No call site of `saveFeedsToFile`/`loadFeedsFromFile` needed to change (compatible signatures).
|
||||||
|
- `bun test tests/feed-retention.test.ts` passes; the existing `bun test` suite passes; `bun run lint` is clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-retention.test.ts`
|
||||||
|
- `bun test` (full suite — watch `feed-refresh`/`feed-pagination` for regressions)
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke (optional): `bun start`, subscribe to any feed, quit, then `cat ~/.config/podtui/config.json | python3 -c "import sys,json; print(max(e['pubDate'] for f in json.load(sys.stdin)['feeds'] for e in f['episodes']))"` and confirm no persisted episode is older than 30 days.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- `updateConfig` captures the patched data eagerly at call time (`JSON.parse(JSON.stringify(patch))`), so pruning in `saveFeedsToFile` before the `updateConfig` call is exactly where the filter must live — filtering later would be silently ineffective for already-queued writes.
|
||||||
|
- This task intentionally does NOT change in-memory behavior, refresh merging, or cache bounds — that is task 02. If both are worked on in parallel, 02 imports nothing from 01 except the documented window semantics; the module-level contract above is the seam.
|
||||||
|
- `downloads.json` is written by `src/stores/download.ts` (`saveDownloads`); reading it directly here avoids a store→module import cycle (download.ts already imports the feed store).
|
||||||
85
tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md
Normal file
85
tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# 02. Merge refreshes against the volatile in-memory episode window with a date-windowed cache
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-02
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P2
|
||||||
|
depends_on: [bounded-feed-lifecycle-01]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Refreshing a feed must UNION the freshly fetched latest window with the episodes already in memory (instead of replacing), so episodes that task 01 pruned from disk — or deep episodes pulled in via "Fetch More" — survive refreshes within a session. Bound in-memory retention by the SAME date window persistence uses (`EPISODE_WINDOW_DAYS`, 30 days) instead of an episode count: the visible list and the pagination cache hold every episode from the last 30 days, and episodes older than that age out of the list on the next refresh (`fullEpisodeCache` currently holds every parsed episode of every feed ever fetched).
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- All work lands in `src/stores/feed.ts` plus one new pure-utils module. Current behavior to change:
|
||||||
|
- `fetchEpisodes(feedUrl, limit, feedId?)` parses the whole feed, stores ALL episodes in the module-level `fullEpisodeCache` Map, returns the first `limit`.
|
||||||
|
- `refreshFeed` / `refreshAllFeeds` pass the fetched window through `applyRefreshedEpisodes`, which REPLACES `feed.episodes` when ids differ (`sameEpisodes` id-set compare; unchanged → keep object identity and skip save — this order-stability contract is pinned by `tests/feed-refresh.test.ts` and must keep passing).
|
||||||
|
- `loadMoreEpisodesForFeed` grows the displayed window from `fullEpisodeCache` (fetching+parsing the full feed when the cache is cold — e.g. after a restart), tracking progress in `episodeLoadCount`.
|
||||||
|
- Task 01 made persistence prune everything over 30 days old (except completed downloads). After a restart, `feed.episodes` therefore only contains the 30-day persisted window; the full cached episode list is rebuilt lazily by the first fetch-more or refresh within the new session. This task makes the session-time behavior correct: fetched refreshes merge (never replace), and the volatile list + cache are bounded by the SAME 30-day window persistence uses — what can be browsed is exactly what can be persisted, and episodes older than the window age out on refresh.
|
||||||
|
- Style: `feed.ts` is tab-indented WITH semicolons. New utils file: match `src/api/rss-parser.ts` style (2-space, no semicolons).
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/utils/feeds-persistence.ts` (the canonical window owner):
|
||||||
|
- Rename the retention constant to `EPISODE_WINDOW_DAYS = 30` — it now bounds the volatile cache/list as well as persistence.
|
||||||
|
- New exported `episodeInWindow(ep: Episode, now: Date): boolean` — `pubDate >= now - EPISODE_WINDOW_DAYS`; missing/invalid pubDates are ALWAYS kept (fail-safe mirror of the persistence rule, so cache and disk can never disagree about an undatable episode). `episodeIsPersistable` becomes `downloadedIds.has(ep.id) || episodeInWindow(ep, now)`.
|
||||||
|
- Rework `src/utils/episode-merge.ts` (pure, store-free, unit-testable):
|
||||||
|
- `mergeEpisodesInWindow(existing: Episode[], fetched: Episode[], now: Date): Episode[]` — union by `ep.id`; on id collision the `fetched` copy wins (fresh metadata); result sorted by `pubDate` descending; pruned to the lifecycle window via `episodeInWindow` (out-of-window episodes dropped, undated kept). No count cap — the bound is the date.
|
||||||
|
- Invariants: never mutates inputs; stable output for `existing=[]`; entries with invalid `pubDate` sort as newest (use `getTime()`, treat `NaN` as `+Infinity` with a small `ts()` helper).
|
||||||
|
- `src/stores/feed.ts`:
|
||||||
|
- Delete `MAX_EPISODES_IN_MEMORY` — no episode-count bound anywhere.
|
||||||
|
- `fetchEpisodes`: window-filter the parsed feed (`allEpisodes.filter(ep => episodeInWindow(ep, new Date()))`) BEFORE caching and returning: `fullEpisodeCache.set(feedId, windowed)` and `episodes: windowed.slice(0, limit)`. The limit is a page size; the window is the bound.
|
||||||
|
- `applyRefreshedEpisodes(prev, feedId, episodes)`: replace the `sameEpisodes` replace-with-fetched logic with merge semantics:
|
||||||
|
- Compute `merged = mergeEpisodesInWindow(f.episodes, episodes, new Date())`.
|
||||||
|
- Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards.
|
||||||
|
- Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`.
|
||||||
|
- Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`).
|
||||||
|
- `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache.
|
||||||
|
- `tests/feed-volatile-merge.test.ts` (reworked) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/feed.ts` fully and `tests/feed-refresh.test.ts` + `tests/feed-pagination.test.ts` (they pin the contracts you must not break; reuse their harness).
|
||||||
|
2. Rework `src/utils/episode-merge.ts` to `mergeEpisodesInWindow`; add `episodeInWindow` (and rename `PERSISTED_WINDOW_DAYS` → `EPISODE_WINDOW_DAYS`) in `feeds-persistence.ts`.
|
||||||
|
3. Integrate in `feed.ts`: replace `sameEpisodes` usage with `sameRefreshWindow` + `mergeEpisodesInWindow` in `applyRefreshedEpisodes`; window-filter `fullEpisodeCache` writes and the returned window in `fetchEpisodes` and `loadMoreEpisodesForFeed`; delete `MAX_EPISODES_IN_MEMORY`.
|
||||||
|
4. Run the existing feed tests — all must pass unchanged (merge must keep order stability and pagination intact).
|
||||||
|
5. Write the new tests, run, then full suite + lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- New `tests/feed-volatile-merge.test.ts`:
|
||||||
|
- Pure unit (Arrange–Act–Assert) for `mergeEpisodesInWindow(existing, fetched, now)`:
|
||||||
|
- dedupe on collision, fetched copy wins (mutate title in the fetched twin, assert the merged entry shows the new title).
|
||||||
|
- union of disjoint lists sorted by `pubDate` desc.
|
||||||
|
- window prune drops out-of-window episodes from BOTH inputs and keeps undated (NaN pubDate) episodes.
|
||||||
|
- input arrays not mutated.
|
||||||
|
- Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers):
|
||||||
|
- Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare).
|
||||||
|
- Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached.
|
||||||
|
- Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there.
|
||||||
|
- No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number.
|
||||||
|
- Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it.
|
||||||
|
- Existing suites that must keep passing: `tests/feed-refresh.test.ts`, `tests/feed-pagination.test.ts`, `tests/feed-refresh-spinner.test.tsx`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- A refresh never removes an episode that was visible before the refresh during the same session — except episodes that aged past the window, which drop out on refresh (the date bound).
|
||||||
|
- An unchanged refresh does not bump `lastUpdated` (object identity of the feed is preserved).
|
||||||
|
- Per-feed cached/parsed episodes are exactly the in-window set: nothing outside the last `EPISODE_WINDOW_DAYS` days is cached or loadable, and everything inside is (no count ceiling).
|
||||||
|
- After a simulated restart (fresh store boot from a pruned config), fetch-more re-parses the feed and applies the same window to the cache.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-volatile-merge.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start`, drill a show in My Shows, fetch-more a few pages, press `r` to refresh — the in-window pages stay; quit and relaunch — the list holds only the 30-day window, and fetch-more re-parses the feed with the same window applied.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on task 01 only conceptually: without the persisted-window prune, this merge is still correct but harder to observe. If 01 isn't merged yet, the store tests still pass; the "restart keeps only 30 days" manual check requires 01.
|
||||||
|
- `fullEpisodeCache`/`episodeLoadCount` are module-level Maps in `feed.ts` — the window filter belongs at the two write sites named in deliverables, not in a wrapper.
|
||||||
|
- Do not touch persistence writes in this task; debounced save behavior is task 03. Keep calling the module-scope `saveFeeds(updated)` helper exactly as today.
|
||||||
84
tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md
Normal file
84
tasks/bounded-feed-lifecycle/03-nonblocking-feed-refresh.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# 03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-03
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P1
|
||||||
|
depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a `Promise.all` barrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, and `config.json` writes (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window.
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- Work lands in `src/stores/feed.ts` only (plus its tests). Current posture:
|
||||||
|
- `refreshAllFeeds()` fires `fetchEpisodes` for every feed at once via `Promise.all` and applies results in ONE `setFeeds` at the end — the user sees nothing until the slowest feed resolves or hits `FETCH_TIMEOUT_MS` (20s).
|
||||||
|
- `parseEpisodesIncremental` already chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path.
|
||||||
|
- `loadMoreEpisodesForFeed`'s cold-cache refetch has NO timeout (copy the `AbortSignal.timeout(FETCH_TIMEOUT_MS)` pattern from `fetchEpisodes`).
|
||||||
|
- Persistence: `saveFeeds(updated)` → `saveFeedsToFile` → `updateConfig`, a serialized full-file read-`JSON.parse`-stringify-`Bun.write` chain in `src/utils/config.ts`. Called from `refreshFeed`, `refreshAllFeeds`, `loadMoreEpisodesForFeed`, `addFeed`, `removeFeed*`, `updateFeed`, `togglePinned`.
|
||||||
|
- The boot IIFE calls `refreshAllFeeds()` right after `loadFeedsFromFile()` — this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves.
|
||||||
|
- Single feed `refreshFeed` applies its own `setFeeds` immediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path.
|
||||||
|
- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through `applyRefreshedEpisodes`/`mergeEpisodes` (02).
|
||||||
|
- Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers `// ── Name ──…` per repo convention.
|
||||||
|
- Tests here use `vi.useFakeTimers()` — `setTimeout`-based debounce must therefore be advanced with `vi.advanceTimersByTime` in tests; don't use `queueMicrotask`-style scheduling for the debounce.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- `src/stores/feed.ts`:
|
||||||
|
- New constant `FETCH_CONCURRENCY = 4` (comment: bounds simultaneous RSS requests; a hung feed burns at most one slot for `FETCH_TIMEOUT_MS`).
|
||||||
|
- New module-level async helper `mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>` — classic worker-pool: `limit` workers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test.
|
||||||
|
- Rewritten `refreshAllFeeds()`:
|
||||||
|
- `setIsLoadingFeeds(true)` … `finally setIsLoadingFeeds(false)` as today.
|
||||||
|
- Process feeds through `mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...)`.
|
||||||
|
- Inside the per-feed callback: `fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id)`; if non-null, immediately `setFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; })`. Failed feeds (null) stay untouched, as today.
|
||||||
|
- After all workers settle: ONE `runAutoDownload()` (as today), and `flushPendingSave()` (below) so a refresh batch always ends with a persisted write when anything changed.
|
||||||
|
- Debounced save plumbing (module scope, replacing direct calls):
|
||||||
|
- `let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null; const SAVE_DEBOUNCE_MS = 250;`
|
||||||
|
- `scheduleSaveFeeds()` — after a state-changing update, mark dirty: set a `savePending = true` flag and (re)arm the trailing timer to fire `flushPendingSave()`.
|
||||||
|
- `flushPendingSave()` — if `savePending`, snapshot `feeds()`, call `saveFeeds(snapshot)`, clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write).
|
||||||
|
- Convert ALL direct `saveFeeds(updated)` / `saveFeeds(newList)` call sites inside `setFeeds` callbacks to `scheduleSaveFeeds()` EXCEPT `removeFeed`/`removeFeedByUrl`, which must call both `scheduleSaveFeeds()` AND `flushPendingSave()` (an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection.
|
||||||
|
- `loadMoreEpisodesForFeed`: add `signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)` to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroring `fetchEpisodes`).
|
||||||
|
- `tests/feed-nonblocking.test.ts` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/feed.ts` and confirm tasks 01/02 are merged (`episodeIsPersistable` in `src/utils/feeds-persistence.ts`, `mergeEpisodes` in `src/utils/episode-merge.ts`).
|
||||||
|
2. Add `FETCH_CONCURRENCY`, `mapWithConcurrency`, and the debounce plumbing.
|
||||||
|
3. Rewrite `refreshAllFeeds` per deliverables; convert the save call sites.
|
||||||
|
4. Add the fetch timeout to `loadMoreEpisodesForFeed`'s cold refetch.
|
||||||
|
5. Export `flushPendingSave` from the store's return object (Actions section).
|
||||||
|
6. Write `tests/feed-nonblocking.test.ts`; run new + existing feed tests; full suite; lint.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- Harness conventions: copy `tests/feed-refresh.test.ts` (temp `XDG_CONFIG_HOME` BEFORE store imports; `Bun.serve` port 0; `vi.useFakeTimers()` in `beforeEach`). Note `vi.advanceTimersByTime(...)` also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment on `yieldToUI`).
|
||||||
|
- New `tests/feed-nonblocking.test.ts`:
|
||||||
|
- Concurrency bound: server records concurrent in-flight requests (increment on entry, `await new Promise(r => setTimeout(r, 50_000))` under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates with `vi.advanceTimersByTime` after asserting). Register 10 feeds; start `refreshAllFeeds()` (don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion.
|
||||||
|
- Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's `lastUpdated`/episodes already updated in `feeds()` BEFORE the slow feed resolves (this is the acceptance proof the `Promise.all` barrier is gone). Then release the slow gate and assert both applied.
|
||||||
|
- Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed; `await` both, then `vi.advanceTimersByTime(SAVE_DEBOUNCE_MS)`; read raw `config.json` ONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle against `updateConfig`'s chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce, `config.json` must NOT yet contain the new episodes; after, it must.)
|
||||||
|
- `flushPendingSave`: refresh with changed content, call `store.flushPendingSave()` without advancing timers, assert `config.json` already contains the new episode.
|
||||||
|
- Existing suites must pass unchanged: `feed-refresh.test.ts`, `feed-pagination.test.ts`, `feed-volatile-merge.test.ts`, `feed-refresh-spinner.test.tsx`, `restore-session.test.ts`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- During a refresh batch, no more than `FETCH_CONCURRENCY` HTTP requests are ever in flight.
|
||||||
|
- Each feed's refreshed episodes are visible in `feeds()` as soon as its own fetch resolves — no waiting for the slowest feed.
|
||||||
|
- Writes to `config.json` are trailing-edge debounced: rapid successive updates produce one final write after the settle window, and `flushPendingSave()` persists immediately.
|
||||||
|
- `loadMoreEpisodesForFeed`'s refetch aborts at `FETCH_TIMEOUT_MS` instead of hanging forever.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.ts`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start` with several subscriptions; hold `j` during the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- The background refresh timer (`scheduleNextRefresh`) already skips ticks while `isLoadingFeeds()` is true — unchanged.
|
||||||
|
- Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites.
|
||||||
|
- `mapWithConcurrency` is generic; keep it module-private in `feed.ts` (no premature new util file).
|
||||||
|
- `updateConfig` snapshots its patch at call time (`JSON.parse(JSON.stringify(patch))`), so debouncing by delaying the `saveFeeds` CALL is correct — a pending write always serializes the latest feeds it was handed.
|
||||||
77
tasks/bounded-feed-lifecycle/04-global-activity-indicator.md
Normal file
77
tasks/bounded-feed-lifecycle/04-global-activity-indicator.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 04. Add a shared activity store and global top-right loading indicator
|
||||||
|
|
||||||
|
meta:
|
||||||
|
id: bounded-feed-lifecycle-04
|
||||||
|
feature: bounded-feed-lifecycle
|
||||||
|
priority: P2
|
||||||
|
depends_on: [bounded-feed-lifecycle-03]
|
||||||
|
tags: [implementation, tests-required]
|
||||||
|
|
||||||
|
objective:
|
||||||
|
|
||||||
|
- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere.
|
||||||
|
|
||||||
|
background (read this before touching code):
|
||||||
|
|
||||||
|
- `src/components/Shell.tsx` renders the whole chrome: one full-width content row (`LayerGraph[nav.activeTab()]()` / `PaneRow`) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentui `box` supports `position="absolute"`, `top`, `right`).
|
||||||
|
- Existing activity signals (read them, don't recreate per-store bookkeeping): `useFeedStore().isLoadingFeeds()` / `.isLoadingMore()`; `useSearchStore().isSearching()` (`src/stores/search.ts`); `useDownloadStore().getActiveCount()` and `.getQueue().length` (`src/stores/download.ts`). Gaps these don't cover: single `refreshFeed`, `addFeed`'s subscribe fetch, iTunes feed resolution inside `addFeed` — hence the activity store.
|
||||||
|
- `src/components/LoadingIndicator.tsx` is the braille spinner (prop `label?: string`); reuse it inside the overlay.
|
||||||
|
- Activity tracking must be leak-proof: every `begin` paired with an `end` via a token, PLUS a `track(promise, label)` helper that auto-ends on settle so callers can't strand the counter.
|
||||||
|
- Task 03 added the incremental per-feed apply inside `refreshAllFeeds`; wire activity around the whole batch ( `isLoadingFeeds` already brackets it — prefer reusing the signal, adding explicit `begin/end` ONLY where no signal exists).
|
||||||
|
- Style: Solid + `@opentui/solid` JSX (no `className`; props like `fg`, `paddingRight`, `position`); store files tab-indented with semicolons; components match `LoadingIndicator.tsx` conventions. Style imports use `@/` alias in components, relative paths in stores.
|
||||||
|
|
||||||
|
deliverables:
|
||||||
|
|
||||||
|
- New `src/stores/activity.ts`:
|
||||||
|
- Signals: `count` (number), `labels` (string[]).
|
||||||
|
- Actions: `beginActivity(label: string): () => void` (returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed), `track<T>(p: Promise<T>, label: string): Promise<T>` (begins, ends in `finally`, re-throws).
|
||||||
|
- Computed: `isActive(): boolean` (`count() > 0`).
|
||||||
|
- Singleton + `useActivityStore()` accessor, mirroring `src/stores/download.ts`'s module pattern.
|
||||||
|
- Wire the gaps in `src/stores/feed.ts` (only where no existing signal covers the operation):
|
||||||
|
- `refreshFeed`: `await activity.track(...)` around the fetch+apply, label `"Refreshing"`.
|
||||||
|
- `addFeed`: wrap the directory-resolve + `fetchEpisodes` stretch, label `"Subscribing"`.
|
||||||
|
- Do NOT wrap `refreshAllFeeds`/`loadMoreEpisodes*` — `isLoadingFeeds`/`isLoadingMore` already cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap).
|
||||||
|
- New `src/components/GlobalActivityIndicator.tsx`:
|
||||||
|
- Computes active state from: `feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive()`.
|
||||||
|
- Label selection: downloads in flight → `Downloading N` (+`M queued` when queue non-empty); else the activity store's latest label + `…` (e.g. `Refreshing…`); else `Loading…`.
|
||||||
|
- Renders `<LoadingIndicator label={…} />` inside `<box position="absolute" top={0} right={0} paddingRight={1}>`; renders nothing (returns `null`) when inactive so it never eats layout when idle.
|
||||||
|
- `src/components/Shell.tsx`: mount `<GlobalActivityIndicator />` as the LAST child of the root `<box flexDirection="column" …>` (after the content row, bottom bar, and help overlay so it paints on top).
|
||||||
|
- `tests/global-activity-indicator.test.tsx` (new) — see tests section.
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
1. Read `src/stores/download.ts`, `src/stores/search.ts`, `src/components/LoadingIndicator.tsx`, and the render JSX of `src/components/Shell.tsx`.
|
||||||
|
2. Write `src/stores/activity.ts` (small; ~60 lines).
|
||||||
|
3. Wire `refreshFeed`/`addFeed` in `src/stores/feed.ts` via `useActivityStore().track(...)`. Import cycle note: `activity.ts` must import NOTHING from other stores (pure counter) so `feed.ts` importing it is safe.
|
||||||
|
4. Write `src/components/GlobalActivityIndicator.tsx`; mount it in `Shell.tsx` last (paints on top).
|
||||||
|
5. Write tests; run new tests, full suite, lint; manual smoke per validation.
|
||||||
|
|
||||||
|
tests:
|
||||||
|
|
||||||
|
- New `tests/global-activity-indicator.test.tsx` (component-test conventions: copy the render harness from `tests/feed-refresh-spinner.test.tsx` — temp `XDG_CONFIG_HOME` before imports; if a jsdom-like setup is used there, reuse it as-is):
|
||||||
|
- Activity store unit asserts: two `begin`s → `isActive()` true; ending one → still true; ending both → false. `track(failingPromise)` still decrements (rejects propagate, counter returns to baseline).
|
||||||
|
- Component asserts: render `<GlobalActivityIndicator />` in isolation —
|
||||||
|
- idle → no text rendered;
|
||||||
|
- `useActivityStore().beginActivity("Refreshing")` → spinner/label present in rendered output; matching end → gone;
|
||||||
|
- with the download store: enqueue via `downloadStore.startDownload`-equivalent the way `tests/download-unsubscribed.test.ts` does (assert indicator renders while `getActiveCount() + queue > 0`); skip actual network by following that test's existing mocking pattern.
|
||||||
|
- Existing suites must pass: `feed-refresh-spinner.test.tsx` (per-page spinners unchanged), full `bun test`.
|
||||||
|
|
||||||
|
acceptance_criteria:
|
||||||
|
|
||||||
|
- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active.
|
||||||
|
- Counter never strands: every completed/failed tracked operation returns `isActive()` to its prior value (proven by the `track` rejection test).
|
||||||
|
- Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout.
|
||||||
|
- `bun test` full suite passes; `bun run lint` clean.
|
||||||
|
|
||||||
|
validation:
|
||||||
|
|
||||||
|
- `bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsx`
|
||||||
|
- `bun test`
|
||||||
|
- `bun run lint`
|
||||||
|
- Manual smoke: `bun start`; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) press `r` on Feed — spinner appears; (c) download an episode from Search — `Downloading` label shows while the transfer runs; (d) leave idle — top-right is empty.
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals.
|
||||||
|
- The overlay intentionally does NOT replace per-pane spinners (`Refreshing…` in Feed/MyShows/Discover/Search stay) — removing those is out of scope.
|
||||||
|
- If `position="absolute"` proves unavailable for text-draw ordering in `@opentui/solid`, the fallback is a dedicated 1-row header (`height={1}`) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message.
|
||||||
27
tasks/bounded-feed-lifecycle/README.md
Normal file
27
tasks/bounded-feed-lifecycle/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Bounded Feed Lifecycle
|
||||||
|
|
||||||
|
Objective: Bound feed episode storage to a rolling 30-day persisted window (older episodes volatile-only unless downloaded), keep feed loading nonblocking, and surface all load/download activity in a global top-right indicator.
|
||||||
|
|
||||||
|
Status legend: [ ] todo, [~] in-progress, [x] done
|
||||||
|
|
||||||
|
Tasks
|
||||||
|
|
||||||
|
- [x] 01 — persisted-retention-window → `01-persisted-retention-window.md`
|
||||||
|
- [x] 02 — volatile-episode-merge → `02-volatile-episode-merge.md`
|
||||||
|
- [x] 03 — nonblocking-feed-refresh → `03-nonblocking-feed-refresh.md`
|
||||||
|
- [x] 04 — global-activity-indicator → `04-global-activity-indicator.md`
|
||||||
|
|
||||||
|
Dependencies
|
||||||
|
|
||||||
|
- 02 depends on 01 (the volatile merge preserves exactly what 01 drops from disk)
|
||||||
|
- 03 depends on 01 (debounced persistence layers onto the pruning save path)
|
||||||
|
- 03 depends on 02 (incremental per-feed apply consumes the merge helper from 02)
|
||||||
|
- 04 depends on 03 (the indicator subscribes to the activity wiring added across refresh/load-more paths in 03)
|
||||||
|
|
||||||
|
Exit criteria
|
||||||
|
|
||||||
|
- After any refresh + save, `config.json` `feeds[*].episodes` contains only episodes with `pubDate` within the last 30 days or episodes marked `completed` in `downloads.json`; loading a legacy config prunes stale episodes on first launch.
|
||||||
|
- The volatile episode list and pagination cache are bounded by the same 30-day window as persistence: only in-window episodes are cached/loadable, and everything in-window is (no episode-count ceiling).
|
||||||
|
- A refresh batch never exceeds a fixed fetch concurrency, applies each feed's result as it lands (no `Promise.all` barrier), and persistence writes are debounced; keyboard input stays responsive throughout.
|
||||||
|
- The top-right indicator is visible iff at least one feed refresh, fetch-more, subscribe fetch, search, or episode download is in flight, hidden otherwise.
|
||||||
|
- `bun test` and `bun run lint` pass.
|
||||||
192
tests/audio-backend.test.ts
Normal file
192
tests/audio-backend.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* MpvBackend resident-daemon contract tests (real mpv process).
|
||||||
|
*
|
||||||
|
* Pins the IPC contract the app's playback depends on:
|
||||||
|
*
|
||||||
|
* 1. play() loads a file and position advances (observed, no polling).
|
||||||
|
* 2. pause()/resume() flip the player-reported pause state through IPC.
|
||||||
|
* 3. seek() lands where asked.
|
||||||
|
* 4. stop() unloads the file but keeps the daemon alive (isAlive stays
|
||||||
|
* true — the daemon model's whole point: no process churn per episode).
|
||||||
|
* 5. preload() parks an episode paused; play() of the SAME url then starts
|
||||||
|
* it by unpausing — the boot-restore fast path with no second load.
|
||||||
|
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||||
|
* resume() afterwards replays from the top.
|
||||||
|
*
|
||||||
|
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||||
|
* tests skip where it is missing.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { MpvBackend } from "../src/utils/audio-player";
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 22050;
|
||||||
|
const FREQ = 440;
|
||||||
|
const AMP = 20000;
|
||||||
|
|
||||||
|
/** Write a WAV file containing `seconds` of a sine at AMP amplitude. */
|
||||||
|
function writeSineWav(path: string, seconds: number): void {
|
||||||
|
const total = Math.round(seconds * SAMPLE_RATE);
|
||||||
|
const dataSize = total * 2;
|
||||||
|
const buf = new Uint8Array(44 + dataSize);
|
||||||
|
const dv = new DataView(buf.buffer);
|
||||||
|
const ascii = (off: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||||
|
};
|
||||||
|
ascii(0, "RIFF");
|
||||||
|
dv.setUint32(4, 36 + dataSize, true);
|
||||||
|
ascii(8, "WAVE");
|
||||||
|
ascii(12, "fmt ");
|
||||||
|
dv.setUint32(16, 16, true);
|
||||||
|
dv.setUint16(20, 1, true);
|
||||||
|
dv.setUint16(22, 1, true);
|
||||||
|
dv.setUint32(24, SAMPLE_RATE, true);
|
||||||
|
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||||
|
dv.setUint16(32, 2, true);
|
||||||
|
dv.setUint16(34, 16, true);
|
||||||
|
ascii(36, "data");
|
||||||
|
dv.setUint32(40, dataSize, true);
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||||
|
dv.setInt16(44 + i * 2, v, true);
|
||||||
|
}
|
||||||
|
Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a predicate until true or the deadline expires. */
|
||||||
|
async function waitFor(
|
||||||
|
label: string,
|
||||||
|
pred: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
if (await pred()) return;
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(`${label}: not true within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMpv = !!Bun.which("mpv");
|
||||||
|
const wavA = join(tmpdir(), `podtui-backend-${process.pid}-a.wav`);
|
||||||
|
const wavB = join(tmpdir(), `podtui-backend-${process.pid}-b.wav`);
|
||||||
|
|
||||||
|
function fixtureWavs(): void {
|
||||||
|
writeSineWav(wavA, 8);
|
||||||
|
writeSineWav(wavB, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(backend: MpvBackend): Promise<void> {
|
||||||
|
backend.dispose();
|
||||||
|
await Bun.$`rm -f ${wavA} ${wavB}`.quiet();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"play / pause / resume / seek over the resident daemon",
|
||||||
|
async () => {
|
||||||
|
fixtureWavs();
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
|
||||||
|
expect(backend.isAlive()).toBe(true);
|
||||||
|
expect(backend.isPlaying()).toBe(true);
|
||||||
|
|
||||||
|
// Observed position advances without any polling from us.
|
||||||
|
await waitFor("position advances", async () => (await backend.getPosition()) > 1.3);
|
||||||
|
expect(await backend.getPauseState()).toBe(false);
|
||||||
|
expect(await backend.getDuration()).toBeGreaterThan(7.5);
|
||||||
|
|
||||||
|
// Pause: reported by the player's own state, position stalls.
|
||||||
|
await backend.pause();
|
||||||
|
await waitFor("paused state observed", async () => (await backend.getPauseState()) === true);
|
||||||
|
const posAtPause = await backend.getPosition();
|
||||||
|
await Bun.sleep(400);
|
||||||
|
expect(Math.abs((await backend.getPosition()) - posAtPause)).toBeLessThan(0.3);
|
||||||
|
|
||||||
|
// Resume: clock advances again.
|
||||||
|
await backend.resume();
|
||||||
|
await waitFor("resumed state observed", async () => (await backend.getPauseState()) === false);
|
||||||
|
await waitFor(
|
||||||
|
"position advances after resume",
|
||||||
|
async () => (await backend.getPosition()) > posAtPause + 0.3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seek lands where asked.
|
||||||
|
await backend.seek(6);
|
||||||
|
await waitFor(
|
||||||
|
"seek observed",
|
||||||
|
async () => Math.abs((await backend.getPosition()) - 6) < 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop unloads the file — but the daemon stays resident.
|
||||||
|
await backend.stop();
|
||||||
|
expect(backend.isPlaying()).toBe(false);
|
||||||
|
expect(backend.isAlive()).toBe(true);
|
||||||
|
expect(await backend.getPosition()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await cleanup(backend);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"preload parks the episode paused; play() of the same url starts it by unpausing",
|
||||||
|
async () => {
|
||||||
|
fixtureWavs();
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.preload(wavB, { volume: 0, speed: 1, startPosition: 2 });
|
||||||
|
// Parked: paused, at the requested offset, nothing advancing.
|
||||||
|
await waitFor(
|
||||||
|
"preload observed paused",
|
||||||
|
async () => (await backend.getPauseState()) === true,
|
||||||
|
);
|
||||||
|
const parkedPos = await backend.getPosition();
|
||||||
|
expect(parkedPos).toBeGreaterThan(1.5);
|
||||||
|
expect(backend.isPlaying()).toBe(false);
|
||||||
|
await Bun.sleep(400);
|
||||||
|
expect(Math.abs((await backend.getPosition()) - parkedPos)).toBeLessThan(0.3);
|
||||||
|
|
||||||
|
// The boot-restore fast path: play() unpauses instead of re-loading.
|
||||||
|
await backend.play(wavB, { volume: 0, speed: 1, startPosition: parkedPos });
|
||||||
|
expect(backend.isPlaying()).toBe(true);
|
||||||
|
await waitFor(
|
||||||
|
"preload fast path plays",
|
||||||
|
async () => (await backend.getPosition()) > parkedPos + 0.3,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanup(backend);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"EOF marks playback ended; resume() then replays from the top",
|
||||||
|
async () => {
|
||||||
|
const wavShort = join(tmpdir(), `podtui-backend-${process.pid}-short.wav`);
|
||||||
|
writeSineWav(wavShort, 2);
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.play(wavShort, { volume: 0, speed: 2 });
|
||||||
|
// 2s at 2x ends in ~1s+startup. isPlaying() must drop on its own.
|
||||||
|
await waitFor("episode ended", async () => !backend.isPlaying());
|
||||||
|
|
||||||
|
// Play pressed on a finished episode replays from the top.
|
||||||
|
await backend.resume();
|
||||||
|
await waitFor("replay started", async () => backend.isPlaying());
|
||||||
|
await waitFor(
|
||||||
|
"replay position near start",
|
||||||
|
async () => (await backend.getPosition()) < 3 && backend.isPlaying(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
backend.dispose();
|
||||||
|
await Bun.$`rm -f ${wavShort}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
364
tests/audio-pcm-cache.test.ts
Normal file
364
tests/audio-pcm-cache.test.ts
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
/**
|
||||||
|
* EpisodePcmCache position-index contract tests.
|
||||||
|
*
|
||||||
|
* The visualizer's bars are served from a position-indexed PCM cache that
|
||||||
|
* ffmpeg fills at full speed. These tests pin the observable contracts the
|
||||||
|
* fragile paced-ring design kept breaking:
|
||||||
|
*
|
||||||
|
* 1. readWindow(out, at) serves the EXACT window ending at playback time
|
||||||
|
* `at` — position mapping is sample-precise, independent of how fast or
|
||||||
|
* far the decode has run.
|
||||||
|
* 2. Reads outside decoded coverage return 0 — the renderer HOLDS the last
|
||||||
|
* frame. (The old reader CLAMPED to a stale buffer; re-rendering the
|
||||||
|
* same window decayed cava into a frozen junk pattern after pause.)
|
||||||
|
* 3. pauseDecode kills ffmpeg but keeps the cache: resume serves bars
|
||||||
|
* instantly, ensureDecodeAround restarts the tail decode.
|
||||||
|
* 4. Seeking into an undecoded region starts a new segment there WITHOUT
|
||||||
|
* invalidating the previously decoded coverage.
|
||||||
|
*
|
||||||
|
* Uses a self-generated WAV (440Hz sine, mono, 22050Hz s16le — the cache's
|
||||||
|
* native rate) so expected samples are computed analytically with no
|
||||||
|
* resampler tolerance.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache";
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 22050;
|
||||||
|
const FREQ = 440;
|
||||||
|
const AMP = 30000;
|
||||||
|
|
||||||
|
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
||||||
|
function writeSineWav(path: string, seconds: number): void {
|
||||||
|
const total = Math.round(seconds * SAMPLE_RATE);
|
||||||
|
const dataSize = total * 2;
|
||||||
|
const buf = new Uint8Array(44 + dataSize);
|
||||||
|
const dv = new DataView(buf.buffer);
|
||||||
|
const ascii = (off: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||||
|
};
|
||||||
|
ascii(0, "RIFF");
|
||||||
|
dv.setUint32(4, 36 + dataSize, true);
|
||||||
|
ascii(8, "WAVE");
|
||||||
|
ascii(12, "fmt ");
|
||||||
|
dv.setUint32(16, 16, true);
|
||||||
|
dv.setUint16(20, 1, true); // PCM
|
||||||
|
dv.setUint16(22, 1, true); // mono
|
||||||
|
dv.setUint32(24, SAMPLE_RATE, true);
|
||||||
|
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||||
|
dv.setUint16(32, 2, true);
|
||||||
|
dv.setUint16(34, 16, true);
|
||||||
|
ascii(36, "data");
|
||||||
|
dv.setUint32(40, dataSize, true);
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||||
|
dv.setInt16(44 + i * 2, v, true);
|
||||||
|
}
|
||||||
|
Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analytic sample value at a file index, matching the writer's formula. */
|
||||||
|
function expectedAt(fileIndex: number): number {
|
||||||
|
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block until the cache covers playback time `sec`. */
|
||||||
|
async function waitForCoverage(
|
||||||
|
cache: EpisodePcmCache,
|
||||||
|
sec: number,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!cache.covers(sec)) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(`cache did not cover ${sec}s in time`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block until the furthest decode pass has hit stream EOF. */
|
||||||
|
async function waitForFinished(
|
||||||
|
cache: EpisodePcmCache,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!cache.decodeFinished) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("decode did not finish in time");
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpWav(): string {
|
||||||
|
return join(tmpdir(), `podtui-pcm-${process.pid}-${Math.floor(Math.random() * 1e9)}.wav`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 60);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 1);
|
||||||
|
|
||||||
|
// Skipping 45s ahead while the pass still crawls at 4x must restart
|
||||||
|
// the segment at the target — waiting for the frontier to chew
|
||||||
|
// through the skipped region is minutes of frozen bars.
|
||||||
|
cache.ensureDecodeAround(45);
|
||||||
|
expect(cache.decoding).toBe(true);
|
||||||
|
expect(cache.activeDecodeBaseSec).toBe(45);
|
||||||
|
await waitForCoverage(cache, 45.1);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"small forward gap closes in place — no needless reconnect",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 60);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 2);
|
||||||
|
|
||||||
|
// ~5s past the running frontier: at 4x pacing this closes in ~1.5s,
|
||||||
|
// cheaper than a reconnect — the pass must NOT restart.
|
||||||
|
const target = cache.coverageEndSec + 5;
|
||||||
|
cache.ensureDecodeAround(target);
|
||||||
|
expect(cache.activeDecodeBaseSec).toBe(0);
|
||||||
|
await waitForCoverage(cache, target);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"readWindow serves the exact window ending at the requested position",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(5);
|
||||||
|
await waitForCoverage(cache, 6.5);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 5.1)).toBe(512);
|
||||||
|
// Window ENDS at the target: out[i] is the sample at
|
||||||
|
// round(5.1*SR) - (len-1) + i (5s offset + 0.1s).
|
||||||
|
const endIdx = Math.round(5.1 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 5ms later window is the same stream shifted by exactly
|
||||||
|
// round(0.005*SR)=110 samples — pins position mapping precision.
|
||||||
|
const later = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(later, 5.105)).toBe(512);
|
||||||
|
for (let i = 0; i <= 512 - 111; i++) {
|
||||||
|
expect(later[i]).toBe(out[i + 110]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"reads outside decoded coverage return 0 (renderer holds last frame, never stale junk)",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(5);
|
||||||
|
await waitForCoverage(cache, 5.5);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
out.fill(-999);
|
||||||
|
|
||||||
|
// Beyond the decode frontier.
|
||||||
|
expect(cache.readWindow(out, 999)).toBe(0);
|
||||||
|
// Before the segment base (decode started at 5s).
|
||||||
|
expect(cache.readWindow(out, 4.0)).toBe(0);
|
||||||
|
// Buffer untouched — no partial/stale samples leak through.
|
||||||
|
for (let i = 0; i < 16; i++) expect(out[i]).toBe(-999);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"pauseDecode keeps the cache: resume serves instantly, tail decode continues",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 12); // short: full tail decode lands well under a second
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 1.5);
|
||||||
|
|
||||||
|
// Pause: decode dies, cache must survive.
|
||||||
|
cache.pauseDecode();
|
||||||
|
expect(cache.decoding).toBe(false);
|
||||||
|
expect(cache.covers(1)).toBe(true);
|
||||||
|
|
||||||
|
// Serve from cache immediately after pause — this is the resume
|
||||||
|
// fast path: zero ffmpeg cold start.
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 1.0)).toBe(512);
|
||||||
|
const endIdx = Math.round(1.0 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume: tail decode restarts and eventually covers the file.
|
||||||
|
cache.ensureDecodeAround(1.0);
|
||||||
|
await waitForFinished(cache);
|
||||||
|
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(11.9);
|
||||||
|
expect(cache.readWindow(out, 11.5)).toBe(512);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"seek into an undecoded region starts a new segment without losing earlier coverage",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
// Decoded the back half only...
|
||||||
|
cache.startDecode(10);
|
||||||
|
await waitForCoverage(cache, 11);
|
||||||
|
expect(cache.covers(2)).toBe(false);
|
||||||
|
|
||||||
|
// ...then the user seeks to 2s: a new segment decodes the front,
|
||||||
|
// and the back-half coverage stays valid throughout.
|
||||||
|
cache.ensureDecodeAround(2);
|
||||||
|
await waitForCoverage(cache, 2.2);
|
||||||
|
expect(cache.covers(10.5)).toBe(true);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 10.5)).toBe(512);
|
||||||
|
const endIdx = Math.round(10.5 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"decode head caps at maxAheadSec ahead of the cursor — the cache is a window, not a whole-episode dump",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({
|
||||||
|
url: wav,
|
||||||
|
maxAheadSec: 4,
|
||||||
|
keepBehindSec: 2,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
// The 8s initial burst delivers the front of the file instantly.
|
||||||
|
await waitForCoverage(cache, 5);
|
||||||
|
|
||||||
|
// Park the cursor at 0 and drive the cap (the render loop reads
|
||||||
|
// every frame; the cap applies on the first read past the head).
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
for (let i = 0; i < 30 && cache.decoding; i++) {
|
||||||
|
cache.readWindow(out, 0);
|
||||||
|
await Bun.sleep(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paused at the head budget (4s) + one 8s burst of slack — NOT
|
||||||
|
// decoded to the 30s EOF.
|
||||||
|
expect(cache.decoding).toBe(false);
|
||||||
|
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(cache.coverageEndSec).toBeLessThanOrEqual(4 + 8 + 1);
|
||||||
|
expect(cache.decodeFinished).toBe(false);
|
||||||
|
|
||||||
|
// A parked cursor keeps the cap: more reads must not restart
|
||||||
|
// the pass or grow the cache.
|
||||||
|
const cappedAt = cache.coverageEndSec;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
cache.readWindow(out, 0);
|
||||||
|
await Bun.sleep(20);
|
||||||
|
}
|
||||||
|
expect(cache.decoding).toBe(false);
|
||||||
|
expect(cache.coverageEndSec).toBeLessThanOrEqual(cappedAt + 1);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"the window prunes segments behind the cursor as playback advances",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({
|
||||||
|
url: wav,
|
||||||
|
maxAheadSec: 4,
|
||||||
|
keepBehindSec: 2,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// Two segments: the back half [10, ~18] and, after the seek,
|
||||||
|
// the front [2, ~10].
|
||||||
|
cache.startDecode(10);
|
||||||
|
await waitForCoverage(cache, 11);
|
||||||
|
cache.ensureDecodeAround(2);
|
||||||
|
await waitForCoverage(cache, 2.2);
|
||||||
|
expect(cache.covers(2.5)).toBe(true);
|
||||||
|
expect(cache.covers(10.5)).toBe(true);
|
||||||
|
|
||||||
|
// Cursor advances past the front segment's end + keepBehind:
|
||||||
|
// the front must fall out of the window, the back must survive.
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
for (let i = 0; i < 40 && cache.covers(2.5); i++) {
|
||||||
|
cache.readWindow(out, 13);
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
expect(cache.covers(2.5)).toBe(false);
|
||||||
|
expect(cache.covers(10.5)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/**
|
|
||||||
* AudioStreamReader sync contract tests.
|
|
||||||
*
|
|
||||||
* The visualizer's bars must track the player's position in real time even
|
|
||||||
* though the reader is an independent ffmpeg process. These tests pin the
|
|
||||||
* two mechanisms that make that true:
|
|
||||||
*
|
|
||||||
* 1. `read(out, target)` serves the FFT window *at* the requested playback
|
|
||||||
* position — not at the decode head, which drifts from the player
|
|
||||||
* (startup skew, stalls).
|
|
||||||
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
|
|
||||||
* the decode head keeps up with the position at any playback speed —
|
|
||||||
* native-rate pacing falls behind by (speed-1)s per second.
|
|
||||||
*
|
|
||||||
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
|
|
||||||
* expected samples can be computed analytically and compared exactly.
|
|
||||||
*/
|
|
||||||
import { test, expect } from "bun:test";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
|
|
||||||
|
|
||||||
const SAMPLE_RATE = 44100;
|
|
||||||
const FREQ = 440;
|
|
||||||
const AMP = 30000;
|
|
||||||
|
|
||||||
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
|
||||||
function writeSineWav(path: string, seconds: number): void {
|
|
||||||
const total = Math.round(seconds * SAMPLE_RATE);
|
|
||||||
const dataSize = total * 2;
|
|
||||||
const buf = new Uint8Array(44 + dataSize);
|
|
||||||
const dv = new DataView(buf.buffer);
|
|
||||||
const ascii = (off: number, s: string) => {
|
|
||||||
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
|
||||||
};
|
|
||||||
ascii(0, "RIFF");
|
|
||||||
dv.setUint32(4, 36 + dataSize, true);
|
|
||||||
ascii(8, "WAVE");
|
|
||||||
ascii(12, "fmt ");
|
|
||||||
dv.setUint32(16, 16, true);
|
|
||||||
dv.setUint16(20, 1, true); // PCM
|
|
||||||
dv.setUint16(22, 1, true); // mono
|
|
||||||
dv.setUint32(24, SAMPLE_RATE, true);
|
|
||||||
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
|
||||||
dv.setUint16(32, 2, true);
|
|
||||||
dv.setUint16(34, 16, true);
|
|
||||||
ascii(36, "data");
|
|
||||||
dv.setUint32(40, dataSize, true);
|
|
||||||
for (let i = 0; i < total; i++) {
|
|
||||||
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
|
||||||
dv.setInt16(44 + i * 2, v, true);
|
|
||||||
}
|
|
||||||
Bun.write(path, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Analytic sample value at a file index, matching the writer's formula. */
|
|
||||||
function expectedAt(fileIndex: number): number {
|
|
||||||
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block until the reader's decode head has advanced past `samples` samples.
|
|
||||||
* The head advances at readrate × real time, so this bounds how long we wait.
|
|
||||||
*/
|
|
||||||
async function waitForHead(
|
|
||||||
reader: AudioStreamReader,
|
|
||||||
samples: number,
|
|
||||||
timeoutMs = 8000,
|
|
||||||
): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
while (reader.samplesWritten < samples) {
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error("reader decode head did not advance in time");
|
|
||||||
}
|
|
||||||
await Bun.sleep(25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"read() serves the exact window at the requested position",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(5, 1);
|
|
||||||
// Cover targets up to ~5.6s (head must pass the read target).
|
|
||||||
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
|
|
||||||
// Window at 5.1s: the window ENDS at the target, so out[i] is at
|
|
||||||
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
|
|
||||||
expect(reader.read(out, 5.1)).toBe(512);
|
|
||||||
for (let i = 0; i < 512; i++) {
|
|
||||||
const idx =
|
|
||||||
Math.round(5 * SAMPLE_RATE) +
|
|
||||||
Math.round((5.1 - 5) * SAMPLE_RATE) -
|
|
||||||
(out.length - 1) +
|
|
||||||
i;
|
|
||||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window at 5.105s is the same stream shifted by exactly
|
|
||||||
// round(0.005*SR)=221 samples — pins that the target maps to a
|
|
||||||
// precise offset, not "whatever the decode head is at".
|
|
||||||
const later = new Float64Array(512);
|
|
||||||
expect(reader.read(later, 5.105)).toBe(512);
|
|
||||||
for (let i = 0; i <= 512 - 222; i++) {
|
|
||||||
expect(later[i]).toBe(out[i + 221]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"decode keeps up with the player clock at 2x speed",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 2);
|
|
||||||
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
|
|
||||||
// With native-rate pacing it would only be at ~1.25s, and the
|
|
||||||
// window at 2.5s would clamp to the head — content mismatch.
|
|
||||||
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
expect(reader.read(out, 2.5)).toBe(512);
|
|
||||||
for (let i = 0; i < 512; i++) {
|
|
||||||
const idx =
|
|
||||||
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
|
|
||||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"read() clamps to the nearest samples when the target is beyond the head",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 1);
|
|
||||||
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
// Target far beyond the decode head: serve the newest available
|
|
||||||
// window (real sine samples, never zeros or garbage).
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
expect(reader.read(out, 999)).toBe(512);
|
|
||||||
const maxAbs = Math.max(...Array.from(out, Math.abs));
|
|
||||||
expect(maxAbs).toBeGreaterThan(10000);
|
|
||||||
for (const v of out) {
|
|
||||||
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
|
|
||||||
async () => {
|
|
||||||
// Real wall-clock time is required here: this test validates ffmpeg's
|
|
||||||
// actual decode pacing (-readrate + -readrate_initial_burst) against
|
|
||||||
// the platform clock. Deterministic time control cannot reproduce the
|
|
||||||
// race where ffmpeg exits early and the bars freeze — that only
|
|
||||||
// surfaces when a real process writes to a real pipe.
|
|
||||||
//
|
|
||||||
// Simulates the actual render loop: for ~5s of wall time, advance a
|
|
||||||
// simulated player position at 1× realtime and call read() each frame.
|
|
||||||
// The decode head must stay ahead of the player position so read()
|
|
||||||
// always returns 512 samples, and ffmpeg must not exit early (which
|
|
||||||
// would freeze the bars). This test would have caught the
|
|
||||||
// backpressure-pacing failure where ffmpeg decoded all data into the
|
|
||||||
// pipe buffer instantly, exited, and the readLoop stopped.
|
|
||||||
const wav = join(
|
|
||||||
tmpdir(),
|
|
||||||
`podtui-reader-${process.pid}-${Date.now()}.wav`,
|
|
||||||
);
|
|
||||||
writeSineWav(wav, 30);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 1);
|
|
||||||
|
|
||||||
const FRAME_MS = 33;
|
|
||||||
const DURATION_MS = 5000;
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
let successes = 0;
|
|
||||||
let failures = 0;
|
|
||||||
let minLead = Infinity;
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
|
|
||||||
const playerPos = (Date.now() - start) / 1000;
|
|
||||||
const count = reader.read(out, playerPos);
|
|
||||||
if (count === 512) successes++;
|
|
||||||
else failures++;
|
|
||||||
|
|
||||||
// The decode head should stay ahead of the player position.
|
|
||||||
const headPos = reader.samplesWritten / SAMPLE_RATE;
|
|
||||||
const lead = headPos - playerPos;
|
|
||||||
if (frame > 3) minLead = Math.min(minLead, lead);
|
|
||||||
|
|
||||||
await Bun.sleep(FRAME_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ffmpeg must still be running — it must not have exited early.
|
|
||||||
expect(reader.running).toBe(true);
|
|
||||||
|
|
||||||
// The vast majority of frames should return a full window.
|
|
||||||
// A few early failures during ffmpeg startup are acceptable.
|
|
||||||
expect(failures).toBeLessThan(5);
|
|
||||||
expect(successes).toBeGreaterThan(100);
|
|
||||||
|
|
||||||
// The decode head must maintain a positive lead over the player.
|
|
||||||
// Without -readrate_initial_burst, the head would lag behind by
|
|
||||||
// the ffmpeg startup latency and never catch up.
|
|
||||||
expect(minLead).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 15000 },
|
|
||||||
);
|
|
||||||
77
tests/cover-art.test.ts
Normal file
77
tests/cover-art.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Cover-art disk-cache contract tests.
|
||||||
|
*
|
||||||
|
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
|
||||||
|
* playback never waits on the network for art it has already fetched. Pins:
|
||||||
|
*
|
||||||
|
* 1. A fetch stores the bytes on disk and returns the cache path.
|
||||||
|
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
|
||||||
|
* the server again (request count stays 1).
|
||||||
|
* 3. Concurrent fetches of the same URL share one download (single-flight).
|
||||||
|
* 4. A failed fetch (404) resolves null instead of throwing.
|
||||||
|
*
|
||||||
|
* Served from a local Bun server — no external network dependence. Cache
|
||||||
|
* entries created here are removed afterwards.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { unlinkSync } from "fs";
|
||||||
|
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
|
||||||
|
|
||||||
|
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
|
||||||
|
|
||||||
|
test("cover art is fetched once, cached on disk, and shared", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
requests++;
|
||||||
|
if (new URL(req.url).pathname === "/missing.jpg") {
|
||||||
|
return new Response("nope", { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(FAKE_JPEG, {
|
||||||
|
headers: { "content-type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
|
||||||
|
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
|
||||||
|
let cachedPath: string | null = null;
|
||||||
|
try {
|
||||||
|
expect(cachedCoverPath(url)).toBeNull();
|
||||||
|
|
||||||
|
// First fetch: downloads and caches.
|
||||||
|
cachedPath = await fetchCoverArt(url);
|
||||||
|
expect(cachedPath).not.toBeNull();
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
|
||||||
|
|
||||||
|
// Second fetch: disk hit, server untouched.
|
||||||
|
expect(await fetchCoverArt(url)).toBe(cachedPath);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
|
||||||
|
// Single-flight: parallel misses of a fresh URL make ONE request.
|
||||||
|
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
]);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(b).toBe(c);
|
||||||
|
if (a) unlinkSync(a);
|
||||||
|
|
||||||
|
// 404 resolves null, never throws.
|
||||||
|
expect(await fetchCoverArt(missing)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
if (cachedPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(cachedPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -171,6 +171,20 @@ test("dispatch('move-up') emits nav.action on the current pane only (j/k never c
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("dispatch('subscribe') on a depth-tab current pane emits nav.action (page-local, like unsubscribe)", () => {
|
||||||
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
nav.setActiveTab(TABS.SEARCH);
|
||||||
|
nav.enterTabContent();
|
||||||
|
expect(nav.activePane()).toBe(DEPTH_CENTER_PANE);
|
||||||
|
|
||||||
|
const events = captureNavActions(() => dispatch("subscribe"));
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].action).toBe("subscribe");
|
||||||
|
expect(events[0].tab).toBe(TABS.SEARCH);
|
||||||
|
expect(events[0].pane).toBe(DEPTH_CENTER_PANE);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
// ── Integration: l drills (open emit), h pops, h@0 → tab root ────────────────
|
||||||
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
test("dispatch('swipe-next') on a depth-tab at depth 0 emits 'open' (drill)", () => {
|
||||||
withHarness(({ nav, dispatch }) => {
|
withHarness(({ nav, dispatch }) => {
|
||||||
|
|||||||
182
tests/download-unsubscribed.test.ts
Normal file
182
tests/download-unsubscribed.test.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Unsubscribed-show download tests — the download store contract behind the
|
||||||
|
* "Unsubscribed Show Downloads" list (My Shows depth 0 and the settings
|
||||||
|
* Download Manager):
|
||||||
|
*
|
||||||
|
* 1. startUnsubscribedDownload records the episode under a deterministic
|
||||||
|
* synthetic feed id with the show's metadata, and
|
||||||
|
* getUnsubscribedDownloads lists it.
|
||||||
|
* 2. A download made under a real (subscribed) feed id is NOT listed as
|
||||||
|
* unsubscribed.
|
||||||
|
* 3. Subscribing to the show re-classifies its unsubscribed download into
|
||||||
|
* the subscribed group — it drops out of getUnsubscribedDownloads.
|
||||||
|
* 4. removeDownloadsForFeed with the show's feed URL removes that show's
|
||||||
|
* unsubscribed downloads too (unsubscribing purges search downloads).
|
||||||
|
*
|
||||||
|
* Served over a real local HTTP server, mirroring how the app's other store
|
||||||
|
* tests exercise the network path. The store singleton is shared with other
|
||||||
|
* test files, so every added feed/download is removed in afterAll.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config/data dirs at throwaway directories BEFORE importing the
|
||||||
|
// stores (their module-level init reads them).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
const dataHome = mkdtempSync(join(tmpdir(), "podtui-unsubdl-data-"));
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
|
||||||
|
import { useDownloadStore } from "../src/stores/download";
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let audioUrl = "";
|
||||||
|
const addedFeedIds: string[] = [];
|
||||||
|
const addedEpisodeIds: string[] = [];
|
||||||
|
|
||||||
|
/** Minimal RSS feed for one show. */
|
||||||
|
function feedXml(title: string, origin: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>${title}</title>
|
||||||
|
<description>Test feed</description>
|
||||||
|
<item>
|
||||||
|
<title>Ep 1</title>
|
||||||
|
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||||
|
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeEpisode = (id: string, title: string): Episode => ({
|
||||||
|
id,
|
||||||
|
podcastId: "pod",
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
audioUrl,
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string, title: string): Podcast => ({
|
||||||
|
id: `dir-${title}`,
|
||||||
|
title,
|
||||||
|
description: "Test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml("Test Show", url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("audio bytes", {
|
||||||
|
headers: { "Content-Type": "audio/mpeg" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const id of addedEpisodeIds) {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
dl.cancelDownload(id);
|
||||||
|
dl.removeDownload(id).catch(() => {});
|
||||||
|
}
|
||||||
|
for (const id of addedFeedIds) {
|
||||||
|
useFeedStore().removeFeed(id);
|
||||||
|
}
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
rmSync(dataHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startUnsubscribedDownload records a synthetic-feed download with show metadata", () => {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const episode = makeEpisode("unsub-ep-1", "Ep 1");
|
||||||
|
const podcast = makePodcast("https://example.com/feed.xml", "Unsub Show");
|
||||||
|
addedEpisodeIds.push(episode.id);
|
||||||
|
|
||||||
|
dl.startUnsubscribedDownload(episode, podcast);
|
||||||
|
|
||||||
|
const listed = dl.getUnsubscribedDownloads();
|
||||||
|
const mine = listed.find((d) => d.episodeId === episode.id);
|
||||||
|
expect(mine).toBeDefined();
|
||||||
|
expect(mine!.feedId).toBe("unsub-https-example-com-feed-xml");
|
||||||
|
expect(mine!.podcastTitle).toBe("Unsub Show");
|
||||||
|
expect(mine!.podcastFeedUrl).toBe("https://example.com/feed.xml");
|
||||||
|
expect(mine!.episodeTitle).toBe("Ep 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("downloads under a real feed id are not listed as unsubscribed", async () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/subbed.xml`;
|
||||||
|
const feed = await feedStore.addFeed(makePodcast(feedUrl, "Subbed"), "test");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
addedFeedIds.push(feed!.id);
|
||||||
|
|
||||||
|
const episode = makeEpisode("subbed-ep-1", "Ep 1");
|
||||||
|
addedEpisodeIds.push(episode.id);
|
||||||
|
dl.startDownload(episode, feed!.id);
|
||||||
|
|
||||||
|
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subscribing to the show re-classifies its unsubscribed download", async () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/later.xml`;
|
||||||
|
const episode = makeEpisode("unsub-ep-later", "Ep 1");
|
||||||
|
const podcast = makePodcast(feedUrl, "Later Show");
|
||||||
|
addedEpisodeIds.push(episode.id);
|
||||||
|
|
||||||
|
// Downloaded while unsubscribed.
|
||||||
|
dl.startUnsubscribedDownload(episode, podcast);
|
||||||
|
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribing later (same feed URL) moves it into the subscribed group.
|
||||||
|
const feed = await feedStore.addFeed(podcast, "test");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
addedFeedIds.push(feed!.id);
|
||||||
|
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removeDownloadsForFeed purges the show's unsubscribed downloads by feed URL", async () => {
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/purge.xml`;
|
||||||
|
const episode = makeEpisode("unsub-ep-purge", "Ep 1");
|
||||||
|
addedEpisodeIds.push(episode.id);
|
||||||
|
|
||||||
|
dl.startUnsubscribedDownload(episode, makePodcast(feedUrl, "Purge Show"));
|
||||||
|
expect(dl.getUnsubscribedDownloads().some((d) => d.episodeId === episode.id)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unsubscribe the show: the feed is gone, but its URL still identifies
|
||||||
|
// the search downloads made while it was unsubscribed.
|
||||||
|
await dl.removeDownloadsForFeed("no-such-feed-id", feedUrl);
|
||||||
|
expect(dl.getAllDownloads().some((d) => d.episodeId === episode.id)).toBe(false);
|
||||||
|
});
|
||||||
264
tests/external-pause-reconcile.test.ts
Normal file
264
tests/external-pause-reconcile.test.ts
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* external-pause-reconcile.test.ts — "audio paused outside PodTUI must not
|
||||||
|
* freeze the player tab" regression test.
|
||||||
|
*
|
||||||
|
* The OS can pause the player without PodTUI knowing: system sleep/lock,
|
||||||
|
* AirPod removal / device swap, OS media keys, the Now Playing center. mpv
|
||||||
|
* flips its own `pause` property and keeps it there. Before the fix,
|
||||||
|
* useAudio's signals stayed on "playing" — [Pause] button shown while
|
||||||
|
* silent, a poll that only re-read the same frozen `time-pos` (stuck
|
||||||
|
* waveform), and no way to catch an external RESUME either (the poll was
|
||||||
|
* stopped whenever the UI thought it was paused).
|
||||||
|
*
|
||||||
|
* Integration style (like restore-session.test.ts): real stores and real
|
||||||
|
* persistence files in a temp XDG_CONFIG_HOME — but with the REAL mpv
|
||||||
|
* backend driven over its actual IPC socket. The test flips mpv's pause
|
||||||
|
* property the same way the OS does and asserts useAudio reconciles in
|
||||||
|
* both directions. Skipped when mpv isn't installed.
|
||||||
|
*
|
||||||
|
* Real-timer note: the reconcile path runs on useAudio's real 150ms poll
|
||||||
|
* interval against a real mpv process, with no injectable clock — the
|
||||||
|
* deliberate-exception case from the no-real-timers rule (same as
|
||||||
|
* visualizer-store.test.ts). `waitFor` polls with Bun.sleep.
|
||||||
|
*
|
||||||
|
* Shared-worker note (same as restore-session.test.ts): the suite reuses
|
||||||
|
* bun test workers, so other files' `mock.module("../src/hooks/useAudio")`
|
||||||
|
* leaks into this file's module registry. The REAL useAudio is therefore
|
||||||
|
* imported via a `?external-pause-test` query suffix — a distinct module
|
||||||
|
* identity bun loads from disk, bypassing the leaked mock.
|
||||||
|
*/
|
||||||
|
import { test, expect, afterAll } from "bun:test";
|
||||||
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
const hasMpv = !!Bun.which("mpv");
|
||||||
|
|
||||||
|
// ── Sandbox BEFORE any app module evaluates (mirrors restore-session) ────
|
||||||
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-extpause-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||||
|
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-extpause-data-"));
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "mpv"; // real backend; the test drives mpv's IPC
|
||||||
|
const APP_CONFIG = join(CONFIG, "podtui");
|
||||||
|
mkdirSync(APP_CONFIG, { recursive: true });
|
||||||
|
|
||||||
|
// Seed one feed so the app store boots cleanly. No coverUrl — the play()
|
||||||
|
// path skips cover-art fetching. The RSS URL is unreachable so the
|
||||||
|
// background refresh fails fast and leaves the seeded data untouched.
|
||||||
|
const ISO = "2026-08-10T00:00:00.000Z";
|
||||||
|
const feed = {
|
||||||
|
id: "feed1",
|
||||||
|
podcast: {
|
||||||
|
id: "pod1",
|
||||||
|
title: "Pod One",
|
||||||
|
description: "",
|
||||||
|
feedUrl: "http://127.0.0.1:1/show.xml",
|
||||||
|
lastUpdated: ISO,
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: [],
|
||||||
|
visibility: "public",
|
||||||
|
sourceId: "test",
|
||||||
|
lastUpdated: ISO,
|
||||||
|
isPinned: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
join(APP_CONFIG, "config.json"),
|
||||||
|
JSON.stringify({ feeds: [feed] }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Local 60s WAV so playback is hermetic (no network, no early EOF) ─────
|
||||||
|
const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
|
||||||
|
{
|
||||||
|
const SAMPLE_RATE = 44100;
|
||||||
|
const DURATION = 60;
|
||||||
|
const dataLen = SAMPLE_RATE * DURATION; // mono 16-bit
|
||||||
|
const buf = Buffer.alloc(44 + dataLen * 2);
|
||||||
|
buf.write("RIFF", 0);
|
||||||
|
buf.writeUInt32LE(36 + dataLen * 2, 4);
|
||||||
|
buf.write("WAVE", 8);
|
||||||
|
buf.write("fmt ", 12);
|
||||||
|
buf.writeUInt32LE(16, 16); // fmt chunk size
|
||||||
|
buf.writeUInt16LE(1, 20); // PCM
|
||||||
|
buf.writeUInt16LE(1, 22); // mono
|
||||||
|
buf.writeUInt32LE(SAMPLE_RATE, 24);
|
||||||
|
buf.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
|
||||||
|
buf.writeUInt16LE(2, 32); // block align
|
||||||
|
buf.writeUInt16LE(16, 34); // bits per sample
|
||||||
|
buf.write("data", 36);
|
||||||
|
buf.writeUInt32LE(dataLen * 2, 40);
|
||||||
|
for (let i = 0; i < dataLen; i++) {
|
||||||
|
const sample = Math.round(
|
||||||
|
Math.sin((2 * Math.PI * 440 * i) / SAMPLE_RATE) * 8000,
|
||||||
|
);
|
||||||
|
buf.writeInt16LE(sample, 44 + i * 2);
|
||||||
|
}
|
||||||
|
writeFileSync(wavPath, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Real modules (loaded after env + sandbox are set up) ──────────────────
|
||||||
|
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||||
|
// loads the real file instead of a leaked mock.module from another test file.
|
||||||
|
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The socket path of the LIVE backend daemon in this process. The backend
|
||||||
|
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
|
||||||
|
* tmpdir for this pid's sockets and take the newest (the one mpv actually
|
||||||
|
* bound — earlier instances may have been orphaned by a re-spawn).
|
||||||
|
*/
|
||||||
|
function mpvSocket(): string | null {
|
||||||
|
let newest: string | null = null;
|
||||||
|
let newestMtime = 0;
|
||||||
|
for (const name of readdirSync(tmpdir())) {
|
||||||
|
if (
|
||||||
|
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
|
||||||
|
!name.endsWith(".sock")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = join(tmpdir(), name);
|
||||||
|
const mtime = statSync(candidate).mtimeMs;
|
||||||
|
if (mtime > newestMtime) {
|
||||||
|
newest = candidate;
|
||||||
|
newestMtime = mtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a raw mpv IPC command over the unix socket — exactly how the OS
|
||||||
|
* media session pauses/resumes mpv without PodTUI's involvement.
|
||||||
|
*/
|
||||||
|
async function mpvCommand(command: unknown[]): Promise<void> {
|
||||||
|
const socket = mpvSocket();
|
||||||
|
if (!socket) throw new Error("backend mpv socket not found");
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
|
let settled = false;
|
||||||
|
const settle = (err: Error | null): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
Bun.connect({
|
||||||
|
unix: socket,
|
||||||
|
socket: {
|
||||||
|
open(s) {
|
||||||
|
s.write(JSON.stringify({ command }) + "\n");
|
||||||
|
},
|
||||||
|
data() {},
|
||||||
|
error() {
|
||||||
|
settle(new Error("mpv IPC connect failed"));
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
settle(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).then((s) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
s.end();
|
||||||
|
} catch {}
|
||||||
|
}, 150),
|
||||||
|
);
|
||||||
|
// Never hang the test on a vanished socket.
|
||||||
|
setTimeout(() => settle(null), 1000);
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll `check` every 25ms until truthy; throw after `timeoutMs`. */
|
||||||
|
async function waitFor(
|
||||||
|
check: () => boolean,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!check()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("condition not met in time");
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const episode = {
|
||||||
|
id: "ep1",
|
||||||
|
podcastId: "pod1",
|
||||||
|
title: "Episode One",
|
||||||
|
description: "desc",
|
||||||
|
audioUrl: wavPath,
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"external pause flips the UI to paused; external resume recovers",
|
||||||
|
async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
await audio.play(episode);
|
||||||
|
expect(audio.isPlaying()).toBe(true);
|
||||||
|
|
||||||
|
// Simulate the OS pausing the session (lock/sleep, AirPod removal,
|
||||||
|
// device swap, media-center pause): flip mpv's own pause property
|
||||||
|
// over IPC. PodTUI is never told.
|
||||||
|
await mpvCommand(["set_property", "pause", true]);
|
||||||
|
await waitFor(() => !audio.isPlaying());
|
||||||
|
expect(audio.isPlaying()).toBe(false);
|
||||||
|
// The episode stays loaded — nothing was torn down.
|
||||||
|
expect(audio.currentEpisode()?.id).toBe("ep1");
|
||||||
|
|
||||||
|
// Simulate an external resume (AirPod play tap, media-center play).
|
||||||
|
await mpvCommand(["set_property", "pause", false]);
|
||||||
|
await waitFor(() => audio.isPlaying());
|
||||||
|
expect(audio.isPlaying()).toBe(true);
|
||||||
|
|
||||||
|
// The TUI transport still works from the reconciled state.
|
||||||
|
await audio.togglePlayback();
|
||||||
|
expect(audio.isPlaying()).toBe(false);
|
||||||
|
await audio.togglePlayback();
|
||||||
|
expect(audio.isPlaying()).toBe(true);
|
||||||
|
|
||||||
|
await audio.stop();
|
||||||
|
expect(audio.isPlaying()).toBe(false);
|
||||||
|
},
|
||||||
|
{ timeout: 30000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Teardown ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
try {
|
||||||
|
useAudio().stop();
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
// The resident daemon survives stop() by design — quit it so test
|
||||||
|
// workers don't leak idle mpv processes.
|
||||||
|
try {
|
||||||
|
await mpvCommand(["quit"]);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const socket = mpvSocket();
|
||||||
|
if (socket) rmSync(socket, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rmSync(wavPath, { force: true });
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
});
|
||||||
323
tests/feed-nonblocking.test.ts
Normal file
323
tests/feed-nonblocking.test.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Non-blocking feed refresh tests — task 03 of the bounded-feed-lifecycle
|
||||||
|
* feature.
|
||||||
|
*
|
||||||
|
* Pins the contracts that make a refresh batch feel non-blocking:
|
||||||
|
*
|
||||||
|
* 1. refreshAllFeeds never holds more than FETCH_CONCURRENCY (4) RSS
|
||||||
|
* requests in flight — a worker pool bounds the batch instead of
|
||||||
|
* Promise.all firing every feed at once.
|
||||||
|
* 2. Each feed's refreshed episodes are applied AS ITS OWN FETCH LANDS —
|
||||||
|
* the old Promise.all barrier is gone, so a slow feed no longer hides
|
||||||
|
* the fast feeds' fresh episodes.
|
||||||
|
* 3. config.json writes are trailing-edge debounced (rapid changes
|
||||||
|
* collapse into one final write) and flushPendingSave() persists
|
||||||
|
* immediately, without waiting out the debounce window.
|
||||||
|
*
|
||||||
|
* Polling note (why the polls below use setImmediate, not microtasks):
|
||||||
|
* vi's fake timers trap setTimeout/setInterval/Date/Bun.sleep, so the
|
||||||
|
* debounce is driven with vi.advanceTimersByTime. But a poll loop of pure
|
||||||
|
* microtask turns (`await Promise.resolve()`) can NEVER observe an
|
||||||
|
* in-flight refresh: it keeps the microtask queue non-empty, the event
|
||||||
|
* loop's poll phase is never reached, and Bun.serve never even receives
|
||||||
|
* the fetch (verified empirically). setImmediate is a real macrotask that
|
||||||
|
* fake timers do NOT trap, and it lets the socket I/O progress — each
|
||||||
|
* `tick()` below is one bounded event-loop turn. No real sleeps anywhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-nonblocking-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
import { whenConfigIdle } from "../src/utils/config";
|
||||||
|
|
||||||
|
interface ServedEpisode {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
|
|
||||||
|
/** Per-pathname request gates: while a path has an unresolved gate, the
|
||||||
|
* server parks that request until the test resolves it. */
|
||||||
|
let gates = new Map<string, { gate: Promise<void>; resolve: () => void }>();
|
||||||
|
/** Requests currently inside the fetch handler (entered, not yet answered). */
|
||||||
|
let inFlight = 0;
|
||||||
|
/** High-water mark of `inFlight` — the concurrency-bound assertion source. */
|
||||||
|
let maxConcurrent = 0;
|
||||||
|
|
||||||
|
// Bun runs test files in ONE process, so the store singleton is shared with
|
||||||
|
// other test files. Track the feeds we add and remove them in afterAll so
|
||||||
|
// whichever file runs next sees a pristine store.
|
||||||
|
const addedFeedIds: string[] = [];
|
||||||
|
/** Feed created by the debounce test, reused by the flushPendingSave test. */
|
||||||
|
let debounceFeedId = "";
|
||||||
|
|
||||||
|
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||||
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
|
const items = episodes
|
||||||
|
.map(
|
||||||
|
(ep, i) => `<item>
|
||||||
|
<title>${ep.title}</title>
|
||||||
|
<pubDate>${ep.date}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Non-Blocking Test Show</title>
|
||||||
|
<description>Non-blocking refresh test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string): Podcast => ({
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Non-Blocking Test Show",
|
||||||
|
description: "Non-blocking refresh test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Park a request path behind an unresolved gate. */
|
||||||
|
function setGate(path: string): void {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
gates.set(path, { gate: promise, resolve });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve every gate currently set. */
|
||||||
|
function releaseAllGates(): void {
|
||||||
|
for (const { resolve } of gates.values()) resolve();
|
||||||
|
gates.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One real macrotask turn — see the polling note in the header. */
|
||||||
|
const tick = (): Promise<void> => {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setImmediate(resolve);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Poll `cond` across up to `iterations` event-loop turns (one setImmediate
|
||||||
|
* each). Returns whether the condition held by the deadline. */
|
||||||
|
async function pollUntil(
|
||||||
|
cond: () => boolean,
|
||||||
|
iterations = 500,
|
||||||
|
): Promise<boolean> {
|
||||||
|
for (let i = 0; i < iterations; i++) {
|
||||||
|
if (cond()) return true;
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
return cond();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw config.json text ("" when the file does not exist yet). */
|
||||||
|
const readConfigRaw = (): Promise<string> =>
|
||||||
|
Bun.file(join(process.env.XDG_CONFIG_HOME!, "podtui", "config.json"))
|
||||||
|
.text()
|
||||||
|
.catch(() => "");
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
inFlight++;
|
||||||
|
if (inFlight > maxConcurrent) maxConcurrent = inFlight;
|
||||||
|
try {
|
||||||
|
const gate = gates.get(url.pathname);
|
||||||
|
if (gate) await gate.gate;
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
} finally {
|
||||||
|
inFlight--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
gates.clear();
|
||||||
|
inFlight = 0;
|
||||||
|
maxConcurrent = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const store = useFeedStore();
|
||||||
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshAllFeeds never exceeds FETCH_CONCURRENCY in-flight requests", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Bound Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const urls = Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, n) => `http://127.0.0.1:${server!.port}/bound-${n}.xml`,
|
||||||
|
);
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const url of urls) {
|
||||||
|
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
ids.push(feed!.id);
|
||||||
|
addedFeedIds.push(feed!.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate every path so the batch's requests pile up at the server. addFeed
|
||||||
|
// ran sequentially above (its own fetches never exceed 1 in flight), so
|
||||||
|
// the counter below measures the batch alone.
|
||||||
|
for (const url of urls) setGate(new URL(url).pathname);
|
||||||
|
inFlight = 0;
|
||||||
|
maxConcurrent = 0;
|
||||||
|
|
||||||
|
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||||
|
const sawBound = await pollUntil(() => maxConcurrent >= 4);
|
||||||
|
expect(sawBound).toBe(true);
|
||||||
|
// The worker pool caps the batch at 4 — exactly 4 gated requests are
|
||||||
|
// parked (nothing has been released, so nothing completed yet), and
|
||||||
|
// nothing may exceed the bound, now or as the batch drains.
|
||||||
|
expect(maxConcurrent).toBe(4);
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||||
|
|
||||||
|
releaseAllGates();
|
||||||
|
await refreshPromise;
|
||||||
|
expect(maxConcurrent).toBeLessThanOrEqual(4);
|
||||||
|
for (const id of ids) {
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refreshAllFeeds applies each feed as its own fetch lands (no barrier)", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const aUrl = `http://127.0.0.1:${server!.port}/incr-a.xml`;
|
||||||
|
const bUrl = `http://127.0.0.1:${server!.port}/incr-b.xml`;
|
||||||
|
const a = await store.addFeed(makePodcast(aUrl), "test-source");
|
||||||
|
const b = await store.addFeed(makePodcast(bUrl), "test-source");
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
const aId = a!.id;
|
||||||
|
const bId = b!.id;
|
||||||
|
addedFeedIds.push(aId, bId);
|
||||||
|
|
||||||
|
// A new episode appears for both feeds; B's fetch is parked at the
|
||||||
|
// server, A's is not.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Incr Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Incr Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
];
|
||||||
|
setGate(new URL(bUrl).pathname);
|
||||||
|
|
||||||
|
const beforeA = store.getFeed(aId)!.lastUpdated.getTime();
|
||||||
|
const beforeB = store.getFeed(bId)!.lastUpdated.getTime();
|
||||||
|
// Advance the (mocked) clock so the refresh's `new Date()` lastUpdated
|
||||||
|
// bump is observably greater than beforeA (the fake clock otherwise
|
||||||
|
// never moves — same pattern as feed-refresh.test.ts).
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
const refreshPromise = store.refreshAllFeeds(); // NOT awaited
|
||||||
|
|
||||||
|
const applied = await pollUntil(
|
||||||
|
() => store.getFeed(aId)!.lastUpdated.getTime() > beforeA,
|
||||||
|
);
|
||||||
|
expect(applied).toBe(true);
|
||||||
|
// A's refreshed window is visible in feeds() while B is STILL gated —
|
||||||
|
// the proof that per-feed results apply as they land.
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(1);
|
||||||
|
expect(store.getFeed(bId)!.lastUpdated.getTime()).toBe(beforeB);
|
||||||
|
|
||||||
|
releaseAllGates();
|
||||||
|
await refreshPromise;
|
||||||
|
expect(store.getFeed(aId)!.episodes.length).toBe(2);
|
||||||
|
expect(store.getFeed(bId)!.episodes.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("config.json writes are trailing-edge debounced (two refreshes, one save)", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" }];
|
||||||
|
const url = `http://127.0.0.1:${server!.port}/debounce.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(url), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
debounceFeedId = feed!.id;
|
||||||
|
addedFeedIds.push(debounceFeedId);
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(3);
|
||||||
|
|
||||||
|
// No timer advanced: the debounced saves have NOT fired — the refreshed
|
||||||
|
// episodes exist only in memory (await whenConfigIdle first so a
|
||||||
|
// straggler write from an earlier test cannot race this read).
|
||||||
|
await whenConfigIdle();
|
||||||
|
const before = await readConfigRaw();
|
||||||
|
expect(before).not.toContain("Deb Ep 1");
|
||||||
|
expect(before).not.toContain("Deb Ep 2");
|
||||||
|
|
||||||
|
// SAVE_DEBOUNCE_MS = 250 (module-private in feed.ts — hardcoded here).
|
||||||
|
vi.advanceTimersByTime(250);
|
||||||
|
await whenConfigIdle();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await whenConfigIdle();
|
||||||
|
|
||||||
|
// One read, both refreshed episodes: the two refreshes collapsed into a
|
||||||
|
// single trailing-edge write.
|
||||||
|
const after = await readConfigRaw();
|
||||||
|
expect(after).toContain("Deb Ep 1");
|
||||||
|
expect(after).toContain("Deb Ep 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flushPendingSave persists immediately, without waiting out the debounce", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
// Same feed as the debounce test (still in the singleton): serve a 4th
|
||||||
|
// episode and refresh — the save is scheduled, then flushed by hand.
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Deb Ep 0", date: "2026-08-10T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 1", date: "2026-08-09T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 2", date: "2026-08-08T00:00:00Z" },
|
||||||
|
{ title: "Deb Ep 3", date: "2026-08-07T00:00:00Z" },
|
||||||
|
];
|
||||||
|
await store.refreshFeed(debounceFeedId);
|
||||||
|
expect(store.getFeed(debounceFeedId)!.episodes.length).toBe(4);
|
||||||
|
|
||||||
|
// No advanceTimersByTime: flushPendingSave must write right now.
|
||||||
|
store.flushPendingSave();
|
||||||
|
await whenConfigIdle();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await whenConfigIdle();
|
||||||
|
|
||||||
|
const raw = await readConfigRaw();
|
||||||
|
expect(raw).toContain("Deb Ep 3");
|
||||||
|
});
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
* row in a drilled show's episode list (My Shows depth 1) and the Feed
|
||||||
* page's row.
|
* page's row.
|
||||||
*
|
*
|
||||||
* addFeed caches the FULL parsed feed while exposing only the first
|
* addFeed caches every episode inside the lifecycle window (the last
|
||||||
* MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` reports when the
|
* EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing
|
||||||
* cache holds more than the loaded window; `loadMoreEpisodes` advances that
|
* only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes`
|
||||||
* window in MAX_EPISODES_REFRESH (50) chunks until it is exhausted. This
|
* reports when the cache holds more than the loaded window;
|
||||||
* pins:
|
* `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50)
|
||||||
|
* chunks until it is exhausted. This pins:
|
||||||
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
* 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes.
|
||||||
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
* 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch
|
||||||
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
* needed) and hasMoreEpisodes flips false once the window reaches the end.
|
||||||
@@ -27,6 +28,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
|||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
|
||||||
interface ServedEpisode {
|
interface ServedEpisode {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -94,10 +97,12 @@ afterAll(() => {
|
|||||||
|
|
||||||
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
test("loadMoreEpisodes advances one feed's window from the cache, then no-ops", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// 60 episodes: 20 shown at subscribe, 40 held back in the cache.
|
// 60 episodes: 20 shown at subscribe, 40 held back in the cache. All
|
||||||
|
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||||
|
// cacheable — the cache bound is the date window, not a count.
|
||||||
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||||
title: `Ep ${60 - i}`,
|
title: `Ep ${60 - i}`,
|
||||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||||
}));
|
}));
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
@@ -123,9 +128,10 @@ test("loadMoreEpisodes advances one feed's window from the cache, then no-ops",
|
|||||||
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
test("hasMoreEpisodes stays true across chunked loads until the end", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
// 120 episodes: 20 shown, 100 cached — two 50-episode chunks remaining.
|
||||||
|
// All inside the lifecycle window (5h apart = 25 days).
|
||||||
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
servedEpisodes = Array.from({ length: 120 }, (_, i) => ({
|
||||||
title: `Ep ${120 - i}`,
|
title: `Ep ${120 - i}`,
|
||||||
date: new Date(Date.UTC(2026, 0, 1 + i)).toISOString(),
|
date: new Date(Date.now() - (120 - i) * 5 * HOUR).toISOString(),
|
||||||
}));
|
}));
|
||||||
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
const feedUrl = `http://127.0.0.1:${server!.port}/paged-chunked.xml`;
|
||||||
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
|||||||
162
tests/feed-refresh-spinner.test.tsx
Normal file
162
tests/feed-refresh-spinner.test.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* FeedPage refresh spinner — while feeds are being fetched (manual `r` and
|
||||||
|
* the background refresh timer both route through refreshAllFeeds →
|
||||||
|
* isLoadingFeeds), a braille spinner renders at the BOTTOM of the episode
|
||||||
|
* list, horizontally centered in the current pane.
|
||||||
|
*
|
||||||
|
* The refresh is left in flight on purpose (the test server delays its
|
||||||
|
* response) so the loading state is visible in the captured frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it) and silence the audio backend.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-spinner-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
|
import { NavigationProvider } from "../src/context/NavigationContext";
|
||||||
|
import { FeedPage } from "../src/pages/Feed/FeedPage";
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
// The LoadingIndicator glyph cycle.
|
||||||
|
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||||
|
|
||||||
|
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||||
|
const frameLines = (f: Frame): string[] =>
|
||||||
|
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||||
|
|
||||||
|
let server: Server<undefined> | null = null;
|
||||||
|
/** Response delay (ms) for the next fetch — 0 during setup, >0 while the
|
||||||
|
* refresh is in flight so the loading state is observable. */
|
||||||
|
let delayMs = 0;
|
||||||
|
let feedUrl = "";
|
||||||
|
let feedId = "";
|
||||||
|
|
||||||
|
/** 3 episodes × 3 rows = 9 list rows: the spinner sits right below them.
|
||||||
|
* Dated inside the lifecycle window (1–3 days ago) so all three render. */
|
||||||
|
function feedXml(origin: string): string {
|
||||||
|
const items = Array.from({ length: 3 }, (_, i) => `<item>
|
||||||
|
<title>Spin Ep ${3 - i}</title>
|
||||||
|
<pubDate>${new Date(Date.now() - (3 - i) * 24 * 3600 * 1000).toISOString()}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`).join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Spinner Show</title>
|
||||||
|
<description>spinner test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (!url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
}
|
||||||
|
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve(
|
||||||
|
new Response(feedXml(url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
delayMs,
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const podcast: Podcast = {
|
||||||
|
id: "",
|
||||||
|
title: "Spinner Show",
|
||||||
|
description: "spinner test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
};
|
||||||
|
feedUrl = `http://127.0.0.1:${server.port}/spinner.xml`;
|
||||||
|
const store = useFeedStore();
|
||||||
|
const feed = await store.addFeed(
|
||||||
|
{ ...podcast, feedUrl },
|
||||||
|
"test-source",
|
||||||
|
);
|
||||||
|
feedId = feed!.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
store.removeFeed(feedId);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh spinner renders at the bottom of the list, centered in the current pane", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<NavigationProvider>
|
||||||
|
<FeedPage />
|
||||||
|
</NavigationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ width: 100, height: 30, useThread: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Settle until the episode list is mounted.
|
||||||
|
let lines: string[] | null = null;
|
||||||
|
for (let i = 0; i < 40 && !lines; i++) {
|
||||||
|
await setup.renderOnce();
|
||||||
|
const ls = frameLines(setup.captureSpans() as unknown as Frame);
|
||||||
|
if (ls.some((l) => l.includes("Spin Ep 3"))) lines = ls;
|
||||||
|
else await new Promise((r) => setTimeout(r, 50));
|
||||||
|
}
|
||||||
|
if (!lines) throw new Error("FeedPage did not render episodes before timeout");
|
||||||
|
|
||||||
|
// Kick off a refresh and leave it in flight: isLoadingFeeds flips true
|
||||||
|
// synchronously, so the very next frame shows the spinner.
|
||||||
|
delayMs = 400;
|
||||||
|
const refreshing = store.refreshAllFeeds();
|
||||||
|
await setup.renderOnce();
|
||||||
|
const loading = frameLines(setup.captureSpans() as unknown as Frame);
|
||||||
|
|
||||||
|
// Locate the spinner row and the current pane's borders ("│" columns;
|
||||||
|
// only the current pane is bordered in PaneRow).
|
||||||
|
const spinnerRow = loading.findIndex((l) => SPINNER_RE.test(l));
|
||||||
|
expect(spinnerRow).toBeGreaterThan(-1);
|
||||||
|
|
||||||
|
const spinnerCol = loading[spinnerRow].search(SPINNER_RE);
|
||||||
|
const borderCols = loading
|
||||||
|
.map((l, i) => (i <= spinnerRow ? [...l].map((ch, x) => (ch === "│" ? x : -1)) : []))
|
||||||
|
.flat()
|
||||||
|
.filter((x) => x >= 0);
|
||||||
|
const paneLeft = Math.min(...borderCols);
|
||||||
|
const paneRight = Math.max(...borderCols);
|
||||||
|
const paneCenter = (paneLeft + paneRight) / 2;
|
||||||
|
expect(paneLeft).toBeGreaterThan(0); // borders actually found
|
||||||
|
|
||||||
|
// Bottom of the list: below the last episode row.
|
||||||
|
const lastEpRow = loading.findLastIndex((l) => l.includes("Spin Ep"));
|
||||||
|
expect(spinnerRow).toBeGreaterThan(lastEpRow);
|
||||||
|
|
||||||
|
// Horizontally centered in the current pane (not left-padded).
|
||||||
|
expect(Math.abs(spinnerCol - paneCenter)).toBeLessThanOrEqual(8);
|
||||||
|
|
||||||
|
// Let the refresh finish so teardown is clean.
|
||||||
|
delayMs = 0;
|
||||||
|
await refreshing;
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ process.env.XDG_CONFIG_HOME = configHome;
|
|||||||
import { useFeedStore } from "../src/stores/feed";
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
import type { Podcast } from "../src/types/podcast";
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
|
||||||
interface ServedEpisode {
|
interface ServedEpisode {
|
||||||
title: string;
|
title: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -37,6 +39,8 @@ interface ServedEpisode {
|
|||||||
let server: ReturnType<typeof Bun.serve> | null = null;
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
let servedEpisodes: ServedEpisode[] = [];
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
let feedAId = "";
|
let feedAId = "";
|
||||||
|
/** When set, the server 503s this path — simulates a feed going down. */
|
||||||
|
let failPath: string | null = null;
|
||||||
|
|
||||||
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
/** XML for the current served episode list (episode ids = feedUrl#index). */
|
||||||
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
@@ -72,6 +76,9 @@ beforeAll(() => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
fetch(req) {
|
fetch(req) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
if (failPath && url.pathname === failPath) {
|
||||||
|
return new Response("feed unavailable", { status: 503 });
|
||||||
|
}
|
||||||
if (url.pathname.endsWith(".xml")) {
|
if (url.pathname.endsWith(".xml")) {
|
||||||
return new Response(feedXml(servedEpisodes, url.origin), {
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
headers: { "Content-Type": "application/rss+xml" },
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
@@ -130,6 +137,37 @@ test("refresh with a genuinely new episode bumps lastUpdated", async () => {
|
|||||||
expect(after.episodes.length).toBe(4);
|
expect(after.episodes.length).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a failed refresh does not wipe the feed's episodes", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const savedEpisodes = servedEpisodes;
|
||||||
|
servedEpisodes = [{ title: "Ep 1", date: "2026-08-01T00:00:00Z" }];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/flaky.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const feedId = feed!.id;
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||||
|
|
||||||
|
// The feed now 503s. fetchEpisodes returns null, and both refresh paths
|
||||||
|
// must leave the loaded episodes untouched — a failed refresh must never
|
||||||
|
// look like an empty feed (which would wipe the show's episodes).
|
||||||
|
failPath = "/flaky.xml";
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(feedId);
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshAllFeeds();
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(1);
|
||||||
|
|
||||||
|
failPath = null;
|
||||||
|
store.removeFeed(feedId);
|
||||||
|
// Restore the shared served content: with union merge semantics (volatile
|
||||||
|
// episodes survive refreshes) this feed keeps its larger in-memory window,
|
||||||
|
// so later tests must serve the same episodes they added — a shrink here
|
||||||
|
// would make the next test's "unchanged" refresh genuinely different.
|
||||||
|
servedEpisodes = savedEpisodes;
|
||||||
|
});
|
||||||
|
|
||||||
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () => {
|
||||||
const store = useFeedStore();
|
const store = useFeedStore();
|
||||||
// Feed B: distinct URL, identical served content, so refreshing it is a
|
// Feed B: distinct URL, identical served content, so refreshing it is a
|
||||||
@@ -153,3 +191,46 @@ test("refreshAllFeeds keeps unchanged feeds' order and timestamps", async () =>
|
|||||||
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
expect(store.getFeed(id)!.lastUpdated.getTime()).toBe(tsBefore[id]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh parses in bounded chunks, yielding to the event loop between them", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
// 60 episodes: a chunked parse (25/chunk) must yield between chunks; a
|
||||||
|
// monolithic parse would complete without yielding at all. All dated
|
||||||
|
// inside the lifecycle window (11h apart ≈ 27.5 days) so every one is
|
||||||
|
// cacheable and the window assertions below hold.
|
||||||
|
servedEpisodes = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
title: `Ep ${60 - i}`,
|
||||||
|
date: new Date(Date.now() - (60 - i) * 11 * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/chunky.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
const feedId = feed!.id;
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(20); // subscribe window
|
||||||
|
|
||||||
|
// Count event-loop yields during the refresh: each parse-chunk boundary
|
||||||
|
// posts through a MessageChannel (the yield primitive in feed.ts — the
|
||||||
|
// one macrotask turn bun's fake timers do not trap, which also pins that
|
||||||
|
// the yield works under fake timers). This runs under fake timers like
|
||||||
|
// the other tests; a setTimeout-based yield would deadlock here.
|
||||||
|
const OriginalMessageChannel = globalThis.MessageChannel;
|
||||||
|
let posts = 0;
|
||||||
|
globalThis.MessageChannel = class extends OriginalMessageChannel {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
posts++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(feedId);
|
||||||
|
} finally {
|
||||||
|
globalThis.MessageChannel = OriginalMessageChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(posts).toBeGreaterThan(0);
|
||||||
|
expect(store.getFeed(feedId)!.episodes.length).toBe(50); // refresh window
|
||||||
|
|
||||||
|
// Leave the shared singleton as we found it (see the addedFeedIds note
|
||||||
|
// in feed-pagination.test.ts — bun runs test files in one process).
|
||||||
|
store.removeFeed(feedId);
|
||||||
|
});
|
||||||
|
|||||||
286
tests/feed-retention.test.ts
Normal file
286
tests/feed-retention.test.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Bounded-feed-lifecycle persistence tests — task 01 (retention window).
|
||||||
|
*
|
||||||
|
* Pins the persistence contract:
|
||||||
|
* 1. saveFeedsToFile never writes an episode older than DEFAULT_EPISODE_WINDOW_DAYS
|
||||||
|
* unless its id is a completed download in downloads.json.
|
||||||
|
* 2. loadFeedsFromFile prunes over-window episodes from legacy configs and
|
||||||
|
* rewrites config.json when it pruned anything.
|
||||||
|
* 3. Undatable episodes (missing/invalid pubDate) are always persisted.
|
||||||
|
*
|
||||||
|
* The async saveFeedsToFile IIFE reads downloads.json then enqueues an
|
||||||
|
* updateConfig write on the serialized write chain, so assertions wait via
|
||||||
|
* whenConfigIdle() + a short real-timer settle (no fake timers here — see
|
||||||
|
* settleWrites).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, afterAll } from "bun:test";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing anything
|
||||||
|
// under test (config-dir reads XDG_CONFIG_HOME lazily, but stay consistent
|
||||||
|
// with the store test harness). Do NOT import the feed store — its module
|
||||||
|
// boot IIFE would hit the network.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-retention-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_EPISODE_WINDOW_DAYS,
|
||||||
|
episodeIsPersistable,
|
||||||
|
loadFeedsFromFile,
|
||||||
|
saveFeedsToFile,
|
||||||
|
} from "../src/utils/feeds-persistence";
|
||||||
|
import { whenConfigIdle } from "../src/utils/config";
|
||||||
|
import { FeedVisibility } from "../src/types/feed";
|
||||||
|
import type { Feed } from "../src/types/feed";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
const configJsonPath = join(configHome, "podtui", "config.json");
|
||||||
|
const downloadsJsonPath = join(configHome, "podtui", "downloads.json");
|
||||||
|
|
||||||
|
/** Milliseconds in one day — mirrors the window math in feeds-persistence. */
|
||||||
|
const DAY = 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
function makeEpisode(partial: Partial<Episode> & { id: string }): Episode {
|
||||||
|
return {
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: partial.id,
|
||||||
|
description: "",
|
||||||
|
audioUrl: `https://example.com/audio/${partial.id}.mp3`,
|
||||||
|
duration: 600,
|
||||||
|
pubDate: new Date(),
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFeed(episodes: Episode[]): Feed {
|
||||||
|
return {
|
||||||
|
id: "feed-1",
|
||||||
|
podcast: {
|
||||||
|
id: "feed-1",
|
||||||
|
title: "Retention Show",
|
||||||
|
description: "Retention test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "https://example.com/feed.xml",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes,
|
||||||
|
visibility: FeedVisibility.PUBLIC,
|
||||||
|
sourceId: "source-1",
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isPinned: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the fire-and-forget save chain to drain. saveFeedsToFile's IIFE is
|
||||||
|
* not awaitable: it reads downloads.json first and only THEN enqueues its
|
||||||
|
* write on the serialized chain, so the first whenConfigIdle() may observe
|
||||||
|
* the chain BEFORE the write is queued. A real-timer settle is the only way
|
||||||
|
* to let the IIFE's async read land without fake timers (which would stall
|
||||||
|
* the Bun.file I/O and the write chain itself); the poll fallback in the
|
||||||
|
* assertions absorbs any residual scheduling skew on a loaded machine.
|
||||||
|
*/
|
||||||
|
async function settleWrites(): Promise<void> {
|
||||||
|
await whenConfigIdle();
|
||||||
|
await delay(20);
|
||||||
|
await whenConfigIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Episode ids of the first feed in config.json, or null when absent. */
|
||||||
|
async function readPersistedEpisodeIds(): Promise<string[] | null> {
|
||||||
|
const raw = await Bun.file(configJsonPath).json().catch(() => null);
|
||||||
|
if (!raw || typeof raw !== "object" || !("feeds" in raw)) return null;
|
||||||
|
const feeds = raw.feeds;
|
||||||
|
if (!Array.isArray(feeds) || feeds.length === 0) return null;
|
||||||
|
const first = feeds[0];
|
||||||
|
if (!first || typeof first !== "object" || !("episodes" in first)) return null;
|
||||||
|
const episodes = first.episodes;
|
||||||
|
if (!Array.isArray(episodes)) return null;
|
||||||
|
return episodes.map((ep) => {
|
||||||
|
if (ep && typeof ep === "object" && "id" in ep) return String(ep.id);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wait (up to ~1s) until config.json's first feed has exactly these ids. */
|
||||||
|
async function pollConfigFor(ids: string[]): Promise<void> {
|
||||||
|
const expected = [...ids].sort().join(",");
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
for (;;) {
|
||||||
|
const actual = (await readPersistedEpisodeIds())?.sort().join(",");
|
||||||
|
if (actual === expected) return;
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
throw new Error(
|
||||||
|
`config.json never reached expected episode ids [${ids.join(", ")}]`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await delay(20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Unit: episodeIsPersistable ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("episodeIsPersistable drops a 70-day-old episode that is not downloaded", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "old-plain-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
});
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps a 70-day-old episode whose id is a completed download", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "old-downloaded-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
episodeIsPersistable(ep, new Set(["old-downloaded-id"]), new Date()),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps a 5-day-old episode", () => {
|
||||||
|
const ep = makeEpisode({
|
||||||
|
id: "recent-id",
|
||||||
|
pubDate: new Date(Date.now() - 5 * DAY),
|
||||||
|
});
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("episodeIsPersistable keeps an episode with an invalid pubDate", () => {
|
||||||
|
const ep = makeEpisode({ id: "undatable-id", pubDate: new Date(NaN) });
|
||||||
|
expect(episodeIsPersistable(ep, new Set(), new Date())).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DEFAULT_EPISODE_WINDOW_DAYS is 60", () => {
|
||||||
|
expect(DEFAULT_EPISODE_WINDOW_DAYS).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Save path: retention window applied with completed-download exemption ──
|
||||||
|
|
||||||
|
test("saveFeedsToFile prunes over-window episodes but keeps completed downloads", async () => {
|
||||||
|
// Arrange: downloads.json lists one completed download.
|
||||||
|
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||||
|
await Bun.write(
|
||||||
|
downloadsJsonPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
episodeId: "old-downloaded-id",
|
||||||
|
feedId: "feed-1",
|
||||||
|
status: "completed",
|
||||||
|
filePath: null,
|
||||||
|
downloadedAt: null,
|
||||||
|
fileSize: 0,
|
||||||
|
error: null,
|
||||||
|
audioUrl: "",
|
||||||
|
episodeTitle: "",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act: recent episode, old plain episode, old downloaded episode.
|
||||||
|
const feed = makeFeed([
|
||||||
|
makeEpisode({
|
||||||
|
id: "recent-id",
|
||||||
|
pubDate: new Date(Date.now() - 5 * DAY),
|
||||||
|
}),
|
||||||
|
makeEpisode({
|
||||||
|
id: "old-plain-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
}),
|
||||||
|
makeEpisode({
|
||||||
|
id: "old-downloaded-id",
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
saveFeedsToFile([feed]);
|
||||||
|
await settleWrites();
|
||||||
|
// The IIFE's downloads.json read can land after the first settle; poll
|
||||||
|
// briefly in case the write chain drained before that read resolved.
|
||||||
|
await pollConfigFor(["old-downloaded-id", "recent-id"]);
|
||||||
|
|
||||||
|
// Assert: persisted episodes keep recent + downloaded, drop old-plain.
|
||||||
|
const persisted = await readPersistedEpisodeIds();
|
||||||
|
expect(persisted).toContain("recent-id");
|
||||||
|
expect(persisted).toContain("old-downloaded-id");
|
||||||
|
expect(persisted).not.toContain("old-plain-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Load path: legacy config cleanup rewrite ───────────────────────────────
|
||||||
|
|
||||||
|
test("loadFeedsFromFile prunes over-window episodes and rewrites config.json", async () => {
|
||||||
|
// Arrange: seed config.json directly with a feed whose episodes are ALL
|
||||||
|
// older than the window; no downloads.json present.
|
||||||
|
mkdirSync(join(configHome, "podtui"), { recursive: true });
|
||||||
|
await Bun.write(
|
||||||
|
configJsonPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
id: "feed-1",
|
||||||
|
podcast: {
|
||||||
|
id: "feed-1",
|
||||||
|
title: "Legacy Show",
|
||||||
|
description: "",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl: "https://example.com/legacy.xml",
|
||||||
|
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: "old-a",
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: "Old A",
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.com/audio/old-a.mp3",
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "old-b",
|
||||||
|
podcastId: "feed-1",
|
||||||
|
title: "Old B",
|
||||||
|
description: "",
|
||||||
|
audioUrl: "https://example.com/audio/old-b.mp3",
|
||||||
|
duration: 60,
|
||||||
|
pubDate: new Date(Date.now() - 70 * DAY).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
visibility: "public",
|
||||||
|
sourceId: "source-1",
|
||||||
|
lastUpdated: new Date(Date.now() - 1 * DAY).toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Act.
|
||||||
|
const feeds = await loadFeedsFromFile();
|
||||||
|
await settleWrites();
|
||||||
|
|
||||||
|
// Assert: returned feed has zero episodes AND config.json was rewritten
|
||||||
|
// (the cleanup save is fire-and-forget — poll for the rewritten file).
|
||||||
|
expect(feeds).toHaveLength(1);
|
||||||
|
expect(feeds[0].episodes).toHaveLength(0);
|
||||||
|
await pollConfigFor([]);
|
||||||
|
expect(await readPersistedEpisodeIds()).toEqual([]);
|
||||||
|
});
|
||||||
331
tests/feed-volatile-merge.test.ts
Normal file
331
tests/feed-volatile-merge.test.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* Configurable episode cache + volatile merge tests.
|
||||||
|
*
|
||||||
|
* The episode list cache (what the Feed and My Shows pages show) is bounded by
|
||||||
|
* the user's preference: a date window (default 60 days) or a count (default
|
||||||
|
* 25). The full parse cache holds ALL episodes; fetch-more pages beyond the
|
||||||
|
* bound from that cache (volatile — never written back). These tests pin:
|
||||||
|
* 1. mergeEpisodesBounded unions refreshed episodes with what's in memory
|
||||||
|
* (fetched copy wins on id collision) and prunes by the supplied keep
|
||||||
|
* predicate (count or date). Undated episodes are always kept.
|
||||||
|
* 2. The store bounds the visible list by the configured mode, but the
|
||||||
|
* full parse cache survives — fetch-more pages beyond the bound.
|
||||||
|
* 3. Refresh merge never shrinks the in-memory list except via the bound.
|
||||||
|
*
|
||||||
|
* Clock constraint: these tests run under vi.useFakeTimers, and a LARGE
|
||||||
|
* vi.advanceTimersByTime (past ~5 days of fake time) makes every subsequent
|
||||||
|
* network fetch hang in Bun 1.3.8's fake-timer implementation. The date
|
||||||
|
* boundary is pinned with relative pubDates, never by moving the clock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll, beforeEach, vi } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config dir at a throwaway directory BEFORE importing the stores
|
||||||
|
// (their module-level init reads it).
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-volatile-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
|
||||||
|
import { useFeedStore } from "../src/stores/feed";
|
||||||
|
import { mergeEpisodesBounded } from "../src/utils/episode-merge";
|
||||||
|
import { episodeInWindow } from "../src/utils/feeds-persistence";
|
||||||
|
import { useAppStore } from "../src/stores/app";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
|
||||||
|
const HOUR = 3600 * 1000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
interface ServedEpisode {
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let servedEpisodes: ServedEpisode[] = [];
|
||||||
|
const addedFeedIds: string[] = [];
|
||||||
|
|
||||||
|
function feedXml(episodes: ServedEpisode[], origin: string): string {
|
||||||
|
const items = episodes
|
||||||
|
.map(
|
||||||
|
(ep, i) => `<item>
|
||||||
|
<title>${ep.title}</title>
|
||||||
|
<pubDate>${ep.date}</pubDate>
|
||||||
|
<enclosure url="${origin}/audio-${i}.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Volatile Show</title>
|
||||||
|
<description>Volatile merge test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const makePodcast = (feedUrl: string): Podcast => ({
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Volatile Show",
|
||||||
|
description: "Volatile merge test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeEpisode = (id: string, title: string, pubDate: Date): Episode => ({
|
||||||
|
id,
|
||||||
|
podcastId: "pod",
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
audioUrl: `https://example.com/${id}.mp3`,
|
||||||
|
duration: 100,
|
||||||
|
pubDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(servedEpisodes, url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
const store = useFeedStore();
|
||||||
|
for (const id of addedFeedIds) store.removeFeed(id);
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── mergeEpisodesBounded unit tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
const NOW = new Date("2026-08-10T00:00:00Z");
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded dedupes on id collision and keeps the fetched copy", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("a", "Old Title", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("b", "Ep B", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("a", "New Title", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged.find((e) => e.id === "a")!.title).toBe("New Title");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded unions disjoint lists sorted newest-first", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("old", "Old", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("newest", "Newest", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
makeEpisode("mid", "Mid", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["newest", "mid", "old"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded with count keep drops oldest beyond the count", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("day1", "Day 1", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("day3", "Day 3", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
makeEpisode("day2", "Day 2", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const keepCount2 = (_ep: Episode, i: number) => i < 2;
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepCount2);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["day3", "day2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded with date keep drops out-of-window and keeps undated", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("fresh", "Fresh", new Date("2026-08-09T00:00:00Z")),
|
||||||
|
makeEpisode("stale", "Stale", new Date("2026-06-01T00:00:00Z")),
|
||||||
|
makeEpisode("undated", "Undated", new Date(NaN)),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("newStale", "New Stale", new Date("2026-05-01T00:00:00Z")),
|
||||||
|
makeEpisode("newFresh", "New Fresh", new Date("2026-08-08T00:00:00Z")),
|
||||||
|
];
|
||||||
|
// 30-day window from NOW (2026-08-10)
|
||||||
|
const keepDate = (ep: Episode) => episodeInWindow(ep, NOW, 30);
|
||||||
|
|
||||||
|
const merged = mergeEpisodesBounded(existing, fetched, keepDate);
|
||||||
|
|
||||||
|
expect(merged.map((e) => e.id)).toEqual(["undated", "fresh", "newFresh"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mergeEpisodesBounded never mutates its inputs", () => {
|
||||||
|
const existing = [
|
||||||
|
makeEpisode("a", "A", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("b", "B", new Date("2026-08-02T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const fetched = [
|
||||||
|
makeEpisode("a", "A (fetched)", new Date("2026-08-01T00:00:00Z")),
|
||||||
|
makeEpisode("c", "C", new Date("2026-08-03T00:00:00Z")),
|
||||||
|
];
|
||||||
|
const existingIds = existing.map((e) => e.id);
|
||||||
|
const existingTitles = existing.map((e) => e.title);
|
||||||
|
const keepAll = () => true;
|
||||||
|
|
||||||
|
mergeEpisodesBounded(existing, fetched, keepAll);
|
||||||
|
|
||||||
|
expect(existing.map((e) => e.id)).toEqual(existingIds);
|
||||||
|
expect(existing.map((e) => e.title)).toEqual(existingTitles);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── store integration (default date mode, 60-day window) ─────────────────
|
||||||
|
|
||||||
|
test("refresh merges new episodes without removing the volatile window", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||||
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||||
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/volatile.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(3);
|
||||||
|
const beforeUpdated = store.getFeed(id)!.lastUpdated.getTime();
|
||||||
|
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "Ep 3", date: "2026-08-03T00:00:00Z" },
|
||||||
|
{ title: "Ep 2", date: "2026-08-02T00:00:00Z" },
|
||||||
|
{ title: "Ep 1", date: "2026-08-01T00:00:00Z" },
|
||||||
|
{ title: "Ep 5", date: "2026-08-05T00:00:00Z" },
|
||||||
|
{ title: "Ep 4", date: "2026-08-04T00:00:00Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(id);
|
||||||
|
|
||||||
|
const afterFirst = store.getFeed(id)!;
|
||||||
|
expect(afterFirst.episodes.length).toBe(5);
|
||||||
|
expect(afterFirst.lastUpdated.getTime()).toBeGreaterThan(beforeUpdated);
|
||||||
|
|
||||||
|
// Identical second refresh: no lastUpdated bump, object identity kept.
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
await store.refreshFeed(id);
|
||||||
|
|
||||||
|
const afterSecond = store.getFeed(id)!;
|
||||||
|
expect(afterSecond).toBe(afterFirst);
|
||||||
|
expect(afterSecond.lastUpdated.getTime()).toBe(afterFirst.lastUpdated.getTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode: episodes outside the 60-day window never enter the list", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
// 600 episodes at 2h spacing span ~50 days — all inside the 60-day default
|
||||||
|
// window, so all 600 are cached and loadable (no count ceiling).
|
||||||
|
servedEpisodes = Array.from({ length: 600 }, (_, i) => ({
|
||||||
|
title: `Ep ${600 - i}`,
|
||||||
|
date: new Date(now - i * 2 * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-all.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
// Subscribe window (20) with more cached.
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||||
|
|
||||||
|
// Load everything — the cache holds all 600 (date mode keeps them all).
|
||||||
|
let iterations = 0;
|
||||||
|
while (store.hasMoreEpisodes(id) && iterations < 20) {
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
iterations++;
|
||||||
|
}
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(false);
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("date mode boundary: 25 days in, 70 days out", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const now = Date.now();
|
||||||
|
servedEpisodes = [
|
||||||
|
{ title: "In Window", date: new Date(now - 25 * DAY).toISOString() },
|
||||||
|
{ title: "Out Window", date: new Date(now - 70 * DAY).toISOString() },
|
||||||
|
];
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/date-boundary.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
|
"In Window",
|
||||||
|
]);
|
||||||
|
// The full cache holds both, but the visible list only shows the in-window
|
||||||
|
// one — fetch-more surfaces the out-of-window one (volatile).
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
expect(store.getFeed(id)!.episodes.map((e) => e.title)).toEqual([
|
||||||
|
"In Window",
|
||||||
|
"Out Window",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── count mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => {
|
||||||
|
const store = useFeedStore();
|
||||||
|
const app = useAppStore();
|
||||||
|
app.updatePreferences({ episodeCacheMode: "count", episodeCacheCount: 25 });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
// 50 episodes at 1h spacing — all recent, but count mode caps at 25.
|
||||||
|
servedEpisodes = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
title: `Ep ${50 - i}`,
|
||||||
|
date: new Date(now - i * HOUR).toISOString(),
|
||||||
|
}));
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/count.xml`;
|
||||||
|
const feed = await store.addFeed(makePodcast(feedUrl), "test-source");
|
||||||
|
expect(feed).not.toBeNull();
|
||||||
|
const id = feed!.id;
|
||||||
|
addedFeedIds.push(id);
|
||||||
|
|
||||||
|
// Subscribe window (20), but the cache holds all 50 — count mode only
|
||||||
|
// bounds the visible list (25), but the full parse cache is unbounded.
|
||||||
|
// The subscribe window returns min(20, 25) = 20.
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(20);
|
||||||
|
expect(store.hasMoreEpisodes(id)).toBe(true);
|
||||||
|
|
||||||
|
// Fetch more: the visible list grows beyond the count bound — these
|
||||||
|
// episodes are volatile (held in feed.episodes, not extending the cache).
|
||||||
|
while (store.hasMoreEpisodes(id)) {
|
||||||
|
await store.loadMoreEpisodes(id);
|
||||||
|
}
|
||||||
|
expect(store.getFeed(id)!.episodes.length).toBe(50);
|
||||||
|
|
||||||
|
// Reset to date mode for subsequent tests.
|
||||||
|
app.updatePreferences({ episodeCacheMode: "date" });
|
||||||
|
});
|
||||||
199
tests/global-activity-indicator.test.tsx
Normal file
199
tests/global-activity-indicator.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* Global activity indicator — the shared leak-proof activity store
|
||||||
|
* (begin/end counter + track helper) and the global top-right overlay that
|
||||||
|
* surfaces feed refresh, fetch-more, subscribe fetch, search, and download
|
||||||
|
* activity. The download transfer is left in flight on purpose (the test
|
||||||
|
* server delays its response) so the "Downloading" state is observable in
|
||||||
|
* the captured frame.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, beforeAll, afterAll } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
|
||||||
|
// Point the config/data dirs at throwaway directories BEFORE importing the
|
||||||
|
// stores (their module-level init reads them) and silence the audio backend.
|
||||||
|
const configHome = mkdtempSync(join(tmpdir(), "podtui-activity-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = configHome;
|
||||||
|
const dataHome = mkdtempSync(join(tmpdir(), "podtui-activity-data-"));
|
||||||
|
process.env.XDG_DATA_HOME = dataHome;
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { ThemeProvider } from "../src/context/ThemeContext";
|
||||||
|
import { GlobalActivityIndicator } from "../src/components/GlobalActivityIndicator";
|
||||||
|
import { useActivityStore } from "../src/stores/activity";
|
||||||
|
import { useDownloadStore } from "../src/stores/download";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
// The LoadingIndicator glyph cycle.
|
||||||
|
const SPINNER_RE = /[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/;
|
||||||
|
|
||||||
|
type Frame = { cols: number; lines: { spans: { text: string }[] }[] };
|
||||||
|
const frameLines = (f: Frame): string[] =>
|
||||||
|
f.lines.map((l) => l.spans.map((s) => s.text).join(""));
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the indicator in isolation under the dark theme. The ThemeProvider
|
||||||
|
* gates children on async init (capabilities + palette detection, up to
|
||||||
|
* ~1.5s under tmux), so settle frames until the indicator is mounted. */
|
||||||
|
async function renderIndicator() {
|
||||||
|
const setup = await testRender(
|
||||||
|
() => (
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<GlobalActivityIndicator />
|
||||||
|
</ThemeProvider>
|
||||||
|
),
|
||||||
|
{ width: 60, height: 10, useThread: false },
|
||||||
|
);
|
||||||
|
await setup.renderOnce();
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
await setup.renderOnce();
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
return setup;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameText = (setup: { captureSpans: () => unknown }): string =>
|
||||||
|
frameLines(setup.captureSpans() as unknown as Frame).join("\n");
|
||||||
|
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
let audioUrl = "";
|
||||||
|
/** Response delay (ms) for the next audio request — keeps the transfer in
|
||||||
|
* flight while the "Downloading" state is asserted. */
|
||||||
|
let audioDelayMs = 0;
|
||||||
|
/** Episode ids this file started downloads for (shared singleton cleanup). */
|
||||||
|
const downloadedEpisodeIds: string[] = [];
|
||||||
|
|
||||||
|
const makeEpisode = (id: string, title: string): Episode => ({
|
||||||
|
id,
|
||||||
|
podcastId: "pod",
|
||||||
|
title,
|
||||||
|
description: "",
|
||||||
|
audioUrl,
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<Response>();
|
||||||
|
setTimeout(
|
||||||
|
() =>
|
||||||
|
resolve(
|
||||||
|
new Response("audio bytes", {
|
||||||
|
headers: { "Content-Type": "audio/mpeg" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
audioDelayMs,
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
for (const id of downloadedEpisodeIds) {
|
||||||
|
dl.cancelDownload(id);
|
||||||
|
await dl.removeDownload(id);
|
||||||
|
}
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(configHome, { recursive: true, force: true });
|
||||||
|
rmSync(dataHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("beginActivity/end pairs compose: ending one keeps the other active", () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
expect(activity.isActive()).toBe(false);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
|
||||||
|
const endFirst = activity.beginActivity("Refreshing");
|
||||||
|
const endSecond = activity.beginActivity("Refreshing");
|
||||||
|
expect(activity.isActive()).toBe(true);
|
||||||
|
expect(activity.labels()).toEqual(["Refreshing", "Refreshing"]);
|
||||||
|
|
||||||
|
endFirst();
|
||||||
|
expect(activity.isActive()).toBe(true);
|
||||||
|
expect(activity.labels()).toEqual(["Refreshing"]);
|
||||||
|
|
||||||
|
endSecond();
|
||||||
|
expect(activity.isActive()).toBe(false);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("track re-throws rejection and returns isActive() to its prior value", async () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const prior = activity.isActive();
|
||||||
|
await expect(
|
||||||
|
activity.track(Promise.reject(new Error("boom")), "Refreshing"),
|
||||||
|
).rejects.toThrow("boom");
|
||||||
|
expect(activity.isActive()).toBe(prior);
|
||||||
|
expect(activity.labels()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("idle: renders nothing, no spinner", async () => {
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
const text = frameText(setup);
|
||||||
|
expect(text).not.toMatch(SPINNER_RE);
|
||||||
|
expect(text.trim()).toBe("");
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tracked activity: spinner appears while active, vanishes on end", async () => {
|
||||||
|
const activity = useActivityStore();
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
expect(frameText(setup)).not.toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
const end = activity.beginActivity("Refreshing");
|
||||||
|
await setup.renderOnce();
|
||||||
|
const active = frameText(setup);
|
||||||
|
expect(active).toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
end();
|
||||||
|
await setup.renderOnce();
|
||||||
|
const done = frameText(setup);
|
||||||
|
expect(done).not.toMatch(SPINNER_RE);
|
||||||
|
expect(done.trim()).toBe("");
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active download: spinner appears and disappears", async () => {
|
||||||
|
const dl = useDownloadStore();
|
||||||
|
const setup = await renderIndicator();
|
||||||
|
|
||||||
|
// Keep the transfer in flight while asserting; the response only lands
|
||||||
|
// after audioDelayMs, so the download stays DOWNLOADING across renders.
|
||||||
|
audioDelayMs = 400;
|
||||||
|
const episode = makeEpisode("activity-dl-ep", "DL Ep");
|
||||||
|
downloadedEpisodeIds.push(episode.id);
|
||||||
|
dl.startDownload(episode, "activity-test-feed");
|
||||||
|
|
||||||
|
await setup.renderOnce();
|
||||||
|
const during = frameText(setup);
|
||||||
|
expect(during).toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
// Cancel: the abort settles the fetch and activeCount returns to 0.
|
||||||
|
dl.cancelDownload(episode.id);
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
if (dl.getActiveCount() + dl.getQueue().length === 0) break;
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
await dl.removeDownload(episode.id);
|
||||||
|
await setup.renderOnce();
|
||||||
|
const after = frameText(setup);
|
||||||
|
expect(dl.getActiveCount() + dl.getQueue().length).toBe(0);
|
||||||
|
expect(after).not.toMatch(SPINNER_RE);
|
||||||
|
|
||||||
|
audioDelayMs = 0;
|
||||||
|
setup.renderer.destroy();
|
||||||
|
});
|
||||||
195
tests/restore-session.test.ts
Normal file
195
tests/restore-session.test.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* restore-session.test.ts — "load the last player session at boot" feature.
|
||||||
|
*
|
||||||
|
* useAudio persists which episode is loaded in the player (on play/load/stop
|
||||||
|
* and synchronously at exit), and `restoreLastSession()` reloads it at boot
|
||||||
|
* PAUSED at its saved position — never autostarted — skipping episodes at or
|
||||||
|
* above 98% completion. The first play action must START the backend (a
|
||||||
|
* restored episode was never handed to it), not unpause a dead player.
|
||||||
|
*
|
||||||
|
* Strategy — the suite shares bun test workers across files, so OTHER test
|
||||||
|
* files' `mock.module("../src/hooks/useAudio")` leaks into this file's module
|
||||||
|
* registry, and store singletons may already exist. Hence:
|
||||||
|
* - the real useAudio is imported via a `?restore-test` query suffix, which
|
||||||
|
* bun treats as a distinct module identity and loads from disk, bypassing
|
||||||
|
* the suite's useAudio mock (verified: query-suffixed imports are not
|
||||||
|
* intercepted by mock.module);
|
||||||
|
* - feeds are injected through the REAL feed store's public addFeed() API
|
||||||
|
* against a local RSS server (no config seeding — works on whatever
|
||||||
|
* singleton state this worker holds), and progress through update().
|
||||||
|
* Audio is the no-op backend via PODTUI_AUDIO_BACKEND=none.
|
||||||
|
*/
|
||||||
|
import { test, expect, afterAll } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||||
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-restore-"));
|
||||||
|
const DATA = mkdtempSync(join(tmpdir(), "podtui-restore-data-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||||
|
process.env.XDG_DATA_HOME = DATA;
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
// ── Local RSS feed server (episode ids = feedUrl#index) ────────────────────
|
||||||
|
let server: ReturnType<typeof Bun.serve> | null = null;
|
||||||
|
function feedXml(origin: string): string {
|
||||||
|
const items = ["Episode One", "Episode Two"]
|
||||||
|
.map(
|
||||||
|
(title) => `<item>
|
||||||
|
<title>${title}</title>
|
||||||
|
<pubDate>2026-08-10T00:00:00Z</pubDate>
|
||||||
|
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>`,
|
||||||
|
)
|
||||||
|
.join("\n");
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Restore Test Show</title>
|
||||||
|
<description>restore-session test feed</description>
|
||||||
|
${items}
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Real modules (loaded after env + server are up) ───────────────────────
|
||||||
|
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||||
|
// loads the real file instead of a leaked mock.module from another test file.
|
||||||
|
const { useAudio, restoreLastSession } = await import("../src/hooks/useAudio?restore-test");
|
||||||
|
const { useFeedStore } = await import("../src/stores/feed");
|
||||||
|
const { useProgressStore } = await import("../src/stores/progress");
|
||||||
|
const { saveLastPlayerToFile, waitForLastPlayerWrite } = await import(
|
||||||
|
"../src/utils/app-persistence"
|
||||||
|
);
|
||||||
|
|
||||||
|
const feedStore = useFeedStore();
|
||||||
|
const progressStore = useProgressStore();
|
||||||
|
|
||||||
|
// Subscribe to the local feed through the real store API.
|
||||||
|
const feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||||
|
const feed = await feedStore.addFeed(
|
||||||
|
{
|
||||||
|
id: feedUrl,
|
||||||
|
title: "Restore Test Show",
|
||||||
|
description: "restore-session test feed",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: true,
|
||||||
|
},
|
||||||
|
"test-source",
|
||||||
|
);
|
||||||
|
if (!feed || feed.episodes.length < 2) {
|
||||||
|
throw new Error("test feed did not load two episodes");
|
||||||
|
}
|
||||||
|
const ep1 = feed.episodes[0];
|
||||||
|
const ep2 = feed.episodes[1];
|
||||||
|
|
||||||
|
// Simulate a previous session: ep1 loaded, 20% through, marker persisted.
|
||||||
|
progressStore.update(ep1.id, 120, 600);
|
||||||
|
saveLastPlayerToFile({ episodeId: ep1.id, timestamp: new Date() });
|
||||||
|
await waitForLastPlayerWrite();
|
||||||
|
|
||||||
|
/** Rewrite the marker as if a previous session had ended this way. */
|
||||||
|
async function writeMarker(episodeId: string | null): Promise<void> {
|
||||||
|
saveLastPlayerToFile({ episodeId, timestamp: new Date() });
|
||||||
|
await waitForLastPlayerWrite();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the current last-player marker from disk. */
|
||||||
|
async function readMarker(): Promise<{ episodeId: string | null } | null> {
|
||||||
|
const file = Bun.file(join(CONFIG, "podtui", "last-player.json"));
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
return (await file.json()) as { episodeId: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(CONFIG, { recursive: true, force: true });
|
||||||
|
rmSync(DATA, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test("restore loads the last player episode paused, without autostart", async () => {
|
||||||
|
const audio = useAudio(); // boot trigger also fires restoreLastSession()
|
||||||
|
await restoreLastSession();
|
||||||
|
|
||||||
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||||
|
expect(audio.isPlaying()).toBe(false);
|
||||||
|
// Position reflects where playback will resume — the player tab shows it.
|
||||||
|
expect(audio.position()).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("first play on a restored episode starts playback from saved progress", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||||
|
|
||||||
|
await audio.togglePlayback();
|
||||||
|
|
||||||
|
expect(audio.isPlaying()).toBe(true);
|
||||||
|
expect(audio.position()).toBe(120);
|
||||||
|
|
||||||
|
// A second toggle pauses (normal pause path) — no backend restart.
|
||||||
|
await audio.togglePlayback();
|
||||||
|
expect(audio.isPlaying()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore skips episodes at or above 98% completion", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
await audio.stop(); // clear the previously restored episode
|
||||||
|
await waitForLastPlayerWrite(); // stop()'s null marker has landed
|
||||||
|
await writeMarker(ep1.id); // stop() cleared the marker; restore needs it
|
||||||
|
|
||||||
|
// Exactly 98% — boundary: NOT restored.
|
||||||
|
progressStore.update(ep1.id, 588, 600);
|
||||||
|
await restoreLastSession();
|
||||||
|
expect(audio.currentEpisode()).toBeNull();
|
||||||
|
|
||||||
|
// Just under 98% — restored.
|
||||||
|
progressStore.update(ep1.id, 587, 600);
|
||||||
|
await restoreLastSession();
|
||||||
|
expect(audio.currentEpisode()?.id).toBe(ep1.id);
|
||||||
|
expect(audio.position()).toBe(587);
|
||||||
|
|
||||||
|
await audio.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore no-ops when the player was empty at last quit", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
await writeMarker(null);
|
||||||
|
|
||||||
|
await restoreLastSession();
|
||||||
|
expect(audio.currentEpisode()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore no-ops when the episode is no longer in any feed", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
await writeMarker("gone-ep");
|
||||||
|
|
||||||
|
await restoreLastSession();
|
||||||
|
expect(audio.currentEpisode()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("play persists the marker; stop clears it", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
|
||||||
|
await audio.play(ep2);
|
||||||
|
await waitForLastPlayerWrite();
|
||||||
|
expect((await readMarker())?.episodeId).toBe(ep2.id);
|
||||||
|
|
||||||
|
await audio.stop();
|
||||||
|
await waitForLastPlayerWrite();
|
||||||
|
expect((await readMarker())?.episodeId).toBeNull();
|
||||||
|
});
|
||||||
344
tests/search-episode-actions.test.tsx
Normal file
344
tests/search-episode-actions.test.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* search-episode-actions.test.tsx — episode search results must stream
|
||||||
|
* directly, subscribed or not, and `a` must subscribe an unsubscribed show
|
||||||
|
* in place.
|
||||||
|
*
|
||||||
|
* Regression: `enter` on an unsubscribed show's episode result used to
|
||||||
|
* subscribe instead of play — there was no direct "stream unsubscribed
|
||||||
|
* episode" path (subscribing first was the only way to hear it). `enter` now
|
||||||
|
* plays every episode result (matching Feed/My Shows), and the new `subscribe`
|
||||||
|
* action (`a`, sibling of `x` unsubscribe) subscribes the focused result.
|
||||||
|
*
|
||||||
|
* Mounts the real app (sandboxed, silent audio, mocked search store) and
|
||||||
|
* drives the Search tab with the test renderer's mock keys: enter on an
|
||||||
|
* unsubscribed episode plays it without subscribing; `a` subscribes (feed
|
||||||
|
* fetched from a local server); enter then plays the now-subscribed episode.
|
||||||
|
* The search store is mocked so results are deterministic (no directory
|
||||||
|
* network calls); the feed store is real and served from a local HTTP server.
|
||||||
|
*
|
||||||
|
* App modules are loaded dynamically (never statically) because the sandbox
|
||||||
|
* config/data dirs must be set BEFORE they evaluate — their module-level init
|
||||||
|
* reads those env vars at import time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, expect, afterAll, beforeAll, mock } from "bun:test";
|
||||||
|
import type { Server } from "bun";
|
||||||
|
import { testRender } from "@opentui/solid";
|
||||||
|
import { createSignal } from "solid-js";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import type { AudioControls } from "../src/hooks/useAudio";
|
||||||
|
import type { SearchResult, SearchScope } from "../src/types/source";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
import type { Podcast } from "../src/types/podcast";
|
||||||
|
import type { DepthFrame, NavigationState } from "../src/context/navigation-store";
|
||||||
|
|
||||||
|
// Recording audio stub: `play` pushes what was streamed. Registered FIRST so
|
||||||
|
// a leaked partial useAudio mock from another file in this worker can't break
|
||||||
|
// the app mount (see tests/search-focus.test.tsx for the same hazard).
|
||||||
|
const played: Episode[] = [];
|
||||||
|
const stubAudio: AudioControls = {
|
||||||
|
isPlaying: () => false,
|
||||||
|
position: () => 0,
|
||||||
|
duration: () => 0,
|
||||||
|
volume: () => 1,
|
||||||
|
speed: () => 1,
|
||||||
|
backendName: () => "none",
|
||||||
|
error: () => null,
|
||||||
|
currentEpisode: () => null,
|
||||||
|
availablePlayers: () => [],
|
||||||
|
play: async (episode: Episode) => {
|
||||||
|
played.push(episode);
|
||||||
|
},
|
||||||
|
load: async () => {},
|
||||||
|
pause: async () => {},
|
||||||
|
resume: async () => {},
|
||||||
|
togglePlayback: async () => {},
|
||||||
|
stop: async () => {},
|
||||||
|
seek: async () => {},
|
||||||
|
seekRelative: async () => {},
|
||||||
|
setVolume: async () => {},
|
||||||
|
setSpeed: async () => {},
|
||||||
|
switchBackend: async () => {},
|
||||||
|
prev: async () => {},
|
||||||
|
next: async () => {},
|
||||||
|
};
|
||||||
|
mock.module("../src/hooks/useAudio", () => ({
|
||||||
|
useAudio: () => stubAudio,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Deterministic search store: `search` feeds the seeded results synchronously;
|
||||||
|
// markSubscribed/markUnsubscribed flip the result's flag (SearchPage renders
|
||||||
|
// it, and the real feed store still owns the actual subscription).
|
||||||
|
const [scope, setScope] = createSignal<SearchScope>("episode");
|
||||||
|
const [results, setResults] = createSignal<SearchResult[]>([]);
|
||||||
|
const [query, setQuery] = createSignal("");
|
||||||
|
const [isSearching, setIsSearching] = createSignal(false);
|
||||||
|
const [error, setError] = createSignal<string | null>(null);
|
||||||
|
const [history, setHistory] = createSignal<string[]>([]);
|
||||||
|
const flip = (id: string, feedUrl: string | undefined, subscribed: boolean) =>
|
||||||
|
setResults((prev) =>
|
||||||
|
prev.map((r) =>
|
||||||
|
r.podcast.id === id ||
|
||||||
|
(feedUrl && r.podcast.feedUrl === feedUrl)
|
||||||
|
? { ...r, podcast: { ...r.podcast, isSubscribed: subscribed } }
|
||||||
|
: r,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const mockSearchStore = {
|
||||||
|
query,
|
||||||
|
isSearching,
|
||||||
|
results,
|
||||||
|
error,
|
||||||
|
history,
|
||||||
|
selectedSources: () => [] as string[],
|
||||||
|
scope,
|
||||||
|
search: async () => {},
|
||||||
|
setQuery,
|
||||||
|
clearResults: () => setResults([]),
|
||||||
|
clearHistory: () => setHistory([]),
|
||||||
|
removeFromHistory: () => {},
|
||||||
|
setSelectedSources: () => {},
|
||||||
|
setScope,
|
||||||
|
markSubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, true),
|
||||||
|
markUnsubscribed: (id: string, feedUrl?: string) => flip(id, feedUrl, false),
|
||||||
|
};
|
||||||
|
mock.module("../src/stores/search", () => ({
|
||||||
|
useSearchStore: () => mockSearchStore,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sandbox BEFORE any app module evaluates — config-dir/persistence read these
|
||||||
|
// env vars at import time, so the app modules are loaded dynamically.
|
||||||
|
const SANDBOX = join(process.cwd(), ".harness", "test-episode-actions");
|
||||||
|
mkdirSync(join(SANDBOX, "config-home"), { recursive: true });
|
||||||
|
mkdirSync(join(SANDBOX, "data-home"), { recursive: true });
|
||||||
|
process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home");
|
||||||
|
process.env.XDG_DATA_HOME = join(SANDBOX, "data-home");
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
const { App } = await import("../src/App");
|
||||||
|
const { ThemeProvider } = await import("../src/context/ThemeContext");
|
||||||
|
const toast = await import("../src/ui/toast");
|
||||||
|
const { KeybindProvider, useKeybinds } = await import(
|
||||||
|
"../src/context/KeybindContext"
|
||||||
|
);
|
||||||
|
const { NavigationProvider, useNavigation } = await import(
|
||||||
|
"../src/context/NavigationContext"
|
||||||
|
);
|
||||||
|
const { DialogProvider } = await import("../src/ui/dialog");
|
||||||
|
const { CommandProvider } = await import("../src/ui/command");
|
||||||
|
const { TABS } = await import("../src/utils/navigation");
|
||||||
|
const { useFeedStore } = await import("../src/stores/feed");
|
||||||
|
|
||||||
|
// Local HTTP server serving one show's RSS feed (the feed store fetches it
|
||||||
|
// when subscribing).
|
||||||
|
let server: Server<unknown> | null = null;
|
||||||
|
let feedUrl = "";
|
||||||
|
let audioUrl = "";
|
||||||
|
|
||||||
|
function feedXml(origin: string): string {
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0"><channel>
|
||||||
|
<title>Stream Me Show</title>
|
||||||
|
<description>Test feed</description>
|
||||||
|
<item>
|
||||||
|
<title>Ep 1</title>
|
||||||
|
<pubDate>2026-08-01T00:00:00Z</pubDate>
|
||||||
|
<enclosure url="${origin}/audio.mp3" length="12345" type="audio/mpeg"/>
|
||||||
|
</item>
|
||||||
|
</channel></rss>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeResult(): SearchResult {
|
||||||
|
const episode: Episode = {
|
||||||
|
id: "stream-ep-1",
|
||||||
|
podcastId: "dir-stream-me",
|
||||||
|
title: "Ep 1",
|
||||||
|
description: "",
|
||||||
|
audioUrl,
|
||||||
|
duration: 0,
|
||||||
|
pubDate: new Date("2026-08-01T00:00:00Z"),
|
||||||
|
};
|
||||||
|
const podcast: Podcast = {
|
||||||
|
id: "dir-stream-me",
|
||||||
|
title: "Stream Me Show",
|
||||||
|
description: "Test feed",
|
||||||
|
author: "tester",
|
||||||
|
feedUrl,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
isSubscribed: false,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
kind: "episode",
|
||||||
|
sourceId: "test",
|
||||||
|
sourceName: "Test",
|
||||||
|
podcast,
|
||||||
|
episode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockInput = { pressKey: (key: string) => void; pressEnter: () => void };
|
||||||
|
type Mounted = {
|
||||||
|
renderer: { destroy: () => void };
|
||||||
|
renderOnce: () => Promise<void>;
|
||||||
|
mockInput: MockInput;
|
||||||
|
nav: () => NavigationState;
|
||||||
|
keybindsReady: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function mountApp(): Promise<Mounted> {
|
||||||
|
let navRef: NavigationState | null = null;
|
||||||
|
let keybindsRef: { ready: boolean } | null = null;
|
||||||
|
const StateProbe = () => {
|
||||||
|
navRef = useNavigation();
|
||||||
|
keybindsRef = useKeybinds();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const HarnessRoot = () => (
|
||||||
|
<toast.ToastProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<KeybindProvider>
|
||||||
|
<NavigationProvider>
|
||||||
|
<StateProbe />
|
||||||
|
<DialogProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<App />
|
||||||
|
<toast.Toast />
|
||||||
|
</CommandProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</KeybindProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</toast.ToastProvider>
|
||||||
|
);
|
||||||
|
const setup = await testRender(() => <HarnessRoot />, {
|
||||||
|
width: 100,
|
||||||
|
height: 30,
|
||||||
|
useThread: false,
|
||||||
|
});
|
||||||
|
// The test renderer intercepts stdout; the app is a TUI that writes frames
|
||||||
|
// asynchronously, so silence that interception (same as search-focus).
|
||||||
|
(
|
||||||
|
setup.renderer as unknown as {
|
||||||
|
disableStdoutInterception?: () => void;
|
||||||
|
}
|
||||||
|
).disableStdoutInterception?.();
|
||||||
|
await setup.renderOnce();
|
||||||
|
await sleep(60);
|
||||||
|
return {
|
||||||
|
renderer: setup.renderer,
|
||||||
|
renderOnce: setup.renderOnce,
|
||||||
|
mockInput: setup.mockInput,
|
||||||
|
nav: () => navRef!,
|
||||||
|
keybindsReady: () => keybindsRef?.ready ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleReady(m: Mounted): Promise<void> {
|
||||||
|
for (let i = 0; i < 80; i++) {
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(60);
|
||||||
|
if (m.keybindsReady()) return;
|
||||||
|
}
|
||||||
|
throw new Error("keybinds never became ready");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(
|
||||||
|
m: Mounted,
|
||||||
|
cond: () => boolean,
|
||||||
|
what: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (cond()) return;
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for: ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname.endsWith(".xml")) {
|
||||||
|
return new Response(feedXml(url.origin), {
|
||||||
|
headers: { "Content-Type": "application/rss+xml" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("audio bytes", {
|
||||||
|
headers: { "Content-Type": "audio/mpeg" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
feedUrl = `http://127.0.0.1:${server!.port}/show.xml`;
|
||||||
|
audioUrl = `http://127.0.0.1:${server!.port}/audio.mp3`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server?.stop(true);
|
||||||
|
rmSync(SANDBOX, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enter streams an unsubscribed show's episode; a subscribes it in place; enter then still plays", async () => {
|
||||||
|
const m = await mountApp();
|
||||||
|
try {
|
||||||
|
await settleReady(m);
|
||||||
|
|
||||||
|
// Open the Search tab (digit press retried until the router attaches).
|
||||||
|
for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) {
|
||||||
|
m.mockInput.pressKey("4");
|
||||||
|
await m.renderOnce();
|
||||||
|
await sleep(40);
|
||||||
|
}
|
||||||
|
expect(m.nav().activeTab()).toBe(TABS.SEARCH);
|
||||||
|
m.mockInput.pressEnter(); // open the tab's content (query depth)
|
||||||
|
await waitFor(m, () => m.nav().inputFocused(), "search tab mounted");
|
||||||
|
|
||||||
|
// Seed one unsubscribed-show episode result and drill to results
|
||||||
|
// (mirrors SearchPage.runSearch: set results, push the frame).
|
||||||
|
setResults([makeResult()]);
|
||||||
|
const frame: DepthFrame = { kind: "search:results", ctx: "test", focus: 0 };
|
||||||
|
m.nav().pushDepth(frame);
|
||||||
|
m.nav().setActivePane(1);
|
||||||
|
await waitFor(m, () => m.nav().currentDepth() === 1, "results depth");
|
||||||
|
|
||||||
|
// enter → the episode streams; the show is NOT subscribed.
|
||||||
|
m.mockInput.pressEnter();
|
||||||
|
await waitFor(m, () => played.length === 1, "play called on enter");
|
||||||
|
expect(played[0].id).toBe("stream-ep-1");
|
||||||
|
expect(
|
||||||
|
useFeedStore()
|
||||||
|
.feeds()
|
||||||
|
.some((f) => f.podcast.feedUrl === feedUrl),
|
||||||
|
).toBe(false);
|
||||||
|
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(false);
|
||||||
|
|
||||||
|
// a → subscribes in place (real feed fetch against the local server).
|
||||||
|
m.mockInput.pressKey("a");
|
||||||
|
await waitFor(
|
||||||
|
m,
|
||||||
|
() =>
|
||||||
|
useFeedStore()
|
||||||
|
.feeds()
|
||||||
|
.some((f) => f.podcast.feedUrl === feedUrl),
|
||||||
|
"a subscribes the show",
|
||||||
|
);
|
||||||
|
expect(mockSearchStore.results()[0].podcast.isSubscribed).toBe(true);
|
||||||
|
|
||||||
|
// enter again → still streams (now under the subscribed show).
|
||||||
|
m.mockInput.pressEnter();
|
||||||
|
await waitFor(m, () => played.length === 2, "play called after subscribe");
|
||||||
|
expect(played[1].id).toBe("stream-ep-1");
|
||||||
|
} finally {
|
||||||
|
m.renderer.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -51,6 +51,7 @@ const stubAudio: AudioControls = {
|
|||||||
currentEpisode: () => null,
|
currentEpisode: () => null,
|
||||||
availablePlayers: () => [],
|
availablePlayers: () => [],
|
||||||
play: async () => {},
|
play: async () => {},
|
||||||
|
load: async () => {},
|
||||||
pause: async () => {},
|
pause: async () => {},
|
||||||
resume: async () => {},
|
resume: async () => {},
|
||||||
togglePlayback: async () => {},
|
togglePlayback: async () => {},
|
||||||
|
|||||||
247
tests/visualizer-store.test.ts
Normal file
247
tests/visualizer-store.test.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Visualizer store lifecycle tests — pin the waveform pipeline contract:
|
||||||
|
*
|
||||||
|
* - playback starts the pipeline and exposes a loading state until the
|
||||||
|
* first FFT frame renders (the braille-spinner window);
|
||||||
|
* - losing Player-tab focus does NOT kill a warm pipeline — it keeps
|
||||||
|
* rendering for the grace period, and regaining focus within the delay
|
||||||
|
* resumes it without a restart;
|
||||||
|
* - after VISUALIZER_UNLOAD_DELAY_MS unfocused the pipeline tears down
|
||||||
|
* (ffmpeg process + cava plan released).
|
||||||
|
*
|
||||||
|
* The store subscribes to the module-level signals in utils/audio-signals.ts
|
||||||
|
* (no useAudio mock — the signals are exported and driven directly, so this
|
||||||
|
* file can never leak a module mock into another test's worker).
|
||||||
|
*
|
||||||
|
* Uses a self-generated local WAV (a frequency chirp, so different playback
|
||||||
|
* positions produce measurably different bar output) and the real ffmpeg +
|
||||||
|
* native cavacore pipeline, mirroring audio-pcm-cache.test.ts.
|
||||||
|
*
|
||||||
|
* Timing note: this is an integration test of the store's real timers — the
|
||||||
|
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no
|
||||||
|
* fake-timer API (no `mock.timer`, no `vi.useFakeTimers`), so the grace
|
||||||
|
* period must be exercised against the platform clock. Delays are kept to
|
||||||
|
* the minimum that observes the contract (see the 30s unload test).
|
||||||
|
*/
|
||||||
|
import { test, expect, afterAll } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { setIsPlaying, setPosition, setCurrentEpisode } from "../src/utils/audio-signals";
|
||||||
|
import { useAppStore } from "../src/stores/app";
|
||||||
|
import type { Episode } from "../src/types/episode";
|
||||||
|
|
||||||
|
// ── Sandbox (the app store reads config from XDG_CONFIG_HOME at first
|
||||||
|
// use; set before importing the store) ─────────────────────────────────
|
||||||
|
|
||||||
|
process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-viz-test-${process.pid}`);
|
||||||
|
process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-viz-data-${process.pid}`);
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
const { useVisualizer, VISUALIZER_UNLOAD_DELAY_MS } = await import(
|
||||||
|
"../src/stores/visualizer"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Local chirp WAV (200Hz → 2kHz over 45s) ─────────────────────────────
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 44100;
|
||||||
|
const F0 = 200;
|
||||||
|
const F1 = 2000;
|
||||||
|
const DURATION = 45;
|
||||||
|
const AMP = 30000;
|
||||||
|
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||||
|
const hasNativeLib = Bun.file(
|
||||||
|
join(process.cwd(), "src", "native", "libcavacore.dylib"),
|
||||||
|
).exists();
|
||||||
|
|
||||||
|
async function writeChirpWav(path: string): Promise<void> {
|
||||||
|
const total = Math.round(DURATION * SAMPLE_RATE);
|
||||||
|
const dataSize = total * 2;
|
||||||
|
const buf = new Uint8Array(44 + dataSize);
|
||||||
|
const dv = new DataView(buf.buffer);
|
||||||
|
const ascii = (off: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||||
|
};
|
||||||
|
ascii(0, "RIFF");
|
||||||
|
dv.setUint32(4, 36 + dataSize, true);
|
||||||
|
ascii(8, "WAVE");
|
||||||
|
ascii(12, "fmt ");
|
||||||
|
dv.setUint32(16, 16, true);
|
||||||
|
dv.setUint16(20, 1, true); // PCM
|
||||||
|
dv.setUint16(22, 1, true); // mono
|
||||||
|
dv.setUint32(24, SAMPLE_RATE, true);
|
||||||
|
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||||
|
dv.setUint16(32, 2, true);
|
||||||
|
dv.setUint16(34, 16, true);
|
||||||
|
ascii(36, "data");
|
||||||
|
dv.setUint32(40, dataSize, true);
|
||||||
|
// Linear chirp: instantaneous frequency sweeps F0 → F1 over DURATION.
|
||||||
|
const sweep = (F1 - F0) / DURATION;
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const t = i / SAMPLE_RATE;
|
||||||
|
const phase = 2 * Math.PI * (F0 * t + 0.5 * sweep * t * t);
|
||||||
|
dv.setInt16(44 + i * 2, Math.round(AMP * Math.sin(phase)), true);
|
||||||
|
}
|
||||||
|
await Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wavPath = join(tmpdir(), `podtui-viz-${process.pid}-${Date.now()}.wav`);
|
||||||
|
await writeChirpWav(wavPath); // long enough to outlast the unload delay at readrate 1
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Poll `check` every 5ms until truthy; throw after `timeoutMs`. */
|
||||||
|
async function waitFor(
|
||||||
|
check: () => boolean,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!check()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("condition not met in time");
|
||||||
|
}
|
||||||
|
await Bun.sleep(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start playback against the local WAV and wait for the first frame.
|
||||||
|
*
|
||||||
|
* The reader samples the window ENDING at the playback position, so a
|
||||||
|
* frozen position clock would serve a 1-sample window at position 0 and
|
||||||
|
* never produce a full frame (in production mpv advances the clock every
|
||||||
|
* poll). Drive the clock to 2s right after play — inside the 3s decode-head
|
||||||
|
* burst — so complete windows are available immediately.
|
||||||
|
*/
|
||||||
|
async function startPlaying(): Promise<void> {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
viz.setBarCount(64);
|
||||||
|
viz.setFocused(true);
|
||||||
|
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
|
||||||
|
setIsPlaying(true);
|
||||||
|
setPosition(2);
|
||||||
|
await waitFor(() => viz.isRunning(), 10000);
|
||||||
|
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skip = !(hasFfmpeg && hasNativeLib);
|
||||||
|
|
||||||
|
// ── Tests ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test.skipIf(skip)(
|
||||||
|
"starts on playback: loading state first, then frequency bars",
|
||||||
|
async () => {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
await startPlaying();
|
||||||
|
expect(viz.barData().length).toBe(64);
|
||||||
|
expect(viz.isLoading()).toBe(false);
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Regression: with the position clock frozen at the start position (mpv
|
||||||
|
// still opening the stream), the reader samples a window ending at the
|
||||||
|
// start — a 1-sample slice it can never fill. The smooth clock must be
|
||||||
|
// seeded at pipeline start so the interpolated target advances and bars
|
||||||
|
// render as soon as ffmpeg has ANY audio, not after the first position poll.
|
||||||
|
test.skipIf(skip)(
|
||||||
|
"renders bars while the position clock is still frozen at 0",
|
||||||
|
async () => {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
viz.setBarCount(64);
|
||||||
|
viz.setFocused(true);
|
||||||
|
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
|
||||||
|
setIsPlaying(true);
|
||||||
|
// Deliberately do NOT advance the mock position: the clock stays at 0.
|
||||||
|
await waitFor(() => viz.isRunning(), 10000);
|
||||||
|
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||||
|
expect(viz.barData().length).toBe(64);
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(skip)(
|
||||||
|
"losing focus keeps the warm pipeline alive; refocus within the delay resumes without restart",
|
||||||
|
async () => {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
await startPlaying();
|
||||||
|
|
||||||
|
viz.setFocused(false);
|
||||||
|
// Not an instant teardown: observe the pipeline well inside the 30s
|
||||||
|
// grace window.
|
||||||
|
await Bun.sleep(500);
|
||||||
|
expect(viz.isRunning()).toBe(true);
|
||||||
|
expect(viz.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
// Still live: moving the position clock changes the bars (chirp →
|
||||||
|
// different spectrum at 3s than at the 2s start position).
|
||||||
|
setPosition(3);
|
||||||
|
const barsBefore = viz.barData();
|
||||||
|
await waitFor(() => viz.barData() !== barsBefore, 3000);
|
||||||
|
|
||||||
|
// Refocus within the delay: warm pipeline, no restart — a restart
|
||||||
|
// would respawn ffmpeg and flash the loading state. Watch for that
|
||||||
|
// flash over a short observation window.
|
||||||
|
viz.setFocused(true);
|
||||||
|
let sawRestartLoading = false;
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < 250) {
|
||||||
|
if (viz.isLoading()) sawRestartLoading = true;
|
||||||
|
await Bun.sleep(5);
|
||||||
|
}
|
||||||
|
expect(sawRestartLoading).toBe(false);
|
||||||
|
expect(viz.isRunning()).toBe(true);
|
||||||
|
expect(viz.barData().length).toBe(64);
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The store's unload is a real `setTimeout(VISUALIZER_UNLOAD_DELAY_MS)` with
|
||||||
|
// no injectable clock (bun 1.3.8 has no fake timers), so the grace period is
|
||||||
|
// exercised against the platform clock — this is the deliberate-exception
|
||||||
|
// case from the no-real-timers rule.
|
||||||
|
test.skipIf(skip)(
|
||||||
|
"unloads the pipeline after the unfocused grace delay",
|
||||||
|
async () => {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
await startPlaying();
|
||||||
|
expect(viz.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
viz.setFocused(false);
|
||||||
|
await Bun.sleep(VISUALIZER_UNLOAD_DELAY_MS + 1500);
|
||||||
|
|
||||||
|
expect(viz.isRunning()).toBe(false);
|
||||||
|
expect(viz.isLoading()).toBe(false);
|
||||||
|
},
|
||||||
|
{ timeout: 45000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Settings master switch: turning the visualizer off must tear the running
|
||||||
|
// pipeline down (not just hide the component), and re-enabling restarts it
|
||||||
|
// from the current position.
|
||||||
|
test.skipIf(skip)(
|
||||||
|
"disabling the visualizer stops a running pipeline; re-enabling restarts it",
|
||||||
|
async () => {
|
||||||
|
const viz = useVisualizer();
|
||||||
|
const app = useAppStore();
|
||||||
|
await app.whenReady();
|
||||||
|
await startPlaying();
|
||||||
|
expect(viz.isRunning()).toBe(true);
|
||||||
|
|
||||||
|
app.updateVisualizer({ enabled: false });
|
||||||
|
await waitFor(() => !viz.isRunning(), 10000);
|
||||||
|
expect(viz.isLoading()).toBe(false);
|
||||||
|
|
||||||
|
app.updateVisualizer({ enabled: true });
|
||||||
|
await waitFor(() => viz.isRunning(), 10000);
|
||||||
|
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
||||||
|
expect(viz.barData().length).toBe(64);
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Teardown ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Release any pipeline still running (e.g. if a test failed midway).
|
||||||
|
setIsPlaying(false);
|
||||||
|
});
|
||||||
94
tests/visualizer-toggle.test.ts
Normal file
94
tests/visualizer-toggle.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* visualizer-toggle.test.ts — "waveform visualizer toggleable on/off in
|
||||||
|
* settings, default on".
|
||||||
|
*
|
||||||
|
* Pins three contracts:
|
||||||
|
* 1. The default is ON (fresh config, before any user change).
|
||||||
|
* 2. The Settings → Visualizer "Waveform" item flips it via updateVisualizer.
|
||||||
|
* 3. Persistence: a config saved BEFORE `enabled` existed (no key) still
|
||||||
|
* loads as ON with its other visualizer fields intact (deep-merge
|
||||||
|
* backfill), and an explicit `enabled: false` survives a reload.
|
||||||
|
*
|
||||||
|
* The config dir is derived from XDG_CONFIG_HOME at call time, so each
|
||||||
|
* persistence test points it at a fresh tmpdir and writes its own
|
||||||
|
* config.json before calling loadAppStateFromFile directly.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||||
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||||
|
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-data-"));
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
const { useAppStore } = await import("../src/stores/app");
|
||||||
|
const { useVisualizerItems } = await import(
|
||||||
|
"../src/pages/Settings/VisualizerSettings"
|
||||||
|
);
|
||||||
|
const { loadAppStateFromFile } = await import("../src/utils/app-persistence");
|
||||||
|
|
||||||
|
/** Write a config.json into a fresh XDG_CONFIG_HOME and load app state. */
|
||||||
|
async function loadWithConfig(settings: unknown): Promise<{
|
||||||
|
state: ReturnType<typeof loadAppStateFromFile> extends Promise<infer T>
|
||||||
|
? T
|
||||||
|
: never;
|
||||||
|
}> {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "podtui-viz-toggle-cfg-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = dir;
|
||||||
|
mkdirSync(join(dir, "podtui"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(dir, "podtui", "config.json"),
|
||||||
|
JSON.stringify({ settings }, null, 2),
|
||||||
|
);
|
||||||
|
return { state: await loadAppStateFromFile() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("waveform visualizer defaults to ON with a fresh config", async () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
await app.whenReady(); // empty sandbox config → defaults
|
||||||
|
expect(app.state().settings.visualizer.enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Settings → Visualizer exposes a Waveform toggle that flips the setting", async () => {
|
||||||
|
const app = useAppStore();
|
||||||
|
await app.whenReady();
|
||||||
|
const items = useVisualizerItems();
|
||||||
|
const item = items.find((it) => it.id === "enabled");
|
||||||
|
expect(item).toBeDefined();
|
||||||
|
expect(item!.kind).toBe("toggle");
|
||||||
|
expect(item!.display()).toBe("On");
|
||||||
|
|
||||||
|
item!.toggle!();
|
||||||
|
expect(app.state().settings.visualizer.enabled).toBe(false);
|
||||||
|
expect(item!.display()).toBe("Off");
|
||||||
|
|
||||||
|
item!.toggle!();
|
||||||
|
expect(app.state().settings.visualizer.enabled).toBe(true);
|
||||||
|
expect(item!.display()).toBe("On");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a config saved before `enabled` existed loads as ON with other fields intact", async () => {
|
||||||
|
const { state } = await loadWithConfig({
|
||||||
|
visualizer: { bars: 16, lowCutOff: 80 },
|
||||||
|
});
|
||||||
|
expect(state.settings.visualizer.enabled).toBe(true); // backfilled
|
||||||
|
expect(state.settings.visualizer.bars).toBe(16); // preserved, not clobbered
|
||||||
|
expect(state.settings.visualizer.lowCutOff).toBe(80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an explicit enabled:false survives a reload", async () => {
|
||||||
|
const { state } = await loadWithConfig({
|
||||||
|
visualizer: { enabled: false, bars: 128 },
|
||||||
|
});
|
||||||
|
expect(state.settings.visualizer.enabled).toBe(false);
|
||||||
|
expect(state.settings.visualizer.bars).toBe(128);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an empty visualizer object in config falls back to full defaults", async () => {
|
||||||
|
const { state } = await loadWithConfig({ visualizer: {} });
|
||||||
|
expect(state.settings.visualizer.enabled).toBe(true);
|
||||||
|
expect(state.settings.visualizer.bars).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
85
tests/volume-persistence.test.ts
Normal file
85
tests/volume-persistence.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* volume-persistence.test.ts — "store and reuse the previous session's
|
||||||
|
* audio level" feature.
|
||||||
|
*
|
||||||
|
* useAudio's volume starts at 100% (default, before any user change);
|
||||||
|
* setVolume() persists the new level to app settings (config.json); and
|
||||||
|
* at boot the volume signal re-syncs from the persisted settings — so the
|
||||||
|
* next session resumes at the previous level.
|
||||||
|
*
|
||||||
|
* Same worker-leak defenses as restore-session.test.ts: other test files
|
||||||
|
* mock.module("../src/hooks/useAudio") and bun reuses workers, so the real
|
||||||
|
* module is imported via a query-suffixed specifier (distinct module
|
||||||
|
* identity, loads from disk). A fresh module instance simulates the next
|
||||||
|
* launch: its refCount starts at 0, so its boot sync re-reads settings.
|
||||||
|
* The boot sync awaits the app store's async config load (whenReady), so
|
||||||
|
* tests await it too and flush microtasks — no wall-clock sleeps.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// ── Sandbox BEFORE any app module evaluates ───────────────────────────────
|
||||||
|
const CONFIG = mkdtempSync(join(tmpdir(), "podtui-volume-"));
|
||||||
|
process.env.XDG_CONFIG_HOME = CONFIG;
|
||||||
|
process.env.XDG_DATA_HOME = mkdtempSync(join(tmpdir(), "podtui-volume-data-"));
|
||||||
|
process.env.PODTUI_AUDIO_BACKEND = "none";
|
||||||
|
|
||||||
|
// ── Real modules ──────────────────────────────────────────────────────────
|
||||||
|
// @ts-expect-error — bun-only query suffix: distinct module identity that
|
||||||
|
// loads the real file instead of a leaked mock.module from another test file.
|
||||||
|
const { useAudio } = await import("../src/hooks/useAudio?restore-test");
|
||||||
|
const { useAppStore } = await import("../src/stores/app");
|
||||||
|
|
||||||
|
/** Flush the boot sync's promise chain (whenReady.then(...)) — microtasks
|
||||||
|
* only, no timers. */
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("volume defaults to 100% and is persisted and reused across sessions", async () => {
|
||||||
|
const appStore = useAppStore();
|
||||||
|
await appStore.whenReady(); // empty sandbox config → defaults
|
||||||
|
|
||||||
|
// Boot 1: no persisted volume — the default is 100% (not the old 70%).
|
||||||
|
const audio = useAudio();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(audio.volume()).toBe(1);
|
||||||
|
expect(appStore.state().settings.volume).toBe(1);
|
||||||
|
|
||||||
|
// User change: signal updates and the level lands in app settings.
|
||||||
|
await audio.setVolume(0.35);
|
||||||
|
expect(audio.volume()).toBe(0.35);
|
||||||
|
expect(appStore.state().settings.volume).toBe(0.35);
|
||||||
|
|
||||||
|
// Boot 2 (fresh module instance — refCount starts at 0, so the boot
|
||||||
|
// sync re-reads settings): the previous session's level is restored.
|
||||||
|
// @ts-expect-error — same bun-only query-suffix mechanism as above.
|
||||||
|
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol");
|
||||||
|
const nextAudio = useAudioNext();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(nextAudio.volume()).toBe(0.35);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("setVolume clamps to the 0–1 range before persisting", async () => {
|
||||||
|
const audio = useAudio();
|
||||||
|
await audio.setVolume(1.7);
|
||||||
|
expect(audio.volume()).toBe(1);
|
||||||
|
expect(useAppStore().state().settings.volume).toBe(1);
|
||||||
|
|
||||||
|
await audio.setVolume(-0.3);
|
||||||
|
expect(audio.volume()).toBe(0);
|
||||||
|
expect(useAppStore().state().settings.volume).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a persisted volume wins over the default at boot", async () => {
|
||||||
|
// Persist a non-default level, then simulate a fresh launch that has no
|
||||||
|
// prior signal state (new module instance).
|
||||||
|
useAppStore().updateSettings({ volume: 0.6 });
|
||||||
|
// @ts-expect-error — same bun-only query-suffix mechanism as above.
|
||||||
|
const { useAudio: useAudioNext } = await import("../src/hooks/useAudio?restore-test-vol2");
|
||||||
|
const nextAudio = useAudioNext();
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(nextAudio.volume()).toBe(0.6);
|
||||||
|
});
|
||||||
@@ -66,6 +66,7 @@ type TestPaneProps = {
|
|||||||
current?: (() => unknown) | unknown;
|
current?: (() => unknown) | unknown;
|
||||||
preview?: unknown;
|
preview?: unknown;
|
||||||
focused?: unknown;
|
focused?: unknown;
|
||||||
|
currentBorder?: unknown;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
@@ -83,6 +84,7 @@ async function renderPaneRow(props: TestPaneProps): Promise<{
|
|||||||
preview={props.preview as any}
|
preview={props.preview as any}
|
||||||
currentLabel="List"
|
currentLabel="List"
|
||||||
focused={props.focused as any}
|
focused={props.focused as any}
|
||||||
|
currentBorder={props.currentBorder as any}
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
),
|
),
|
||||||
@@ -232,4 +234,17 @@ describe("PaneRow current-pane borders", () => {
|
|||||||
expect(borderColumns(spans)).toEqual([20, 69]);
|
expect(borderColumns(spans)).toEqual([20, 69]);
|
||||||
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("currentBorder=['left'] removes the right edge (left only)", async () => {
|
||||||
|
const { spans, destroy } = await renderPaneRow({
|
||||||
|
parent: null,
|
||||||
|
current: () => <text>ITEM</text>,
|
||||||
|
preview: null,
|
||||||
|
currentBorder: ["left"],
|
||||||
|
});
|
||||||
|
cleanups.push(destroy);
|
||||||
|
// Only the left border glyph at column 20 — no right edge at 69.
|
||||||
|
expect(borderColumns(spans)).toEqual([20]);
|
||||||
|
expect(frameText(spans)).not.toMatch(boxGlyphs);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user