Compare commits

..

73 Commits

Author SHA1 Message Date
b7c4938c54 Auto-commit 2026-03-11 16:27 2026-03-11 16:27:26 -04:00
256f112512 remove unneeded 2026-03-08 23:12:07 -04:00
8196ac8e31 fix: implement page-specific tab depth navigation
- Changed nextPane/prevPane to use current tab's pane count instead of global TabsCount
- Added Page-specific pane counts mapping for accurate depth calculation
- Pages with 1 pane (Feed, Player) now skip depth navigation
- Fixed wrapping logic to respect each page's layout structure
2026-03-08 21:01:33 -04:00
f003377f0d some nav cleanup 2026-03-08 19:25:48 -04:00
1618588a30 cycle 2026-02-22 19:07:07 -05:00
c9a370a424 more keyboard handling 2026-02-21 00:46:36 -05:00
b45e7bf538 temp keyboard handling 2026-02-20 23:42:29 -05:00
1e6618211a more indication 2026-02-20 22:42:15 -05:00
1a5efceebd device switch 2026-02-20 21:58:49 -05:00
0c16353e2e fix nav context 2026-02-20 01:28:46 -05:00
8d350d9eb5 navigation controls + starting indicators 2026-02-20 01:13:32 -05:00
cc09786592 sort fix 2026-02-19 21:15:38 -05:00
cedf099910 working indicator (simpler) 2026-02-19 20:45:48 -05:00
d1e1dd28b4 nonworking indicator, sort broken 2026-02-19 17:52:57 -05:00
1c65c85d02 redoing navigation logic to favor more local 2026-02-19 15:59:50 -05:00
8e0f90f449 nonworking keybinds 2026-02-13 17:25:32 -05:00
91fcaa9b9e getting keybinds going 2026-02-12 17:39:52 -05:00
0bbb327b29 using presets 2026-02-12 09:27:49 -05:00
276732d2a9 continued out the reuse 2026-02-12 00:11:56 -05:00
72000b362d use of selectable 2026-02-11 21:57:17 -05:00
9a2b790897 for consistency 2026-02-11 14:10:35 -05:00
2dfc96321b colors 2026-02-11 11:16:18 -05:00
3d5bc84550 set 2026-02-10 15:30:53 -05:00
f707594d0c more theme color integration 2026-02-10 15:10:07 -05:00
a405474f11 better themeing 2026-02-10 14:28:06 -05:00
ce022dc447 add explicit width 2026-02-10 13:15:25 -05:00
6053d4d02c keybinds 2026-02-10 01:26:18 -05:00
64a2ba2751 simple 2026-02-07 22:30:51 -05:00
bcf248f7dd renders 2026-02-07 19:05:45 -05:00
5bd393c9cd cooking 2026-02-07 18:45:13 -05:00
627fb65547 sketching out layout structure, cleaning Discover 2026-02-07 16:44:49 -05:00
73aa211229 janitorial work 2026-02-07 15:12:34 -05:00
7eb49ac1c7 on left 2026-02-07 14:53:22 -05:00
19a1f1a43b add agents file 2026-02-07 12:01:36 -05:00
2e323d283f needs testing 2026-02-07 00:45:14 -05:00
46f9135776 flatten 2026-02-07 00:44:52 -05:00
db74e20571 better copying 2026-02-06 16:51:00 -05:00
70f50eec2a covert html on fetch instead 2026-02-06 16:45:34 -05:00
1cee931913 understanding 2026-02-06 16:29:09 -05:00
bfea6816ef dead 2026-02-06 15:02:21 -05:00
75f1f7d6af remove migration code 2026-02-06 15:00:21 -05:00
1e3b794b8e file ordering 2026-02-06 14:55:42 -05:00
1293d30225 starting janitorial work 2026-02-06 13:41:44 -05:00
920042ee2a fix stream multiplaction 2026-02-06 11:47:48 -05:00
e1dc242b1d better visualizer 2026-02-06 11:08:41 -05:00
8d6b19582c implementing cava for real time visualization 2026-02-06 10:11:51 -05:00
63ded34a6b basic clean 2026-02-06 09:57:33 -05:00
0e4f47323f mulitmedia pass, downloads 2026-02-06 00:00:15 -05:00
42a1ddf458 meh 2026-02-05 23:43:19 -05:00
168e6d5a61 final feature set 2026-02-05 22:55:24 -05:00
6b00871c32 working playback 2026-02-05 21:18:44 -05:00
e0fa76fb32 slight ui improvement 2026-02-05 19:08:39 -05:00
f3344fbed2 start player 2026-02-05 18:42:56 -05:00
03e69d04dc fix 2026-02-05 18:29:05 -05:00
91de49be0d cleanup 2026-02-05 18:01:26 -05:00
3d156403c7 fixed tmux system theme loading 2026-02-05 14:20:51 -05:00
e239b33042 getting terminal colors working 2026-02-05 13:46:47 -05:00
9fa52d71ca still self referencing 2026-02-05 01:43:10 -05:00
ea9ab4d3f9 theme redux 2026-02-05 01:07:22 -05:00
6950deaa88 it is time 2026-02-05 00:55:10 -05:00
4579659784 will be reworking in just a moment 2026-02-04 23:58:53 -05:00
c26150221a pause 2026-02-04 22:48:54 -05:00
39a4f88496 proper layering work 2026-02-04 16:23:25 -05:00
624a6ba022 pause to fix nav/theme 2026-02-04 12:38:35 -05:00
cdabf2c3e0 checkpoint 2026-02-04 12:10:30 -05:00
b8549777ba missing md 2026-02-04 11:36:47 -05:00
9b1a3585e6 fix 2026-02-04 11:24:19 -05:00
4cee352641 remove dev 2026-02-04 10:23:12 -05:00
72b2870f64 options 2026-02-04 10:02:07 -05:00
f7df578461 broke 2026-02-04 09:39:58 -05:00
bd4747679d fix keyboard, finish 05 2026-02-04 01:18:59 -05:00
d5ce8452e4 4, partial 5 2026-02-04 01:00:57 -05:00
7b5c256e07 start 2026-02-04 00:06:16 -05:00
187 changed files with 15304 additions and 3734 deletions

11
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,11 @@
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"],
}

32
.gitignore vendored
View File

@@ -1,2 +1,34 @@
.opencode .opencode
# dependencies (bun install)
node_modules node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
*.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
*.lock
# Finder (MacOS) folder config
.DS_Store

97
AGENTS.md Normal file
View File

@@ -0,0 +1,97 @@
# AGENTS.md
## Build, Lint, and Test Commands
### Development
- `bun start` - Run the application
- `bun run dev` - Run with hot reload (watch mode)
### Build
- `bun run build` - Build JavaScript bundle to `dist/`
- `bun run build:native` - Build native libraries (requires `scripts/build-cavacore.sh`)
### Testing
- `bun test` - Run all tests
- `bun tests/cavacore-smoke.ts` - Run specific native library smoke test
### Linting
- `bun run lint` - Run ESLint with TypeScript rules
## Code Style Guidelines
### TypeScript Configuration
- Target: ESNext with bundler module resolution
- Strict mode enabled
- Path alias: `@/*` maps to `src/*`
- JSX: Use `@opentui/solid` as import source
### Import Organization
1. Third-party framework imports (solid-js, @opentui/solid)
2. Local utility imports
3. Type imports (separate from value imports)
### Naming Conventions
- **Components**: PascalCase (e.g., `FeedPage`, `Player`)
- **Hooks**: `use*` prefix (e.g., `useAudio`, `useAppKeyboard`)
- **Stores**: `create*` factory + `use*` accessor (e.g., `createFeedStore`, `useFeedStore`)
- **Utilities**: camelCase (e.g., `parseRSSFeed`, `detectPlayers`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `MAX_EPISODES_REFRESH`)
- **Types/interfaces**: PascalCase (e.g., `Feed`, `AudioBackend`)
- **Enums**: PascalCase (e.g., `FeedVisibility`)
### Code Structure
- **Section Dividers**: Use `// ── Section Name ────────────────────────────────────────────────────────────` format
- **Helper Functions**: Define before main logic
- **Factory Functions**: Use for store creation (return object with state, computed, actions)
- **Singleton Pattern**: Stores use module-level singleton with `use*` accessor
### Type Definitions
- Use `interface` for object shapes
- Use `type` for unions, intersections, and complex types
- Use `enum` for constant sets
- Export types from `src/types/` directory
- Include JSDoc comments for complex types
### Error Handling
- Use `try/catch` for async operations
- For expected failures, use `.catch(() => {})` to suppress errors
- Return default values on failure (e.g., `return []` or `return null`)
- Use `catch` blocks with descriptive comments for unexpected errors
- For UI components, wrap in `ErrorBoundary` with clear fallback
### Async Patterns
- Fire-and-forget async operations: `.catch(() => {})` with comment
- Async store initialization: IIFE `(async () => { ... })()`
- Promise handling: Use `.catch()` to return defaults
### Comments
- **File headers**: Brief description of file purpose
- **Complex functions**: JSDoc-style comments explaining behavior
- **Section dividers**: Visual separators for code organization
- **Inline comments**: Explain non-obvious logic, especially async patterns
### Code Formatting
- 2-space indentation
- No semicolons (Bun style)
- Arrow functions with implicit return where appropriate
- Object shorthand where possible
- Prefer `const` over `let`
### Reactivity (Solid.js)
- Use `createSignal` for primitive state
- Use `createMemo` for computed values
- Use `createEffect` for side effects
- Component functions return JSX
- Store functions return plain objects with state, computed, and actions
### Persistence
- Async persistence operations fire-and-forget
- Use `.catch(() => {})` to suppress errors
- Update state synchronously, persist asynchronously
- Use `setFeeds()` pattern to update state and trigger save
### Testing
- Test files in `tests/` directory
- Use Bun's test framework
- Include JSDoc comments explaining test purpose
- Native library tests use `bun:ffi` for FFI calls

15
README.md Normal file
View File

@@ -0,0 +1,15 @@
# solid
To install dependencies:
```bash
bun install
```
To run:
```bash
bun dev
```
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.

62
build.ts Normal file
View File

@@ -0,0 +1,62 @@
import solidPlugin from "@opentui/solid/bun-plugin"
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
import { join, dirname } from "node:path"
// Build the JavaScript bundle
await Bun.build({
entrypoints: ["./src/index.tsx"],
outdir: "./dist",
target: "bun",
minify: true,
sourcemap: "external",
plugins: [solidPlugin],
})
// Copy the native library to dist for distribution
const platform = process.platform
const arch = process.arch
// Map platform/arch to OpenTUI package names
const platformMap: Record<string, string> = {
"darwin-arm64": "darwin-arm64",
"darwin-x64": "darwin-x64",
"linux-x64": "linux-x64",
"linux-arm64": "linux-arm64",
"win32-x64": "win32-x64",
"win32-arm64": "win32-arm64",
}
const platformKey = `${platform}-${arch}`
const platformPkg = platformMap[platformKey]
if (platformPkg) {
const libName = platform === "win32"
? "opentui.dll"
: platform === "darwin"
? "libopentui.dylib"
: "libopentui.so"
const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName)
if (existsSync(srcPath)) {
const destPath = join("dist", libName)
copyFileSync(srcPath, destPath)
console.log(`Copied native library: ${libName}`)
}
}
// Copy cavacore native library to dist
const cavacoreLib = platform === "darwin"
? "libcavacore.dylib"
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so"
const cavacoreSrc = join("src", "native", cavacoreLib)
if (existsSync(cavacoreSrc)) {
copyFileSync(cavacoreSrc, join("dist", cavacoreLib))
console.log(`Copied cavacore library: ${cavacoreLib}`)
} else {
console.warn(`Warning: ${cavacoreSrc} not found — run scripts/build-cavacore.sh first`)
}
console.log("Build complete")

BIN
bun.lockb

Binary file not shown.

1
bunfig.toml Normal file
View File

@@ -0,0 +1 @@
preload = ["@opentui/solid/preload"]

View File

@@ -1 +1,38 @@
{ "dependencies": { "@opentui/core": "^0.1.77" } } {
"name": "podcast-tui-app",
"version": "0.1.0",
"module": "src/index.tsx",
"type": "module",
"private": true,
"bin": {
"podtui": "./dist/index.js"
},
"scripts": {
"start": "bun src/index.tsx",
"dev": "bun --watch src/index.tsx",
"build:native": "bash scripts/build-cavacore.sh",
"build": "bun run build.ts",
"dist": "bun dist/index.js",
"test": "bun test",
"lint": "bun run lint.ts"
},
"devDependencies": {
"@types/bun": "latest",
"@types/uuid": "^11.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"typescript": "^5.9.3"
},
"dependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@opentui/core": "^0.1.77",
"@opentui/solid": "^0.1.77",
"babel-preset-solid": "1.9.9",
"date-fns": "^4.1.0",
"solid-js": "^1.9.9",
"uuid": "^13.0.0",
"zustand": "^5.0.11"
}
}

79
scripts/build-cavacore.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Build cavacore as a shared library with fftw3 statically linked.
#
# Prerequisites:
# macOS: brew install fftw
# Linux: apt install libfftw3-dev (or equivalent)
#
# Output: src/native/libcavacore.{dylib,so}
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/cava/cavacore.c"
OUT_DIR="$ROOT/src/native"
mkdir -p "$OUT_DIR"
OS="$(uname -s)"
ARCH="$(uname -m)"
# Resolve fftw3 paths
if [ "$OS" = "Darwin" ]; then
if [ "$ARCH" = "arm64" ]; then
FFTW_PREFIX="${FFTW_PREFIX:-/opt/homebrew}"
else
FFTW_PREFIX="${FFTW_PREFIX:-/usr/local}"
fi
LIB_EXT="dylib"
SHARED_FLAG="-dynamiclib"
INSTALL_NAME="-install_name @rpath/libcavacore.dylib"
else
FFTW_PREFIX="${FFTW_PREFIX:-/usr}"
LIB_EXT="so"
SHARED_FLAG="-shared"
INSTALL_NAME=""
fi
FFTW_INCLUDE="$FFTW_PREFIX/include"
FFTW_STATIC="$FFTW_PREFIX/lib/libfftw3.a"
if [ ! -f "$FFTW_STATIC" ]; then
echo "Error: libfftw3.a not found at $FFTW_STATIC"
echo "Install fftw3: brew install fftw (macOS) or apt install libfftw3-dev (Linux)"
exit 1
fi
if [ ! -f "$SRC" ]; then
echo "Error: cavacore.c not found at $SRC"
echo "Ensure the cava submodule is initialized: git submodule update --init"
exit 1
fi
OUT="$OUT_DIR/libcavacore.$LIB_EXT"
echo "Building libcavacore.$LIB_EXT ($OS $ARCH)"
echo " Source: $SRC"
echo " FFTW3: $FFTW_STATIC"
echo " Output: $OUT"
cc -O2 \
$SHARED_FLAG \
$INSTALL_NAME \
-fPIC \
-I"$FFTW_INCLUDE" \
-I"$ROOT/cava" \
-o "$OUT" \
"$SRC" \
"$FFTW_STATIC" \
-lm
echo "Built: $OUT"
# Verify exported symbols
if [ "$OS" = "Darwin" ]; then
echo ""
echo "Exported symbols:"
nm -gU "$OUT" | grep "cava_"
fi

194
src/App.tsx Normal file
View File

@@ -0,0 +1,194 @@
import { createMemo, ErrorBoundary, Accessor } from "solid-js";
import { useKeyboard, useSelectionHandler } from "@opentui/solid";
import { TabNavigation } from "./components/TabNavigation";
import { CodeValidation } from "@/components/CodeValidation";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { Clipboard } from "@/utils/clipboard";
import { useToast } from "@/ui/toast";
import { useRenderer } from "@opentui/solid";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
import { DIRECTION, LayerGraph, TABS, LayerDepths } from "./utils/navigation";
import { useTheme, ThemeProvider } from "./context/ThemeContext";
import { KeybindProvider, useKeybinds } from "./context/KeybindContext";
import { NavigationProvider, useNavigation } from "./context/NavigationContext";
import { useAudioNavStore, AudioSource } from "./stores/audio-nav";
const DEBUG = import.meta.env.DEBUG;
export function App() {
const nav = useNavigation();
const auth = useAuthStore();
const feedStore = useFeedStore();
const audio = useAudio();
const toast = useToast();
const renderer = useRenderer();
const themeContext = useTheme();
const theme = themeContext.theme;
// Create a reactive expression for background color
const backgroundColor = () => {
return themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface;
};
const keybind = useKeybinds();
const audioNav = useAudioNavStore();
useMultimediaKeys({
playerFocused: () =>
nav.activeTab() === TABS.PLAYER && nav.activeDepth() > 0,
inputFocused: () => nav.inputFocused(),
hasEpisode: () => !!audio.currentEpisode(),
});
const handlePlayEpisode = (episode: Episode) => {
audio.play(episode);
nav.setActiveTab(TABS.PLAYER);
nav.setActiveDepth(1);
audioNav.setSource(AudioSource.FEED);
};
useSelectionHandler((selection: any) => {
if (!selection) return;
const text = selection.getSelectedText?.();
if (!text || text.trim().length === 0) return;
Clipboard.copy(text)
.then(() => {
toast.show({ message: "Copied to Clipboard!", variant: "info" });
})
.catch(toast.error)
.finally(() => {
renderer.clearSelection();
});
});
useKeyboard(
(keyEvent) => {
const isCycle = keybind.match("cycle", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isDown = keybind.match("down", keyEvent);
const isLeft = keybind.match("left", keyEvent);
const isRight = keybind.match("right", keyEvent);
const isDive = keybind.match("dive", keyEvent);
const isOut = keybind.match("out", keyEvent);
const isToggle = keybind.match("audio-toggle", keyEvent);
const isNext = keybind.match("audio-next", keyEvent);
const isPrev = keybind.match("audio-prev", keyEvent);
const isSeekForward = keybind.match("audio-seek-forward", keyEvent);
const isSeekBackward = keybind.match("audio-seek-backward", keyEvent);
const isQuit = keybind.match("quit", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
// unified navigation: left->right, top->bottom across all tabs
if (nav.activeDepth() == 0) {
// at top level: cycle through tabs
if (
(isCycle && !isInverting) ||
(isDown && !isInverting) ||
(isUp && isInverting)
) {
nav.nextTab();
return;
}
if (
(isCycle && isInverting) ||
(isDown && isInverting) ||
(isUp && !isInverting)
) {
nav.prevTab();
return;
}
// dive out to first pane
if (
(isDive && !isInverting) ||
(isOut && isInverting) ||
(isRight && !isInverting) ||
(isLeft && isInverting)
) {
nav.setActiveDepth(1);
}
} else {
// in panes: navigate between them
if (
(isDive && isInverting) ||
(isOut && !isInverting) ||
(isRight && isInverting) ||
(isLeft && !isInverting)
) {
nav.setActiveDepth(0);
} else if (isDown && !isInverting) {
nav.nextPane();
} else if (isUp && isInverting) {
nav.prevPane();
}
}
},
{ release: false },
);
return (
<ErrorBoundary
fallback={(err) => (
<box border padding={2} borderColor={theme.error}>
<text fg={theme.error}>
Error: {err?.message ?? String(err)}
{"\n"}
Press a number key (1-6) to switch tabs.
</text>
</box>
)}
>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={
themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
>
<LoadingIndicator />
{DEBUG && (
<box flexDirection="row" width="100%" height={1}>
<text fg={theme.primary}></text>
<text fg={theme.secondary}></text>
<text fg={theme.accent}></text>
<text fg={theme.error}></text>
<text fg={theme.warning}></text>
<text fg={theme.success}></text>
<text fg={theme.info}></text>
<text fg={theme.text}></text>
<text fg={theme.textMuted}></text>
<text fg={theme.surface}></text>
<text fg={theme.background}></text>
<text fg={theme.border}></text>
<text fg={theme.borderActive}></text>
<text fg={theme.diffAdded}></text>
<text fg={theme.diffRemoved}></text>
<text fg={theme.diffContext}></text>
<text fg={theme.markdownText}></text>
<text fg={theme.markdownHeading}></text>
<text fg={theme.markdownLink}></text>
<text fg={theme.markdownCode}></text>
<text fg={theme.syntaxKeyword}></text>
<text fg={theme.syntaxString}></text>
<text fg={theme.syntaxNumber}></text>
<text fg={theme.syntaxFunction}></text>
</box>
)}
<box flexDirection="row" width="100%" height="100%">
<TabNavigation />
{LayerGraph[nav.activeTab()]()}
</box>
</box>
</ErrorBoundary>
);
}

73
src/api/client.ts Normal file
View File

@@ -0,0 +1,73 @@
import type { Feed } from "../types/feed"
import type { Episode } from "../types/episode"
import type { Podcast } from "../types/podcast"
import type { PodcastSource } from "../types/source"
import { parseRSSFeed } from "@/api/rss-parser"
import { handleAPISource, handleCustomSource, handleRSSSource } from "@/api/source-handler"
export const fetchEpisodes = async (feedUrl: string): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl)
if (!response.ok) return []
const xml = await response.text()
return parseRSSFeed(xml, feedUrl).episodes
} catch {
return []
}
}
export const fetchFeeds = async (
sourceIds: string[],
sources: PodcastSource[]
): Promise<Feed[]> => {
const active = sources.filter((source) => sourceIds.includes(source.id))
const feeds: Feed[] = []
await Promise.all(
active.map(async (source) => {
try {
if (source.type === "rss") {
const rssFeeds = await handleRSSSource(source)
feeds.push(...rssFeeds)
} else if (source.type === "api") {
const apiFeeds = await handleAPISource(source, "")
feeds.push(...apiFeeds)
} else {
const customFeeds = await handleCustomSource(source, "")
feeds.push(...customFeeds)
}
} catch {
// ignore individual source errors
}
})
)
return feeds
}
export const searchPodcasts = async (
query: string,
sources: PodcastSource[]
): Promise<Podcast[]> => {
const results: Podcast[] = []
await Promise.all(
sources.map(async (source) => {
try {
if (source.type === "rss") {
const feeds = await handleRSSSource(source)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else if (source.type === "api") {
const feeds = await handleAPISource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
} else {
const feeds = await handleCustomSource(source, query)
results.push(...feeds.map((feed: Feed) => feed.podcast))
}
} catch {
// ignore errors
}
})
)
return results
}

147
src/api/rss-parser.ts Normal file
View File

@@ -0,0 +1,147 @@
import type { Podcast } from "../types/podcast"
import type { Episode, EpisodeType } from "../types/episode"
import { detectContentType, ContentType } from "../utils/rss-content-detector"
import { htmlToText } from "../utils/html-to-text"
const getTagValue = (xml: string, tag: string): string => {
const match = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i"))
return match?.[1]?.trim() ?? ""
}
/** Get an attribute value from a self-closing or open tag */
const getAttr = (xml: string, tag: string, attr: string): string => {
const tagMatch = xml.match(new RegExp(`<${tag}[^>]*>`, "i"))
if (!tagMatch) return ""
const attrMatch = tagMatch[0].match(new RegExp(`${attr}\\s*=\\s*["']([^"']*)["']`, "i"))
return attrMatch?.[1] ?? ""
}
const decodeEntities = (value: string) =>
value
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
/**
* Clean a field (description or title): detect HTML vs plain text, and convert
* HTML to readable plain text. Plain text just gets entity decoding.
*/
const cleanField = (raw: string): string => {
if (!raw) return ""
const decoded = decodeEntities(raw)
const type = detectContentType(decoded)
if (type === ContentType.HTML) {
return htmlToText(decoded)
}
return decoded
}
/**
* Parse an itunes:duration value which can be:
* - "HH:MM:SS"
* - "MM:SS"
* - seconds as a plain number string (e.g. "1234")
* Returns duration in seconds, or 0 if unparseable.
*/
const parseDuration = (raw: string): number => {
if (!raw) return 0
const trimmed = raw.trim()
// Pure numeric (seconds)
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10)
}
// HH:MM:SS or MM:SS
const parts = trimmed.split(":").map(Number)
if (parts.some(isNaN)) return 0
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return parts[0] * 60 + parts[1]
}
return 0
}
const parseEpisodeType = (raw: string): EpisodeType | undefined => {
const lower = raw.trim().toLowerCase()
if (lower === "trailer") return "trailer" as EpisodeType
if (lower === "bonus") return "bonus" as EpisodeType
if (lower === "full") return "full" as EpisodeType
return undefined
}
export const parseRSSFeed = (xml: string, feedUrl: string): Podcast & { episodes: Episode[] } => {
const channel = xml.match(/<channel[\s\S]*?<\/channel>/i)?.[0] ?? xml
const title = cleanField(getTagValue(channel, "title")) || "Untitled Podcast"
const description = cleanField(getTagValue(channel, "description"))
const author = decodeEntities(getTagValue(channel, "itunes:author"))
const lastUpdated = new Date()
const items = channel.match(/<item[\s\S]*?<\/item>/gi) ?? []
const episodes = items.map((item, 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 {
id: feedUrl,
title,
description,
author,
feedUrl,
lastUpdated,
isSubscribed: true,
episodes,
}
}

94
src/api/source-handler.ts Normal file
View File

@@ -0,0 +1,94 @@
import { FeedVisibility } from "../types/feed"
import type { Feed } from "../types/feed"
import type { PodcastSource } from "../types/source"
import type { Podcast } from "../types/podcast"
import { parseRSSFeed } from "./rss-parser"
const buildFeedFromPodcast = (podcast: Podcast, sourceId: string): Feed => {
return {
id: `${sourceId}-${podcast.id}`,
podcast,
episodes: [],
visibility: FeedVisibility.PUBLIC,
sourceId,
lastUpdated: new Date(),
isPinned: false,
}
}
export const handleRSSSource = async (source: PodcastSource): Promise<Feed[]> => {
if (!source.baseUrl) return []
const response = await fetch(source.baseUrl)
if (!response.ok) return []
const xml = await response.text()
const parsed = parseRSSFeed(xml, source.baseUrl)
return [
{
id: `${source.id}-${parsed.feedUrl}`,
podcast: {
id: parsed.id,
title: parsed.title,
description: parsed.description,
feedUrl: parsed.feedUrl,
author: parsed.author,
categories: parsed.categories,
lastUpdated: parsed.lastUpdated,
isSubscribed: true,
},
episodes: parsed.episodes,
visibility: FeedVisibility.PUBLIC,
sourceId: source.id,
lastUpdated: parsed.lastUpdated,
isPinned: false,
},
]
}
export const handleAPISource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
const url = new URL(source.baseUrl || "https://itunes.apple.com/search")
url.searchParams.set("term", query || "podcast")
url.searchParams.set("media", "podcast")
url.searchParams.set("entity", "podcast")
url.searchParams.set("country", source.country || "US")
url.searchParams.set("lang", source.language || "en_us")
const response = await fetch(url.toString())
if (!response.ok) return []
const data = (await response.json()) as { results?: Array<{ collectionId?: number; collectionName?: string; feedUrl?: string; artistName?: string }> }
const results = data.results ?? []
return results
.filter((item) => item.collectionName && item.feedUrl)
.map((item) => {
const podcast: Podcast = {
id: item.collectionId ? `itunes-${item.collectionId}` : `${source.id}-${item.collectionName}`,
title: item.collectionName || "Untitled Podcast",
description: item.collectionName || "",
feedUrl: item.feedUrl || "",
author: item.artistName,
lastUpdated: new Date(),
isSubscribed: false,
}
return buildFeedFromPodcast(podcast, source.id)
})
}
export const handleCustomSource = async (
source: PodcastSource,
query: string
): Promise<Feed[]> => {
if (!query) return []
const podcast: Podcast = {
id: `${source.id}-${query.toLowerCase().replace(/\s+/g, "-")}`,
title: `${query} Highlights`,
description: `Curated results for ${query}`,
feedUrl: source.baseUrl || "",
author: source.name,
lastUpdated: new Date(),
isSubscribed: false,
}
return [buildFeedFromPodcast(podcast, source.id)]
}

View File

@@ -0,0 +1,180 @@
/**
* Code validation component for PodTUI
* 8-character alphanumeric code input for sync authentication
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface CodeValidationProps {
focused?: boolean;
onBack?: () => void;
}
type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code");
const [codeError, setCodeError] = createSignal<string | null>(null);
const fields: FocusField[] = ["code", "submit", "back"];
/** Format code as user types (uppercase, alphanumeric only) */
const handleCodeInput = (value: string) => {
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Limit to max length
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited);
// Clear error when typing
if (codeError()) {
setCodeError(null);
}
};
const validateCode = (value: string): boolean => {
if (!value) {
setCodeError("Code is required");
return false;
}
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(
`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
);
return false;
}
if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) {
setCodeError("Code must contain only letters and numbers");
return false;
}
setCodeError(null);
return true;
};
const handleSubmit = async () => {
if (!validateCode(code())) {
return;
}
const success = await auth.validateCode(code());
if (!success && auth.error) {
setCodeError(auth.error.message);
}
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "tab") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
const codeProgress = () => {
const len = code().length;
const max = AUTH_CONFIG.codeValidation.codeLength;
return `${len}/${max}`;
};
const codeDisplay = () => {
const current = code();
const max = AUTH_CONFIG.codeValidation.codeLength;
const filled = current.split("");
const empty = Array(max - filled.length).fill("_");
return [...filled, ...empty].join(" ");
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Enter Sync Code</strong>
</text>
<box height={1} />
<text fg={theme.textMuted}>
Enter your 8-character sync code to link your account.
</text>
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
<box height={1} />
{/* Code display */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "code" ? theme.primary : undefined}>
Code ({codeProgress()}):
</text>
<box border padding={1} borderColor={theme.border}>
<text
fg={
code().length === AUTH_CONFIG.codeValidation.codeLength
? theme.success
: theme.warning
}
>
{codeDisplay()}
</text>
</box>
{/* Hidden input for actual typing */}
<input
value={code()}
onInput={handleCodeInput}
placeholder=""
focused={props.focused && focusField() === "code"}
width={30}
/>
{codeError() && <text fg={theme.error}>{codeError()}</text>}
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "submit" ? theme.primary : undefined}>
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

View File

@@ -0,0 +1,24 @@
import { createSignal, createMemo, onCleanup } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
const spinnerChars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
//TODO: Watch for actual loading state (fetching feeds)
export function LoadingIndicator() {
const { theme } = useTheme();
const [index, setIndex] = createSignal(0);
const interval = setInterval(() => {
setIndex((i) => (i + 1) % spinnerChars.length);
}, 65);
onCleanup(() => clearInterval(interval));
const currentChar = createMemo(() => spinnerChars[index()]);
return (
<box flexDirection="row" justifyContent="flex-end" alignItems="flex-start">
<text fg={theme.primary} content={currentChar()} />
</box>
);
}

View File

@@ -0,0 +1,28 @@
import type { TabId } from "./Tab"
import { useTheme } from "@/context/ThemeContext"
type NavigationProps = {
activeTab: TabId
onTabSelect: (tab: TabId) => void
}
export function Navigation(props: NavigationProps) {
const { theme } = useTheme();
return (
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
<text fg={theme.text}>
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
<span> </span>
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
<span> </span>
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
<span> </span>
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
<span> </span>
{props.activeTab === "player" ? "[" : " "}Player{props.activeTab === "player" ? "]" : " "}
<span> </span>
{props.activeTab === "settings" ? "[" : " "}Settings{props.activeTab === "settings" ? "]" : " "}
</text>
</box>
)
}

View File

@@ -0,0 +1,81 @@
import { useTheme } from "@/context/ThemeContext";
import { children as solidChildren } from "solid-js";
import type { ParentComponent } from "solid-js";
import type { BoxOptions, TextOptions } from "@opentui/core";
export const SelectableBox: ParentComponent<
{
selected: () => boolean;
} & BoxOptions
> = (props) => {
const themeContext = useTheme();
const { theme } = themeContext;
const child = solidChildren(() => props.children);
return (
<box
border={!!props.border}
borderColor={props.selected() ? theme.surface : theme.border}
backgroundColor={
props.selected()
? theme.primary
: themeContext.selected === "system"
? "transparent"
: themeContext.theme.surface
}
{...props}
>
{child()}
</box>
);
};
enum ColorSet {
PRIMARY,
SECONDARY,
TERTIARY,
DEFAULT,
}
function getTextColor(set: ColorSet, selected: () => boolean) {
const { theme } = useTheme();
switch (set) {
case ColorSet.PRIMARY:
return selected() ? theme.textSelectedPrimary : theme.textPrimary;
case ColorSet.SECONDARY:
return selected() ? theme.textSelectedSecondary : theme.textSecondary;
case ColorSet.TERTIARY:
return selected() ? theme.textSelectedTertiary : theme.textTertiary;
default:
return theme.textPrimary;
}
}
export const SelectableText: ParentComponent<
{
selected: () => boolean;
primary?: boolean;
secondary?: boolean;
tertiary?: boolean;
} & TextOptions
> = (props) => {
const child = solidChildren(() => props.children);
return (
<text
fg={getTextColor(
props.primary
? ColorSet.PRIMARY
: props.secondary
? ColorSet.SECONDARY
: props.tertiary
? ColorSet.TERTIARY
: ColorSet.DEFAULT,
props.selected,
)}
{...props}
>
{child()}
</text>
);
};

View File

@@ -0,0 +1,28 @@
import { shortcuts } from "@/config/shortcuts";
import { useTheme } from "@/context/ThemeContext";
export function ShortcutHelp() {
const { theme } = useTheme();
return (
<box border title="Shortcuts" style={{ padding: 1 }}>
<box style={{ flexDirection: "column" }}>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[0]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[0]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[1]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[1]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[2]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[2]?.action ?? ""}</text>
</box>
<box style={{ flexDirection: "row" }}>
<text fg={theme.text}>{shortcuts[3]?.keys ?? ""} </text>
<text fg={theme.text}>{shortcuts[3]?.action ?? ""}</text>
</box>
</box>
</box>
);
}

View File

@@ -0,0 +1,55 @@
import { useTheme } from "@/context/ThemeContext";
import { TABS, TabsCount } from "@/utils/navigation";
import { For } from "solid-js";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
export const tabs: TabDefinition[] = [
{ id: TABS.FEED, label: "Feed" },
{ id: TABS.MYSHOWS, label: "My Shows" },
{ id: TABS.DISCOVER, label: "Discover" },
{ id: TABS.SEARCH, label: "Search" },
{ id: TABS.PLAYER, label: "Player" },
{ id: TABS.SETTINGS, label: "Settings" },
];
export function TabNavigation() {
const { theme } = useTheme();
const { activeTab, setActiveTab, activeDepth } = useNavigation();
return (
<box
border
borderColor={activeDepth() !== 0 ? theme.border : theme.accent}
backgroundColor={"transparent"}
style={{
flexDirection: "column",
width: 12,
height: TabsCount * 3 + 2,
}}
>
<For each={tabs}>
{(tab) => (
<SelectableBox
border
height={3}
selected={() => tab.id == activeTab()}
onMouseDown={() => setActiveTab(tab.id)}
>
<SelectableText
selected={() => tab.id == activeTab()}
primary
alignSelf="center"
>
{tab.label}
</SelectableText>
</SelectableBox>
)}
</For>
</box>
);
}
export type TabDefinition = {
id: TABS;
label: string;
};

75
src/config/auth.ts Normal file
View File

@@ -0,0 +1,75 @@
/**
* Authentication configuration for PodTUI
* Authentication is DISABLED by default - users can opt-in
*/
import { OAuthProvider, type OAuthProviderConfig } from "../types/auth"
/** Default auth enabled state - DISABLED by default */
export const DEFAULT_AUTH_ENABLED = false
/** Authentication configuration */
export const AUTH_CONFIG = {
/** Whether auth is enabled by default */
defaultEnabled: DEFAULT_AUTH_ENABLED,
/** Code validation settings */
codeValidation: {
/** Code length (8 characters) */
codeLength: 8,
/** Allowed characters (alphanumeric) */
allowedChars: /^[A-Z0-9]+$/,
/** Code expiration time in minutes */
expirationMinutes: 15,
},
/** Password requirements */
password: {
minLength: 8,
requireUppercase: false,
requireLowercase: false,
requireNumber: false,
requireSpecial: false,
},
/** Email validation */
email: {
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
},
/** Local storage keys */
storage: {
authState: "podtui_auth_state",
user: "podtui_user",
lastLogin: "podtui_last_login",
},
} as const
/** OAuth provider configurations */
export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [
{
id: OAuthProvider.GOOGLE,
name: "Google",
enabled: false, // Not feasible in terminal
description: "Sign in with Google (requires browser redirect)",
},
{
id: OAuthProvider.APPLE,
name: "Apple",
enabled: false, // Not feasible in terminal
description: "Sign in with Apple (requires browser redirect)",
},
]
/** Terminal OAuth limitation message */
export const OAUTH_LIMITATION_MESSAGE = `
OAuth authentication (Google, Apple) is not directly available in terminal applications.
To use OAuth:
1. Visit the web portal in your browser
2. Sign in with your preferred provider
3. Generate a sync code
4. Enter the code here to link your account
Alternatively, use email/password authentication or file-based sync.
`.trim()

20
src/config/keybind.jsonc Normal file
View File

@@ -0,0 +1,20 @@
{
"up": ["up", "k"],
"down": ["down", "j"],
"left": ["left", "h"],
"right": ["right", "l"],
"cycle": ["tab"], // this will cycle no matter the depth/orientation
"dive": ["return"],
"out": ["esc"],
"inverseModifier": ["shift"],
"leader": ":", // will not trigger while focused on input
"quit": ["<leader>q"],
"refresh": ["<leader>r"],
"audio-toggle": ["<leader>p"],
"audio-pause": [],
"audio-play": [],
"audio-next": ["<leader>n"],
"audio-prev": ["<leader>l"],
"audio-seek-forward": ["<leader>sf"],
"audio-seek-backward": ["<leader>sb"],
}

6
src/config/shortcuts.ts Normal file
View File

@@ -0,0 +1,6 @@
export const shortcuts = [
{ keys: "Ctrl+Q", action: "Quit" },
{ keys: "Ctrl+S", action: "Save" },
{ keys: "Left/Right", action: "Switch tabs" },
{ keys: "Esc", action: "Close modal" },
] as const

View File

@@ -0,0 +1,12 @@
export const syncFormats = {
json: {
version: "1.0",
extension: ".json",
},
xml: {
version: "1.0",
extension: ".xml",
},
}
export const supportedSyncVersions = [syncFormats.json.version, syncFormats.xml.version]

25
src/constants/themes.ts Normal file
View File

@@ -0,0 +1,25 @@
import type {
ThemeColors,
ThemeDefinition,
ThemeName,
} from "../types/settings";
import {
BASE_THEME_COLORS,
BASE_LAYER_BACKGROUND,
} from "../types/desktop-theme";
import catppuccin from "../themes/catppuccin.json" with { type: "json" };
import gruvbox from "../themes/gruvbox.json" with { type: "json" };
import tokyo from "../themes/tokyo.json" with { type: "json" };
import nord from "../themes/nord.json" with { type: "json" };
export const DEFAULT_THEME: ThemeColors = {
...BASE_THEME_COLORS,
layerBackgrounds: BASE_LAYER_BACKGROUND,
};
export const THEME_JSON: Record<string, ThemeDefinition> = {
catppuccin: catppuccin as ThemeDefinition,
gruvbox: gruvbox as ThemeDefinition,
tokyo: tokyo as ThemeDefinition,
nord: nord as ThemeDefinition,
};

View File

@@ -0,0 +1,136 @@
import { createSignal, onMount } from "solid-js";
import { createSimpleContext } from "./helper";
import {
copyKeybindsIfNeeded,
loadKeybindsFromFile,
saveKeybindsToFile,
} from "../utils/keybinds-persistence";
import { createStore } from "solid-js/store";
export type KeybindsResolved = {
up: string[];
down: string[];
left: string[];
right: string[];
cycle: string[]; // this will cycle no matter the depth/orientation
dive: string[];
out: string[];
inverseModifier: string;
leader: string; // will not trigger while focused on input
quit: string[];
select: string[]; // for selecting/activating items
"audio-toggle": string[];
"audio-pause": string[];
"audio-play": string[];
"audio-next": string[];
"audio-prev": string[];
"audio-seek-forward": string[];
"audio-seek-backward": string[];
};
export enum KeybindAction {
UP,
DOWN,
LEFT,
RIGHT,
CYCLE,
DIVE,
OUT,
QUIT,
SELECT,
AUDIO_TOGGLE,
AUDIO_PAUSE,
AUDIO_PLAY,
AUDIO_NEXT,
AUDIO_PREV,
AUDIO_SEEK_F,
AUDIO_SEEK_B,
}
export const { use: useKeybinds, provider: KeybindProvider } =
createSimpleContext({
name: "Keybinds",
init: () => {
const [store, setStore] = createStore({
up: [],
down: [],
left: [],
right: [],
cycle: [],
dive: [],
out: [],
inverseModifier: "",
leader: "",
quit: [],
select: [],
refresh: [],
"audio-toggle": [],
"audio-pause": [],
"audio-play": [],
"audio-next": [],
"audio-prev": [],
"audio-seek-forward": [],
"audio-seek-backward": [],
} as KeybindsResolved);
const [ready, setReady] = createSignal(false);
async function load() {
await copyKeybindsIfNeeded();
const keybinds = await loadKeybindsFromFile();
setStore(keybinds);
setReady(true);
}
async function save() {
saveKeybindsToFile(store);
}
function print(input: keyof KeybindsResolved): string {
const keys = store[input] || [];
return Array.isArray(keys) ? keys.join(", ") : keys;
}
function match(
keybind: keyof KeybindsResolved,
evt: { name: string; ctrl?: boolean; meta?: boolean; shift?: boolean },
): boolean {
const keys = store[keybind];
if (!keys) return false;
for (const key of keys) {
if (evt.name === key) return true;
}
return false;
}
function isInverting(evt: {
name: string;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}) {
if (store.inverseModifier === "ctrl" && evt.ctrl) return true;
if (store.inverseModifier === "meta" && evt.meta) return true;
if (store.inverseModifier === "shift" && evt.shift) return true;
return false;
}
// Load on mount
onMount(() => {
load().catch(() => {});
});
return {
get ready() {
return ready();
},
get keybinds() {
return store;
},
save,
print,
match,
isInverting,
};
},
});

View File

@@ -0,0 +1,73 @@
import { createEffect, createSignal, on } from "solid-js";
import { createSimpleContext } from "./helper";
import { TABS, TabsCount, LayerDepths } from "@/utils/navigation";
// Page-specific pane counts
const PANE_COUNTS = {
[TABS.FEED]: 1,
[TABS.MYSHOWS]: 2,
[TABS.DISCOVER]: 2,
[TABS.SEARCH]: 3,
[TABS.PLAYER]: 1,
[TABS.SETTINGS]: 5,
};
export const { use: useNavigation, provider: NavigationProvider } =
createSimpleContext({
name: "Navigation",
init: () => {
const [activeTab, setActiveTab] = createSignal<TABS>(TABS.FEED);
const [activeDepth, setActiveDepth] = createSignal(0);
const [inputFocused, setInputFocused] = createSignal(false);
createEffect(
on(
() => activeTab,
() => setActiveDepth(0),
),
);
const nextTab = () => {
if (activeTab() >= TabsCount) {
setActiveTab(1);
return;
}
setActiveTab(activeTab() + 1);
};
const prevTab = () => {
if (activeTab() <= 1) {
setActiveTab(TabsCount);
return;
}
setActiveTab(activeTab() - 1);
};
const nextPane = () => {
// Move to next pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev % count) + 1);
};
const prevPane = () => {
// Move to previous pane within the current tab's pane structure
const count = PANE_COUNTS[activeTab()];
if (count <= 1) return; // No panes to navigate (feed/player)
setActiveDepth((prev) => (prev - 2 + count) % count + 1);
};
return {
activeTab,
activeDepth,
inputFocused,
setActiveTab,
setActiveDepth,
setInputFocused,
nextTab,
prevTab,
nextPane,
prevPane,
};
},
});

View File

@@ -0,0 +1,308 @@
import { createEffect, createMemo, onMount, onCleanup } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { useRenderer } from "@opentui/solid";
import type { ThemeName } from "../types/settings";
import type { ThemeJson } from "../types/theme-schema";
import { useAppStore } from "../stores/app";
import { THEME_JSON } from "../constants/themes";
import {
generateSyntax,
generateSubtleSyntax,
} from "../utils/syntax-highlighter";
import { resolveTerminalTheme, loadThemes } from "../utils/theme";
import { createSimpleContext } from "./helper";
import {
setupThemeSignalHandler,
emitThemeChanged,
emitThemeModeChanged,
} from "../utils/theme-observer";
import {
createTerminalPalette,
type RGBA,
type TerminalColors,
} from "@opentui/core";
export type ThemeResolved = {
primary: RGBA;
secondary: RGBA;
accent: RGBA;
error: RGBA;
warning: RGBA;
success: RGBA;
info: RGBA;
text: RGBA;
textMuted: RGBA;
textPrimary: RGBA;
textSecondary: RGBA;
textTertiary: RGBA;
textSelectedPrimary: RGBA;
textSelectedSecondary: RGBA;
textSelectedTertiary: RGBA;
background: RGBA;
backgroundPanel: RGBA;
backgroundElement: RGBA;
backgroundMenu: RGBA;
border: RGBA;
borderActive: RGBA;
borderSubtle: RGBA;
diffAdded: RGBA;
diffRemoved: RGBA;
diffContext: RGBA;
diffHunkHeader: RGBA;
diffHighlightAdded: RGBA;
diffHighlightRemoved: RGBA;
diffAddedBg: RGBA;
diffRemovedBg: RGBA;
diffContextBg: RGBA;
diffLineNumber: RGBA;
diffAddedLineNumberBg: RGBA;
diffRemovedLineNumberBg: RGBA;
markdownText: RGBA;
markdownHeading: RGBA;
markdownLink: RGBA;
markdownLinkText: RGBA;
markdownCode: RGBA;
markdownBlockQuote: RGBA;
markdownEmph: RGBA;
markdownStrong: RGBA;
markdownHorizontalRule: RGBA;
markdownListItem: RGBA;
markdownListEnumeration: RGBA;
markdownImage: RGBA;
markdownImageText: RGBA;
markdownCodeBlock: RGBA;
syntaxComment: RGBA;
syntaxKeyword: RGBA;
syntaxFunction: RGBA;
syntaxVariable: RGBA;
syntaxString: RGBA;
syntaxNumber: RGBA;
syntaxType: RGBA;
syntaxOperator: RGBA;
syntaxPunctuation: RGBA;
muted?: RGBA;
surface?: RGBA;
selectedListItemText?: RGBA;
layerBackgrounds?: {
layer0: RGBA;
layer1: RGBA;
layer2: RGBA;
layer3: RGBA;
};
_hasSelectedListItemText?: boolean;
thinkingOpacity?: number;
};
/**
* Theme context using the createSimpleContext pattern.
*
* This ensures children are NOT rendered until the theme is ready,
* preventing "useTheme must be used within a ThemeProvider" errors.
*
*/
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const appStore = useAppStore();
const renderer = useRenderer();
const [store, setStore] = createStore({
themes: THEME_JSON as Record<string, ThemeJson>,
mode: props.mode,
active: appStore.state().settings.theme as string,
system: undefined as undefined | TerminalColors,
ready: false,
});
function init() {
resolveSystemTheme();
loadThemes()
.then((custom) => {
setStore(
produce((draft) => {
Object.assign(draft.themes, custom);
}),
);
})
.catch(() => {
setStore("active", "catppuccin");
})
.finally(() => {
// Only set ready if not waiting for system theme
if (store.active !== "system") {
setStore("ready", true);
}
});
}
async function waitForCapabilities(timeoutMs = 300) {
if (renderer.capabilities) return;
await new Promise<void>((resolve) => {
let done = false;
const onCaps = () => {
if (done) return;
done = true;
renderer.off("capabilities", onCaps);
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
if (done) return;
done = true;
renderer.off("capabilities", onCaps);
resolve();
}, timeoutMs);
renderer.on("capabilities", onCaps);
});
}
async function resolveSystemTheme() {
if (process.env.TMUX) {
await waitForCapabilities();
}
let colors: TerminalColors | null = null;
try {
colors = await renderer.getPalette({ size: 16 });
} catch {
colors = null;
}
if (!colors?.palette?.[0] && process.env.TMUX) {
const writeOut = (
renderer as unknown as {
writeOut?: (data: string | Buffer) => boolean;
}
).writeOut;
const writeFn =
typeof writeOut === "function"
? writeOut.bind(renderer)
: process.stdout.write.bind(process.stdout);
const detector = createTerminalPalette(
process.stdin,
process.stdout,
writeFn,
true,
);
try {
const tmuxColors = await detector.detect({ size: 16, timeout: 1200 });
if (tmuxColors?.palette?.[0]) {
colors = tmuxColors;
}
} finally {
detector.cleanup();
}
}
const hasPalette = Boolean(
colors?.palette?.some((value) => Boolean(value)),
);
const hasDefaultColors = Boolean(
colors?.defaultBackground || colors?.defaultForeground,
);
if (!hasPalette && !hasDefaultColors) {
// No system colors available, fall back to default
// This happens when the terminal doesn't support OSC palette queries
// (e.g., running inside tmux, or on unsupported terminals)
if (store.active === "system") {
setStore(
produce((draft) => {
draft.active = "catppuccin";
draft.ready = true;
}),
);
}
return;
}
if (colors) {
setStore(
produce((draft) => {
draft.system = colors;
if (store.active === "system") {
draft.ready = true;
}
}),
);
}
}
onMount(init);
// Setup SIGUSR2 signal handler for dynamic theme reload
// This allows external tools to trigger a theme refresh by sending:
// `kill -USR2 <pid>`
const cleanupSignalHandler = setupThemeSignalHandler(() => {
renderer.clearPaletteCache();
init();
});
onCleanup(cleanupSignalHandler);
// Sync active theme with app store settings
createEffect(() => {
const theme = appStore.state().settings.theme;
if (theme) setStore("active", theme);
});
// Emit theme change events for observers
createEffect(() => {
const theme = store.active;
const mode = store.mode;
if (store.ready) {
emitThemeChanged(theme, mode);
}
});
const values = createMemo(() => {
return resolveTerminalTheme(
store.themes,
store.active,
store.mode,
store.system,
);
});
const syntax = createMemo(() =>
generateSyntax(values() as unknown as Record<string, RGBA>),
);
const subtleSyntax = createMemo(() =>
generateSubtleSyntax(
values() as unknown as Record<string, RGBA> & {
thinkingOpacity?: number;
},
),
);
return {
theme: new Proxy(values(), {
get(_target, prop) {
// @ts-expect-error - dynamic property access
return values()[prop];
},
}) as ThemeResolved,
get selected() {
return store.active;
},
all() {
return store.themes;
},
syntax,
subtleSyntax,
mode() {
return store.mode;
},
setMode(mode: "dark" | "light") {
setStore("mode", mode);
emitThemeModeChanged(mode);
},
set(theme: string) {
appStore.setTheme(theme as ThemeName);
},
get ready() {
return store.ready;
},
};
},
});

53
src/context/helper.tsx Normal file
View File

@@ -0,0 +1,53 @@
import { createContext, Show, useContext, type ParentProps } from "solid-js"
/**
* Creates a simple context with automatic ready-state handling.
*
* This pattern ensures that child components are NOT rendered until the
* context's `ready` property is true (or undefined, meaning no ready check needed).
*
* This prevents the "useX must be used within a XProvider" errors that occur
* when child components try to use context values before the provider has
* finished async initialization.
*
* Usage:
* ```tsx
* export const { use: useMyContext, provider: MyProvider } = createSimpleContext({
* name: "MyContext",
* init: (props: { someProp: string }) => {
* const [ready, setReady] = createSignal(false)
* // ... async initialization ...
* return {
* get ready() { return ready() },
* // ... other values
* }
* },
* })
* ```
*/
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
name: string
init: ((input: Props) => T) | (() => T)
}) {
const ctx = createContext<T>()
return {
provider: (props: ParentProps<Props>) => {
const init = input.init(props)
// Use an arrow function accessor for the ready check to maintain reactivity.
// The getter `init.ready` reads from a store, so wrapping it in an
// accessor allows Solid to track changes reactively.
return (
// @ts-expect-error - ready may not exist on all context types
<Show when={init.ready === undefined || init.ready}>
<ctx.Provider value={init}>{props.children}</ctx.Provider>
</Show>
)
},
use() {
const value = useContext(ctx)
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
return value
},
}
}

528
src/hooks/useAudio.ts Normal file
View File

@@ -0,0 +1,528 @@
/**
* Reactive SolidJS hook wrapping the AudioBackend.
*
* Provides signals for playback state and methods for controlling
* audio. Integrates with the event bus and app store.
*
* Usage:
* ```tsx
* const audio = useAudio()
* audio.play(episode)
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
* ```
*/
import { createSignal, onCleanup } from "solid-js"
import {
createAudioBackend,
detectPlayers,
type AudioBackend,
type BackendName,
type DetectedPlayer,
} from "../utils/audio-player"
import { emit, on } from "../utils/event-bus"
import { useAppStore } from "../stores/app"
import { useProgressStore } from "../stores/progress"
import { useMediaRegistry } from "../utils/media-registry"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
import { useAudioNavStore, AudioSource } from "../stores/audio-nav"
import { useFeedStore } from "../stores/feed"
export interface AudioControls {
// Signals (reactive getters)
isPlaying: () => boolean
position: () => number
duration: () => number
volume: () => number
speed: () => number
backendName: () => BackendName
error: () => string | null
currentEpisode: () => Episode | null
availablePlayers: () => DetectedPlayer[]
// Actions
play: (episode: Episode) => Promise<void>
pause: () => Promise<void>
resume: () => Promise<void>
togglePlayback: () => Promise<void>
stop: () => Promise<void>
seek: (seconds: number) => Promise<void>
seekRelative: (delta: number) => Promise<void>
setVolume: (volume: number) => Promise<void>
setSpeed: (speed: number) => Promise<void>
switchBackend: (name: BackendName) => Promise<void>
prev: () => Promise<void>
next: () => Promise<void>
}
// Singleton state — shared across all components that call useAudio()
let backend: AudioBackend | null = null
let pollTimer: ReturnType<typeof setInterval> | null = null
let refCount = 0
let pollCount = 0 // Counts poll ticks for throttling progress saves
const [isPlaying, setIsPlaying] = createSignal(false)
const [position, setPosition] = createSignal(0)
const [duration, setDuration] = createSignal(0)
const [volume, setVolume] = createSignal(0.7)
const [speed, setSpeed] = createSignal(1)
const [backendName, setBackendName] = createSignal<BackendName>("none")
const [error, setError] = createSignal<string | null>(null)
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
function ensureBackend(): AudioBackend {
if (!backend) {
const detected = detectPlayers()
setAvailablePlayers(detected)
backend = createAudioBackend()
setBackendName(backend.name)
}
return backend
}
function startPolling(): void {
stopPolling()
pollCount = 0
pollTimer = setInterval(async () => {
if (!backend || !isPlaying()) return
try {
const pos = await backend.getPosition()
const dur = await backend.getDuration()
setPosition(pos)
if (dur > 0) setDuration(dur)
// Save progress every ~5 seconds (10 ticks * 500ms)
pollCount++
if (pollCount % 10 === 0) {
const ep = currentEpisode()
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
// Update platform media position
const media = useMediaRegistry()
media.setPosition(pos)
}
}
// Check if backend stopped playing (track ended)
if (!backend.isPlaying() && isPlaying()) {
setIsPlaying(false)
stopPolling()
// Save final position on track end
const ep = currentEpisode()
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, pos, dur > 0 ? dur : duration(), speed())
}
}
} catch {
// Backend may have been disposed
}
}, 500)
}
function stopPolling(): void {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
async function play(episode: Episode): Promise<void> {
const b = ensureBackend()
setError(null)
if (!episode.audioUrl) {
setError("No audio URL for this episode")
return
}
try {
const appStore = useAppStore()
const progressStore = useProgressStore()
const storeSpeed = appStore.state().settings.playbackSpeed
const vol = volume()
const spd = storeSpeed || speed()
// Resume from saved progress if available and not completed
const savedProgress = progressStore.get(episode.id)
let startPos = 0
if (savedProgress && !progressStore.isCompleted(episode.id)) {
startPos = savedProgress.position
}
await b.play(episode.audioUrl, {
volume: vol,
speed: spd,
startPosition: startPos > 0 ? startPos : undefined,
})
setCurrentEpisode(episode)
setIsPlaying(true)
setPosition(startPos)
setSpeed(spd)
if (episode.duration) setDuration(episode.duration)
// Register with platform media controls
const media = useMediaRegistry()
media.setNowPlaying({
title: episode.title,
artist: episode.podcastId,
duration: episode.duration,
})
media.setPlaybackState(true)
if (startPos > 0) media.setPosition(startPos)
startPolling()
emit("player.play", { episodeId: episode.id })
} catch (err) {
setError(err instanceof Error ? err.message : "Playback failed")
setIsPlaying(false)
}
}
async function pause(): Promise<void> {
if (!backend) return
try {
await backend.pause()
setIsPlaying(false)
stopPolling()
const ep = currentEpisode()
if (ep) {
// Save progress on pause
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
emit("player.pause", { episodeId: ep.id })
// Update platform media controls
const media = useMediaRegistry()
media.setPlaybackState(false)
media.setPosition(position())
}
} catch (err) {
setError(err instanceof Error ? err.message : "Pause failed")
}
}
async function resume(): Promise<void> {
if (!backend) return
try {
await backend.resume()
setIsPlaying(true)
startPolling()
const ep = currentEpisode()
if (ep) {
emit("player.play", { episodeId: ep.id })
const media = useMediaRegistry()
media.setPlaybackState(true)
}
} catch (err) {
setError(err instanceof Error ? err.message : "Resume failed")
}
}
async function togglePlayback(): Promise<void> {
if (isPlaying()) {
await pause()
} else if (currentEpisode()) {
await resume()
}
}
async function stop(): Promise<void> {
if (!backend) return
try {
// Save progress before stopping
const ep = currentEpisode()
if (ep) {
const progressStore = useProgressStore()
progressStore.update(ep.id, position(), duration(), speed())
}
await backend.stop()
setIsPlaying(false)
setPosition(0)
setCurrentEpisode(null)
stopPolling()
emit("player.stop", {})
// Clear platform media controls
const media = useMediaRegistry()
media.clearNowPlaying()
} catch (err) {
setError(err instanceof Error ? err.message : "Stop failed")
}
}
async function seek(seconds: number): Promise<void> {
if (!backend) return
const clamped = Math.max(0, Math.min(seconds, duration()))
try {
await backend.seek(clamped)
setPosition(clamped)
} catch (err) {
setError(err instanceof Error ? err.message : "Seek failed")
}
}
async function seekRelative(delta: number): Promise<void> {
await seek(position() + delta)
}
async function doSetVolume(vol: number): Promise<void> {
const clamped = Math.max(0, Math.min(1, vol))
if (backend) {
try {
await backend.setVolume(clamped)
} catch {
// Some backends can't change volume at runtime
}
}
setVolume(clamped)
}
async function doSetSpeed(spd: number): Promise<void> {
const clamped = Math.max(0.25, Math.min(3, spd))
if (backend) {
try {
await backend.setSpeed(clamped)
} catch {
// Some backends can't change speed at runtime
}
}
setSpeed(clamped)
// Sync back to app store
try {
const appStore = useAppStore()
appStore.updateSettings({ playbackSpeed: clamped })
} catch {
// Store may not be available
}
}
async function switchBackend(name: BackendName): Promise<void> {
const wasPlaying = isPlaying()
const ep = currentEpisode()
const pos = position()
const vol = volume()
const spd = speed()
// Stop current backend
if (backend) {
stopPolling()
backend.dispose()
backend = null
}
// Create new backend
backend = createAudioBackend(name)
setBackendName(backend.name)
setAvailablePlayers(detectPlayers())
// Resume playback if we were playing
if (wasPlaying && ep && ep.audioUrl) {
try {
await backend.play(ep.audioUrl, {
startPosition: pos,
volume: vol,
speed: spd,
})
setIsPlaying(true)
startPolling()
} catch (err) {
setError(err instanceof Error ? err.message : "Backend switch failed")
setIsPlaying(false)
}
}
}
/**
* Reactive audio controls hook.
*
* Returns a singleton — all components share the same playback state.
* Registers event bus listeners and cleans them up with onCleanup.
*/
export function useAudio(): AudioControls {
// Initialize backend on first use
ensureBackend()
// Sync initial speed from app store
if (refCount === 0) {
try {
const appStore = useAppStore()
const storeSpeed = appStore.state().settings.playbackSpeed
if (storeSpeed && storeSpeed !== speed()) {
setSpeed(storeSpeed)
}
} catch {
// Store may not be available yet
}
}
refCount++
// Listen for event bus commands (e.g. from other components)
const unsubPlay = on("player.play", async (data) => {
// External play requests — currently just tracks episodeId.
// Episode lookup would require feed store integration.
})
const unsubStop = on("player.stop", async () => {
if (backend && isPlaying()) {
await backend.stop()
setIsPlaying(false)
setPosition(0)
setCurrentEpisode(null)
stopPolling()
}
})
// Listen for global multimedia key events (from useMultimediaKeys)
const unsubMediaToggle = on("media.toggle", async () => {
await togglePlayback()
})
const unsubMediaVolUp = on("media.volumeUp", async () => {
await doSetVolume(Math.min(1, Number((volume() + 0.05).toFixed(2))))
})
const unsubMediaVolDown = on("media.volumeDown", async () => {
await doSetVolume(Math.max(0, Number((volume() - 0.05).toFixed(2))))
})
const unsubMediaSeekFwd = on("media.seekForward", async () => {
await seekRelative(10)
})
const unsubMediaSeekBack = on("media.seekBackward", async () => {
await seekRelative(-10)
})
const unsubMediaSpeed = on("media.speedCycle", async () => {
const next = speed() >= 2 ? 0.5 : Number((speed() + 0.25).toFixed(2))
await doSetSpeed(next)
})
const audioNav = useAudioNavStore();
const feedStore = useFeedStore();
async function prev(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const currentPos = position();
const currentDur = duration();
const NAV_START_THRESHOLD = 30;
if (currentPos > NAV_START_THRESHOLD && currentDur > 0) {
await seek(NAV_START_THRESHOLD);
} else {
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.max(0, currentIndex - 1);
if (newIndex < episodes.length && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.prev(newIndex);
}
}
}
async function next(): Promise<void> {
const current = currentEpisode();
if (!current) return;
const source = audioNav.getSource();
let episodes: Array<{ episode: Episode; feed: Feed }> = [];
if (source === AudioSource.FEED) {
episodes = feedStore.getAllEpisodesChronological();
} else if (source === AudioSource.MY_SHOWS) {
const podcastId = audioNav.getPodcastId();
if (!podcastId) return;
const feed = feedStore.getFilteredFeeds().find(f => f.podcast.id === podcastId);
if (!feed) return;
episodes = feed.episodes.map(ep => ({ episode: ep, feed }));
}
const currentIndex = audioNav.getCurrentIndex();
const newIndex = Math.min(episodes.length - 1, currentIndex + 1);
if (newIndex >= 0 && episodes[newIndex]) {
const { episode } = episodes[newIndex];
await play(episode);
audioNav.next(newIndex);
}
}
onCleanup(() => {
refCount--
unsubPlay()
unsubStop()
unsubMediaToggle()
unsubMediaVolUp()
unsubMediaVolDown()
unsubMediaSeekFwd()
unsubMediaSeekBack()
unsubMediaSpeed()
if (refCount <= 0) {
stopPolling()
if (backend) {
backend.dispose()
backend = null
}
// Clear media registry on full teardown
const media = useMediaRegistry()
media.clearNowPlaying()
refCount = 0
}
})
return {
isPlaying,
position,
duration,
volume,
speed,
backendName,
error,
currentEpisode,
availablePlayers,
play,
pause,
resume,
togglePlayback,
stop,
seek,
seekRelative,
setVolume: doSetVolume,
setSpeed: doSetSpeed,
switchBackend,
prev,
next,
}
}

View File

@@ -0,0 +1,34 @@
import { createSignal, onCleanup } from "solid-js"
type CacheOptions<T> = {
fetcher: () => Promise<T>
intervalMs?: number
}
export const useCachedData = <T,>(options: CacheOptions<T>) => {
const [data, setData] = createSignal<T | null>(null)
const [loading, setLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const refresh = async () => {
setLoading(true)
setError(null)
try {
const value = await options.fetcher()
setData(() => value)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load data")
} finally {
setLoading(false)
}
}
refresh()
if (options.intervalMs) {
const interval = setInterval(refresh, options.intervalMs)
onCleanup(() => clearInterval(interval))
}
return { data, loading, error, refresh }
}

View File

@@ -0,0 +1,98 @@
/**
* Global multimedia key handler hook.
*
* Captures media-related key events (play/pause, volume, seek, speed)
* regardless of which component is focused. Uses the event bus to
* decouple key detection from audio control logic.
*
* Keys are only handled when an episode is loaded (or for play/pause,
* always). This prevents accidental volume/seek changes when there's
* nothing playing.
*/
import { useKeyboard } from "@opentui/solid"
import { emit } from "../utils/event-bus"
export type MediaKeyAction =
| "media.toggle"
| "media.volumeUp"
| "media.volumeDown"
| "media.seekForward"
| "media.seekBackward"
| "media.speedCycle"
/** Key-to-action mappings for multimedia controls */
const MEDIA_KEY_MAP: Record<string, MediaKeyAction> = {
// Common terminal media keys — these overlap with Player.tsx local
// bindings, but Player guards on `props.focused` so the global
// handler fires independently when the player tab is *not* active.
//
// When Player IS focused both handlers fire, but since the audio
// actions are idempotent (toggle = toggle, seek = additive) having
// them called twice for the same keypress is avoided by the event
// bus approach — the audio hook only processes event-bus events, and
// Player.tsx calls audio methods directly. We therefore guard with
// a "playerFocused" flag passed via options.
}
export interface MultimediaKeysOptions {
/** When true, skip handling (Player.tsx handles keys locally) */
playerFocused?: () => boolean
/** When true, skip handling (text input has focus) */
inputFocused?: () => boolean
/** Whether an episode is currently loaded */
hasEpisode?: () => boolean
}
/**
* Registers a global keyboard listener that emits media events on the
* event bus. Call once at the app level (e.g. in App.tsx).
*/
export function useMultimediaKeys(options: MultimediaKeysOptions = {}) {
useKeyboard((key) => {
// Don't intercept when a text input owns the keyboard
if (options.inputFocused?.()) return
// Don't intercept when Player component handles its own keys
if (options.playerFocused?.()) return
// Ctrl/Meta combos are app-level shortcuts, not media keys
if (key.ctrl || key.meta) return
switch (key.name) {
case "space":
// Toggle play/pause — always valid (may start a loaded episode)
emit("media.toggle", {})
break
case "up":
if (!options.hasEpisode?.()) return
emit("media.volumeUp", {})
break
case "down":
if (!options.hasEpisode?.()) return
emit("media.volumeDown", {})
break
case "left":
if (!options.hasEpisode?.()) return
emit("media.seekBackward", {})
break
case "right":
if (!options.hasEpisode?.()) return
emit("media.seekForward", {})
break
case "s":
if (!options.hasEpisode?.()) return
emit("media.speedCycle", {})
break
default:
// Not a media key — do nothing
break
}
})
}

225
src/index.tsx Normal file
View File

@@ -0,0 +1,225 @@
const VERSION = "0.1.0";
interface CliArgs {
version: boolean;
query: string | null;
play: string | null;
}
function parseArgs(): CliArgs {
const args = process.argv.slice(2);
const result: CliArgs = {
version: false,
query: null,
play: null,
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--version" || arg === "-v") {
result.version = true;
} else if (arg === "--query" || arg === "-q") {
result.query = args[i + 1] || "";
i++;
} else if (arg === "--play" || arg === "-p") {
result.play = args[i + 1] || "";
i++;
}
}
return result;
}
const cliArgs = parseArgs();
if (cliArgs.version) {
console.log(`PodTUI version ${VERSION}`);
process.exit(0);
}
if (cliArgs.query !== null || cliArgs.play !== null) {
import("./utils/feeds-persistence").then(async ({ loadFeedsFromFile }) => {
const feeds = await loadFeedsFromFile();
if (cliArgs.query !== null) {
const query = cliArgs.query;
const normalizedQuery = query.toLowerCase();
const matches = feeds.filter((feed) => {
const title = feed.podcast.title.toLowerCase();
return title.includes(normalizedQuery);
});
if (matches.length === 0) {
console.log(`No shows found matching: ${query}`);
if (feeds.length > 0) {
console.log("\nAvailable shows:");
feeds.slice(0, 5).forEach((feed) => {
console.log(` - ${feed.podcast.title}`);
});
if (feeds.length > 5) {
console.log(` ... and ${feeds.length - 5} more`);
}
}
process.exit(0);
}
if (matches.length === 1) {
const feed = matches[0];
console.log(`\n${feed.podcast.title}`);
if (feed.podcast.description) {
console.log(feed.podcast.description.substring(0, 200) + (feed.podcast.description.length > 200 ? "..." : ""));
}
console.log(`\nRecent episodes (${Math.min(5, feed.episodes.length)}):`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
const date = ep.pubDate instanceof Date ? ep.pubDate.toLocaleDateString() : String(ep.pubDate);
console.log(` ${idx + 1}. ${ep.title} (${date})`);
});
process.exit(0);
}
console.log(`\nClosest matches for "${query}":`);
matches.slice(0, 5).forEach((feed, idx) => {
console.log(` ${idx + 1}. ${feed.podcast.title}`);
});
process.exit(0);
}
if (cliArgs.play !== null) {
const playArg = cliArgs.play;
const normalizedArg = playArg.toLowerCase();
let feedResult: typeof feeds[0] | null = null;
let episodeResult: typeof feeds[0]["episodes"][0] | null = null;
if (normalizedArg === "latest") {
let latestFeed: typeof feeds[0] | null = null;
let latestEpisode: typeof feeds[0]["episodes"][0] | null = null;
let latestDate = 0;
for (const feed of feeds) {
if (feed.episodes.length > 0) {
const ep = feed.episodes[0];
const epDate = ep.pubDate instanceof Date ? ep.pubDate.getTime() : Number(ep.pubDate);
if (epDate > latestDate) {
latestDate = epDate;
latestFeed = feed;
latestEpisode = ep;
}
}
}
feedResult = latestFeed;
episodeResult = latestEpisode;
} else {
const parts = normalizedArg.split("/");
const showQuery = parts[0];
const episodeQuery = parts[1];
const matchingFeeds = feeds.filter((feed) =>
feed.podcast.title.toLowerCase().includes(showQuery)
);
if (matchingFeeds.length === 0) {
console.log(`No show found matching: ${showQuery}`);
process.exit(1);
}
const feed = matchingFeeds[0];
if (!episodeQuery) {
if (feed.episodes.length > 0) {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
console.log(`No episodes available for: ${feed.podcast.title}`);
process.exit(1);
}
} else if (episodeQuery === "latest") {
feedResult = feed;
episodeResult = feed.episodes[0];
} else {
const matchingEpisode = feed.episodes.find((ep) =>
ep.title.toLowerCase().includes(episodeQuery)
);
if (matchingEpisode) {
feedResult = feed;
episodeResult = matchingEpisode;
} else {
console.log(`Episode not found: ${episodeQuery}`);
console.log(`Available episodes for ${feed.podcast.title}:`);
feed.episodes.slice(0, 5).forEach((ep, idx) => {
console.log(` ${idx + 1}. ${ep.title}`);
});
process.exit(1);
}
}
}
if (!feedResult || !episodeResult) {
console.log("Could not find episode to play");
process.exit(1);
}
console.log(`\nPlaying: ${episodeResult.title}`);
console.log(`Show: ${feedResult.podcast.title}`);
try {
const { createAudioBackend } = await import("./utils/audio-player");
const backend = createAudioBackend();
if (episodeResult.audioUrl) {
await backend.play(episodeResult.audioUrl);
console.log("Playback started (use the UI to control)");
} else {
console.log("No audio URL available for this episode");
process.exit(1);
}
} catch (err) {
console.error("Playback error:", err);
process.exit(1);
}
}
}).catch((err) => {
console.error("Error:", err);
process.exit(1);
});
} else {
import("@opentui/solid").then(async ({ render, useRenderer }) => {
const { App } = await import("./App");
const { ThemeProvider } = await import("./context/ThemeContext");
const toast = await import("./ui/toast");
const { KeybindProvider } = await import("./context/KeybindContext");
const { NavigationProvider } = await import("./context/NavigationContext");
const { DialogProvider } = await import("./ui/dialog");
const { CommandProvider } = await import("./ui/command");
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer();
renderer.disableStdoutInterception();
return props.children;
}
render(
() => (
<RendererSetup>
<toast.ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<NavigationProvider>
<DialogProvider>
<CommandProvider>
<App />
<toast.Toast />
</CommandProvider>
</DialogProvider>
</NavigationProvider>
</KeybindProvider>
</ThemeProvider>
</toast.ToastProvider>
</RendererSetup>
),
{ useThread: false },
);
});
}

BIN
src/native/libcavacore.dylib Executable file

Binary file not shown.

27
src/opentui-jsx.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
declare namespace JSX {
type Element = any
interface IntrinsicElements {
box: any
text: any
span: any
input: any
select: any
textarea: any
scrollbox: any
tab_select: any
ascii_font: any
code: any
diff: any
line_number: any
markdown: any
b: any
strong: any
i: any
em: any
u: any
br: any
a: any
}
}
export {}

View File

@@ -0,0 +1,185 @@
/**
* DiscoverPage component - Main discover/browse interface for PodTUI
*/
import { createSignal, For, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { useTheme } from "@/context/ThemeContext";
import { PodcastCard } from "./PodcastCard";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum DiscoverPagePaneType {
CATEGORIES = 1,
SHOWS = 2,
}
export const DiscoverPaneCount = 2;
export function DiscoverPage() {
const discoverStore = useDiscoverStore();
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length > 0 && showIndex() < filteredPodcasts.length) {
setShowIndex(showIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== DiscoverPagePaneType.SHOWS) return;
const filteredPodcasts = discoverStore.filteredPodcasts();
if (filteredPodcasts.length === 0) return;
if (isDown && !isInverting()) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if (isUp && isInverting()) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setShowIndex((i) => (i + 1) % filteredPodcasts.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setShowIndex((i) => (i - 1 + filteredPodcasts.length) % filteredPodcasts.length);
}
},
{ release: false },
);
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId);
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId);
if (index >= 0) setCategoryIndex(index);
setShowIndex(0);
};
const handleShowSelect = (index: number) => {
setShowIndex(index);
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id);
};
const { theme } = useTheme();
return (
<box flexDirection="row" flexGrow={1} height="100%" width="100%" gap={1}>
<box
border
padding={1}
borderColor={
nav.activeDepth() != DiscoverPagePaneType.CATEGORIES
? theme.border
: theme.accent
}
flexDirection="column"
gap={1}
>
<text
fg={
nav.activeDepth() == DiscoverPagePaneType.CATEGORIES
? theme.accent
: theme.text
}
>
Categories:
</text>
<box flexDirection="column" gap={1}>
<For each={discoverStore.categories}>
{(category) => {
const isSelected = () =>
discoverStore.selectedCategory() === category.id;
return (
<SelectableBox
selected={isSelected}
onMouseDown={() => handleCategorySelect(category.id)}
>
<SelectableText selected={isSelected} primary>
{category.icon} {category.name}
</SelectableText>
</SelectableBox>
);
}}
</For>
</box>
</box>
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() == DiscoverPagePaneType.SHOWS
? theme.accent
: theme.border
}
>
<box padding={1}>
<SelectableText
selected={() => false}
primary={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</SelectableText>
</box>
<box flexDirection="column" height="100%">
<Show
fallback={
<box padding={2}>
{discoverStore.filteredPodcasts().length !== 0 ? (
<text fg={theme.warning}>Loading trending shows...</text>
) : (
<text fg={theme.textMuted}>
No podcasts found in this category.
</text>
)}
</box>
}
when={
!discoverStore.isLoading() &&
discoverStore.filteredPodcasts().length === 0
}
>
<scrollbox
focused={nav.activeDepth() == DiscoverPagePaneType.SHOWS}
>
<box flexDirection="column">
<For each={discoverStore.filteredPodcasts()}>
{(podcast, index) => (
<PodcastCard
podcast={podcast}
selected={
index() === showIndex() &&
nav.activeDepth() == DiscoverPagePaneType.SHOWS
}
onSelect={() => handleShowSelect(index())}
onSubscribe={() => handleSubscribe(podcast)}
/>
)}
</For>
</box>
</scrollbox>
</Show>
</box>
</box>
</box>
);
}

View File

@@ -0,0 +1,85 @@
/**
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type PodcastCardProps = {
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const { theme } = useTheme();
const handleSubscribeClick = () => {
props.onSubscribe?.();
};
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText selected={() => props.selected} primary>
<strong>{props.podcast.title}</strong>
</SelectableText>
<Show when={props.podcast.isSubscribed}>
<text fg={theme.success}>[+]</text>
</Show>
</box>
{/* Author */}
<Show when={props.podcast.author && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {props.podcast.author}
</SelectableText>
</Show>
{/* Description */}
<Show when={props.podcast.description && !props.compact}>
<SelectableText
selected={() => props.selected}
tertiary
>
{props.podcast.description!.length > 80
? props.podcast.description!.slice(0, 80) + "..."
: props.podcast.description}
</SelectableText>
</Show>
{/**<box
flexDirection="row"
justifyContent="space-between"
marginTop={props.compact ? 0 : 1}
/>**/}
<box flexDirection="row" gap={1}>
<Show when={(props.podcast.categories ?? []).length > 0}>
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
{(cat) => <text fg={theme.warning}>[{cat}]</text>}
</For>
</Show>
</box>
<Show when={props.selected}>
<box onMouseDown={handleSubscribeClick}>
<text fg={props.podcast.isSubscribed ? theme.error : theme.success}>
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -0,0 +1,194 @@
/**
* Feed detail view component for PodTUI
* Shows podcast info and episode list
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import type { Feed } from "@/types/feed";
import type { Episode } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedDetailProps {
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [showInfo, setShowInfo] = createSignal(true);
const episodes = () => {
// Sort episodes by publication date (newest first)
return [...props.feed.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return `${hrs}h ${mins % 60}m`;
}
return `${mins}m`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v);
return;
}
if (key.name === "v") {
props.feed.podcast.onToggleVisibility?.(props.feed.id);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return") {
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
{/* Header with back button */}
<box flexDirection="row" justifyContent="space-between">
<box border padding={0} onMouseDown={props.onBack} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[Esc] Back</SelectableText>
</box>
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[i] {showInfo() ? "Hide" : "Show"} Info</SelectableText>
</box>
<box border padding={0} onMouseDown={() => props.feed.podcast.onToggleVisibility?.(props.feed.id)} borderColor={theme.border}>
<SelectableText selected={() => false} primary>[v] Toggle Visibility</SelectableText>
</box>
</box>
{/* Podcast info section */}
<Show when={showInfo()}>
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<SelectableText selected={() => false} primary>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
{props.feed.podcast.author && (
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>by</SelectableText>
<SelectableText selected={() => false} primary>{props.feed.podcast.author}</SelectableText>
</box>
)}
<box height={1} />
<SelectableText selected={() => false} tertiary>
{props.feed.podcast.description?.slice(0, 200)}
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
</SelectableText>
<box height={1} />
<box flexDirection="row" gap={2}>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Episodes:</SelectableText>
<SelectableText selected={() => false} tertiary>{props.feed.episodes.length}</SelectableText>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Updated:</SelectableText>
<SelectableText selected={() => false} tertiary>{formatDate(props.feed.lastUpdated)}</SelectableText>
</box>
<SelectableText selected={() => false} tertiary>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</SelectableText>
{props.feed.isPinned && <SelectableText selected={() => false} tertiary>[Pinned]</SelectableText>}
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>[v] Toggle Visibility</SelectableText>
</box>
</box>
</Show>
{/* Episodes header */}
<box flexDirection="row" justifyContent="space-between">
<SelectableText selected={() => false} primary>
<strong>Episodes</strong>
</SelectableText>
<SelectableText selected={() => false} tertiary>({episodes().length} total)</SelectableText>
</box>
{/* Episode list */}
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
<For each={episodes()}>
{(episode, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode);
}
}}
>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{index() === selectedIndex() ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
{episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDate(episode.pubDate)}</SelectableText>
<SelectableText selected={() => index() === selectedIndex()} tertiary>{formatDuration(episode.duration)}</SelectableText>
</box>
</SelectableBox>
)}
</For>
</scrollbox>
{/* Help text */}
<text fg={theme.textMuted}>
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
);
}

View File

@@ -0,0 +1,207 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedFilterProps {
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "private" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "private", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "visibility") {
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort();
} else if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned();
} else if (focusField() === "private") {
togglePrivate();
}
}
};
const cycleVisibility = () => {
const current = props.filter.visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
props.onFilterChange({ ...props.filter, visibility: next });
};
const cycleSort = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const currentIndex = sortOptions.indexOf(
props.filter.sortBy as FeedSortField,
);
const nextIndex = (currentIndex + 1) % sortOptions.length;
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] });
};
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
});
};
const togglePrivate = () => {
props.onFilterChange({
...props.filter,
showPrivate: !props.filter.showPrivate,
});
};
const handleSearchInput = (value: string) => {
setSearchValue(value);
props.onFilterChange({ ...props.filter, searchQuery: value });
};
const visibilityLabel = () => {
const vis = props.filter.visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const visibilityColor = () => {
const vis = props.filter.visibility;
if (vis === "public") return theme.success;
if (vis === "private") return theme.warning;
return theme.text;
};
const sortLabel = () => {
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
case "updated":
default:
return "Updated";
}
};
return (
<box flexDirection="column" border padding={1} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? theme.backgroundElement : undefined}
borderColor={theme.border}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? theme.primary : theme.textMuted}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "sort" ? theme.primary : theme.textMuted}>Sort:</text>
<text fg={theme.text}>{sortLabel()}</text>
</box>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? theme.primary : theme.textMuted}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? theme.warning : theme.textMuted}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
</box>
</box>
{/* Private filter */}
<box
border
padding={0}
backgroundColor={focusField() === "private" ? theme.backgroundElement : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "private" ? theme.primary : theme.textMuted}>
Private:
</text>
<text fg={props.filter.showPrivate ? theme.warning : theme.textMuted}>
{props.filter.showPrivate ? "Yes" : "No"}
</text>
</box>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text fg={focusField() === "search" ? theme.primary : theme.textMuted}>Search:</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text fg={theme.textMuted}>Tab to navigate, Enter/Space to toggle</text>
</box>
);
}

154
src/pages/Feed/FeedItem.tsx Normal file
View File

@@ -0,0 +1,154 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface FeedItemProps {
feed: Feed;
isSelected: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
compact?: boolean;
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? theme.success : theme.warning;
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " ";
};
const { theme } = useTheme();
if (props.compact) {
// Compact single-line view
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => {}}
>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.feed.customName || props.feed.podcast.title}
</SelectableText>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
({episodeCount()})
</SelectableText>
)}
</SelectableBox>
);
}
// Full view with details
return (
<SelectableBox
selected={() => props.isSelected}
flexDirection="column"
gap={0}
padding={1}
onMouseDown={() => {}}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<SelectableText
selected={() => props.isSelected}
primary
>
{props.isSelected ? ">" : " "}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
tertiary
>
{visibilityIcon()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
secondary
>
{pinnedIndicator()}
</SelectableText>
<SelectableText
selected={() => props.isSelected}
primary
>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</SelectableText>
</box>
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
{episodeCount()} episodes ({unplayedCount()} new)
</SelectableText>
)}
{props.showLastUpdated && (
<SelectableText
selected={() => props.isSelected}
tertiary
>
Updated: {formatDate(props.feed.lastUpdated)}
</SelectableText>
)}
</box>
{props.feed.podcast.description && (
<SelectableText
selected={() => props.isSelected}
paddingLeft={4}
paddingTop={0}
tertiary
>
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</SelectableText>
)}
</SelectableBox>
);
}

198
src/pages/Feed/FeedList.tsx Normal file
View File

@@ -0,0 +1,198 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation and mouse support
*/
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { FeedItem } from "./FeedItem";
import { useFeedStore } from "@/stores/feed";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
interface FeedListProps {
focused?: boolean;
compact?: boolean;
showEpisodeCount?: boolean;
showLastUpdated?: boolean;
onSelectFeed?: (feed: Feed) => void;
onOpenFeed?: (feed: Feed) => void;
onFocusChange?: (focused: boolean) => void;
}
export function FeedList(props: FeedListProps) {
const { theme } = useTheme();
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return") {
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id);
}
} else if (key.name === "v") {
// Toggle visibility on selected feed
const feed = feeds[selectedIndex()];
if (feed) {
const newVisibility = feed.visibility === FeedVisibility.PUBLIC ? FeedVisibility.PRIVATE : FeedVisibility.PUBLIC;
feedStore.updateFeed(feed.id, { visibility: newVisibility });
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed);
}
};
useKeyboard((key) => {
if (!props.focused) return;
handleKeyPress(key);
});
const cycleVisibilityFilter = () => {
const current = feedStore.filter().visibility;
let next: FeedVisibility | "all";
if (current === "all") next = FeedVisibility.PUBLIC;
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE;
else next = "all";
feedStore.setFilter({ ...feedStore.filter(), visibility: next });
};
const cycleSortField = () => {
const sortOptions: FeedSortField[] = [
FeedSortField.UPDATED,
FeedSortField.TITLE,
FeedSortField.EPISODE_COUNT,
FeedSortField.LATEST_EPISODE,
];
const current = feedStore.filter().sortBy as FeedSortField;
const idx = sortOptions.indexOf(current);
const next = sortOptions[(idx + 1) % sortOptions.length];
feedStore.setFilter({ ...feedStore.filter(), sortBy: next });
};
const visibilityLabel = () => {
const vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed);
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed);
}
};
return (
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text fg={theme.text}>
<strong>My Feeds</strong>
</text>
<text fg={theme.textMuted}>({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box border padding={0} onMouseDown={cycleVisibilityFilter} borderColor={theme.border}>
<text fg={theme.primary}>[f] {visibilityLabel()}</text>
</box>
<box border padding={0} onMouseDown={cycleSortField} borderColor={theme.border}>
<text fg={theme.primary}>[s] {sortLabel()}</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2} borderColor={theme.border}>
<text fg={theme.textMuted}>
No feeds found. Add podcasts from the Discover or Search tabs.
</text>
</box>
}
>
<scrollbox height={15} focused={props.focused}>
<For each={filteredFeeds()}>
{(feed, index) => (
<box onMouseDown={() => handleFeedClick(feed, index())}>
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
</box>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={0}>
<text fg={theme.textMuted}>
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>
);
}

195
src/pages/Feed/FeedPage.tsx Normal file
View File

@@ -0,0 +1,195 @@
/**
* FeedPage - Shows latest episodes across all subscribed shows
* Reverse chronological order, grouped by date
*/
import { createSignal, For, Show, onMount } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { TABS } from "@/utils/navigation";
import { useKeyboard } from "@opentui/solid";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum FeedPaneType {
FEED = 1,
}
export const FeedPaneCount = 1;
const ITEMS_PER_BATCH = 50;
export function FeedPage() {
const feedStore = useFeedStore();
const nav = useNavigation();
const { theme } = useTheme();
const [selectedEpisodeID, setSelectedEpisodeID] = createSignal<
string | undefined
>();
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const keybind = useKeybinds();
const [focusedIndex, setFocusedIndex] = createSignal(0);
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const episodes = allEpisodes();
if (episodes.length > 0 && episodes[focusedIndex()]) {
setSelectedEpisodeID(episodes[focusedIndex()].episode.id);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== FeedPaneType.FEED) return;
const episodes = allEpisodes();
if (episodes.length === 0) return;
if (isDown && !isInverting()) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if (isUp && isInverting()) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setFocusedIndex((i) => (i + 1) % episodes.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setFocusedIndex((i) => (i - 1 + episodes.length) % episodes.length);
}
},
{ release: false },
);
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const groupEpisodesByDate = () => {
const groups: Record<string, Array<{ episode: Episode; feed: Feed }>> = {};
for (const item of allEpisodes()) {
const dateKey = formatDate(new Date(item.episode.pubDate));
if (!groups[dateKey]) {
groups[dateKey] = [];
}
groups[dateKey].push(item);
}
return Object.entries(groups).sort(([a, _aItems], [b, _bItems]) => {
// Convert date strings back to Date objects for proper chronological sorting
const dateA = new Date(a);
const dateB = new Date(b);
// Sort in descending order (newest first)
return dateB.getTime() - dateA.getTime();
});
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
return (
<box
border
borderColor={
nav.activeDepth() !== FeedPaneType.FEED ? theme.border : theme.accent
}
backgroundColor={theme.background}
flexDirection="column"
height="100%"
width="100%"
>
<Show
when={allEpisodes().length > 0}
fallback={
<box padding={2}>
<text fg={theme.textMuted}>
No episodes yet. Subscribe to shows from Discover or Search.
</text>
</box>
}
>
<scrollbox
height="100%"
focused={nav.activeDepth() == FeedPaneType.FEED}
>
<For each={groupEpisodesByDate()}>
{([date, items]) => (
<box flexDirection="column" gap={1} padding={1}>
<SelectableText selected={() => false} primary>
{date}
</SelectableText>
<For each={items}>
{(item) => {
const isSelected = () => {
if (
nav.activeTab() == TABS.FEED &&
nav.activeDepth() == FeedPaneType.FEED &&
selectedEpisodeID() &&
selectedEpisodeID() === item.episode.id
) {
return true;
}
return false;
};
const isFocused = () => {
const episodes = allEpisodes();
const currentIndex = episodes.findIndex(
(e: any) => e.episode.id === item.episode.id,
);
return currentIndex === focusedIndex();
};
return (
<SelectableBox
selected={isSelected}
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
paddingTop={0}
paddingBottom={0}
onMouseDown={() => {
setSelectedEpisodeID(item.episode.id);
const episodes = allEpisodes();
setFocusedIndex(
episodes.findIndex((e: any) => e.episode.id === item.episode.id),
);
}}
>
<SelectableText selected={isSelected} primary>
{item.episode.title}
</SelectableText>
<box flexDirection="row" gap={2} paddingLeft={2}>
<SelectableText selected={isSelected} primary>
{item.feed.podcast.title}
</SelectableText>
<SelectableText selected={isSelected} tertiary>
{formatDuration(item.episode.duration)}
</SelectableText>
</box>
</SelectableBox>
);
}}
</For>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
);
}

View File

@@ -0,0 +1,325 @@
/**
* MyShowsPage - Two-panel file-explorer style view
* Left panel: list of subscribed shows
* Right panel: episodes for the selected show
*/
import { createSignal, For, Show, createMemo, createEffect, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "@/stores/feed";
import { useDownloadStore } from "@/stores/download";
import { DownloadStatus } from "@/types/episode";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
import { useAudioNavStore, AudioSource } from "@/stores/audio-nav";
import { useNavigation } from "@/context/NavigationContext";
import { LoadingIndicator } from "@/components/LoadingIndicator";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum MyShowsPaneType {
SHOWS = 1,
EPISODES = 2,
}
export const MyShowsPaneCount = 2;
export function MyShowsPage() {
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const audioNav = useAudioNavStore();
const [isRefreshing, setIsRefreshing] = createSignal(false);
const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0);
const { theme } = useTheme();
const mutedColor = () => theme.muted || theme.text;
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
const shows = feedStore.getFilteredFeeds();
const episodesList = episodes();
if (isSelect) {
if (shows.length > 0 && showIndex() < shows.length) {
setShowIndex(showIndex() + 1);
}
if (episodesList.length > 0 && episodeIndex() < episodesList.length) {
setEpisodeIndex(episodeIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== MyShowsPaneType.EPISODES) return;
if (episodesList.length > 0) {
if (isDown && !isInverting()) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if (isUp && isInverting()) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setEpisodeIndex((i) => (i + 1) % episodesList.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setEpisodeIndex((i) => (i - 1 + episodesList.length) % episodesList.length);
}
}
},
{ release: false },
);
});
/** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5;
const shows = () => feedStore.getFilteredFeeds();
const selectedShow = createMemo(() => {
return shows()[0]; //TODO: Integrate with locally handled keyboard navigation
});
const episodes = createMemo(() => {
const show = selectedShow();
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy");
};
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) return `${hrs}h ${mins % 60}m`;
return `${mins}m`;
};
/** Get download status label for an episode */
const downloadLabel = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "[Q]";
case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]`;
}
case DownloadStatus.COMPLETED:
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]";
default:
return "";
}
};
const handleRefresh = async () => {
const show = selectedShow();
if (!show) return;
setIsRefreshing(true);
await feedStore.refreshFeed(show.id);
setIsRefreshing(false);
};
const handleUnsubscribe = () => {
const show = selectedShow();
if (!show) return;
feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
/** Get download status color */
const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return theme.warning.toString();
case DownloadStatus.DOWNLOADING:
return theme.primary.toString();
case DownloadStatus.COMPLETED:
return theme.success.toString();
case DownloadStatus.FAILED:
return theme.error.toString();
default:
return mutedColor().toString();
}
};
return (
<box flexDirection="row" flexGrow={1} width="100%">
<box flexDirection="column" height="100%">
<Show when={isRefreshing()}>
<text fg={theme.warning}>Refreshing...</text>
</Show>
<Show
when={shows().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>
No shows yet. Subscribe from Discover or Search.
</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.SHOWS
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.SHOWS}
>
<For each={shows()}>
{(feed, index) => (
<box
flexDirection="row"
gap={1}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === showIndex() ? theme.primary : undefined
}
onMouseDown={() => {
setShowIndex(index());
setEpisodeIndex(0);
audioNav.setSource(
AudioSource.MY_SHOWS,
selectedShow()?.podcast.id,
);
}}
>
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{index() === showIndex() ? ">" : " "}
</text>
<text
fg={index() === showIndex() ? theme.surface : theme.text}
>
{feed.customName || feed.podcast.title}
</text>
<text fg={index() === showIndex() ? undefined : theme.text}>
({feed.episodes.length})
</text>
</box>
)}
</For>
</scrollbox>
</Show>
</box>
<box flexDirection="column" height="100%">
<Show
when={selectedShow()}
fallback={
<box padding={1}>
<text fg={theme.muted}>Select a show</text>
</box>
}
>
<Show
when={episodes().length > 0}
fallback={
<box padding={1}>
<text fg={theme.muted}>No episodes. Press [r] to refresh.</text>
</box>
}
>
<scrollbox
border
height="100%"
borderColor={
nav.activeDepth() == MyShowsPaneType.EPISODES
? theme.accent
: theme.border
}
focused={nav.activeDepth() == MyShowsPaneType.EPISODES}
>
<For each={episodes()}>
{(episode, index) => (
<box
flexDirection="column"
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={
index() === episodeIndex() ? theme.primary : undefined
}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{index() === episodeIndex() ? ">" : " "}
</text>
<text
fg={
index() === episodeIndex()
? theme.surface
: theme.text
}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title}
</text>
</box>
<box flexDirection="row" gap={2} paddingLeft={2}>
<text
fg={index() === episodeIndex() ? undefined : theme.info}
>
{formatDate(episode.pubDate)}
</text>
<text fg={theme.muted}>
{formatDuration(episode.duration)}
</text>
<Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show>
</box>
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<LoadingIndicator />
</box>
</Show>
<Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}>
<text fg={theme.muted}>Scroll down for more episodes</text>
</box>
</Show>
</scrollbox>
</Show>
</Show>
</box>
</box>
);
}

View File

@@ -0,0 +1,64 @@
import type { BackendName } from "../utils/audio-player"
import { useTheme } from "@/context/ThemeContext"
type PlaybackControlsProps = {
isPlaying: boolean
volume: number
speed: number
backendName?: BackendName
hasAudioUrl?: boolean
onToggle: () => void
onPrev: () => void
onNext: () => void
onVolumeChange: (value: number) => void
onSpeedChange: (value: number) => void
}
const BACKEND_LABELS: Record<BackendName, string> = {
mpv: "mpv",
ffplay: "ffplay",
afplay: "afplay",
system: "system",
none: "none",
}
export function PlaybackControls(props: PlaybackControlsProps) {
const { theme } = useTheme();
return (
<box flexDirection="row" gap={1} alignItems="center" border padding={1} borderColor={theme.border}>
<box border padding={0} onMouseDown={props.onPrev} borderColor={theme.border}>
<text fg={theme.primary}>[Prev]</text>
</box>
<box border padding={0} onMouseDown={props.onToggle} borderColor={theme.border}>
<text fg={theme.primary}>{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box border padding={0} onMouseDown={props.onNext} borderColor={theme.border}>
<text fg={theme.primary}>[Next]</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Vol</text>
<text fg={theme.text}>{Math.round(props.volume * 100)}%</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>Speed</text>
<text fg={theme.text}>{props.speed}x</text>
</box>
{props.backendName && props.backendName !== "none" && (
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg={theme.textMuted}>via</text>
<text fg={theme.primary}>{BACKEND_LABELS[props.backendName]}</text>
</box>
)}
{props.backendName === "none" && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio player found</text>
</box>
)}
{props.hasAudioUrl === false && (
<box marginLeft={2}>
<text fg={theme.warning}>No audio URL</text>
</box>
)}
</box>
)
}

View File

@@ -0,0 +1,112 @@
import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { useKeybinds } from "@/context/KeybindContext";
import { useKeyboard } from "@opentui/solid";
import { onMount } from "solid-js";
enum PlayerPaneType {
PLAYER = 1,
}
export const PlayerPaneCount = 1;
export function PlayerPage() {
const audio = useAudio();
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isInverting = keybind.isInverting(keyEvent);
if (keybind.match("audio-toggle", keyEvent)) {
audio.togglePlayback();
return;
}
if (keybind.match("audio-seek-forward", keyEvent)) {
audio.seek(audio.currentEpisode()?.duration ?? 0);
return;
}
if (keybind.match("audio-seek-backward", keyEvent)) {
audio.seek(0);
return;
}
},
{ release: false },
);
});
const progressPercent = () => {
const d = audio.duration();
if (d <= 0) return 0;
return Math.min(100, Math.round((audio.position() / d) * 100));
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${String(s).padStart(2, "0")}`;
};
return (
<box flexDirection="column" gap={1} width="100%">
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Now Playing</strong>
</text>
<text fg={theme.muted}>
{formatTime(audio.position())} / {formatTime(audio.duration())} (
{progressPercent()}%)
</text>
</box>
{audio.error() && <text fg={theme.error}>{audio.error()}</text>}
<box
border
borderColor={nav.activeDepth() == PlayerPaneType.PLAYER ? theme.accent : theme.border}
padding={1}
flexDirection="column"
gap={1}
>
<text fg={theme.text}>
<strong>{audio.currentEpisode()?.title}</strong>
</text>
<text fg={theme.muted}>{audio.currentEpisode()?.description}</text>
<RealtimeWaveform
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer;
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
};
})()}
/>
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!audio.currentEpisode()?.audioUrl}
onToggle={audio.togglePlayback}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(audio.currentEpisode()?.duration ?? 0)} //TODO: get next chronological(if feed) or episode(if MyShows)
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
</box>
);
}

View File

@@ -0,0 +1,256 @@
/**
* RealtimeWaveform — live audio frequency visualization using cavacore.
*
* Spawns an independent ffmpeg
* process to decode the audio stream, feeds PCM samples through cavacore
* for FFT analysis, and renders frequency bars as colored terminal
* characters at ~30fps.
*/
import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
import { useAudio } from "@/hooks/useAudio";
import { useTheme } from "@/context/ThemeContext";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
const { theme } = useTheme();
const audio = useAudio();
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false);
let cava: CavaCore | null = null;
let reader: AudioStreamReader | null = null;
let frameTimer: ReturnType<typeof setInterval> | null = null;
let sampleBuffer: Float64Array | null = null;
// ── Lifecycle: init cavacore once ──────────────────────────────────
const initCava = () => {
if (cava) return true;
cava = loadCavaCore();
if (!cava) {
setAvailable(false);
return false;
}
setAvailable(true);
return true;
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization();
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = {
bars: 32,
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
};
cava.init(config);
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
if (!reader || reader.url !== url) {
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed);
// Start render loop
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;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer);
if (count === 0) return;
// Feed samples to cavacore → get frequency bars
const input =
count < sampleBuffer.length
? sampleBuffer.subarray(0, count)
: sampleBuffer;
const output = cava.execute(input);
// Copy bar values to a new array for the signal
setBarData(Array.from(output));
};
createEffect(
on(
[
audio.isPlaying,
() => audio.currentEpisode()?.audioUrl ?? "", // may need to fire an error here
audio.speed,
() => 32,
],
([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);
}
}),
);
// Cleanup on unmount
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 ──────────────────────────────────────────────────────
const playedRatio = () =>
audio.duration() <= 0
? 0
: Math.min(1, audio.position() / audio.duration());
const renderLine = () => {
const bars = barData();
const numBars = 32;
// If no data yet, show empty placeholder
if (bars.length === 0) {
const placeholder = ".".repeat(numBars);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
</box>
);
}
const played = Math.floor(numBars * playedRatio());
const playedColor = audio.isPlaying() ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = bars
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{playedChars || " "}</text>
<text fg={futureColor}>{futureChars || " "}</text>
</box>
);
};
const handleClick = (event: { x: number }) => {
const numBars = 32;
const ratio = event.x / numBars;
const next = Math.max(
0,
Math.min(audio.duration(), Math.round(audio.duration() * ratio)),
);
audio.seek(next);
};
return (
<box border borderColor={theme.border} padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
);
}

View File

@@ -0,0 +1,95 @@
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
import { SelectableBox, SelectableText } from "@/components/Selectable";
type ResultCardProps = {
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const { theme } = useTheme();
const podcast = () => props.result.podcast;
return (
<SelectableBox
selected={() => props.selected}
flexDirection="column"
padding={1}
onMouseDown={props.onSelect}
>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<SelectableText
selected={() => props.selected}
primary
>
<strong>{podcast().title}</strong>
</SelectableText>
<SourceBadge
sourceId={props.result.sourceId}
sourceName={props.result.sourceName}
sourceType={props.result.sourceType}
/>
</box>
<Show when={podcast().isSubscribed}>
<text fg={theme.success}>[Subscribed]</text>
</Show>
</box>
<Show when={podcast().author}>
<SelectableText
selected={() => props.selected}
tertiary
>
by {podcast().author}
</SelectableText>
</Show>
<Show when={podcast().description}>
{(description) => (
<SelectableText
selected={() => props.selected}
tertiary
>
{description().length > 120
? description().slice(0, 120) + "..."
: description()}
</SelectableText>
)}
</Show>
<Show when={(podcast().categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(podcast().categories ?? []).slice(0, 3).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<Show when={!podcast().isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
</SelectableBox>
);
}

View File

@@ -0,0 +1,75 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
import { useTheme } from "@/context/ThemeContext";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
const { theme } = useTheme();
return (
<box flexDirection="column" border padding={1} gap={1} height="100%" borderColor={theme.border}>
<Show
when={props.result}
fallback={ <text fg={theme.textMuted}>Select a result to see details.</text>}
>
{(result) => (
<>
<text fg={theme.text}>
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg={theme.textMuted}>by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg={theme.textMuted}>{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg={theme.warning}>[{category}]</text>
))}
</box>
</Show>
<text fg={theme.textMuted}>Feed: {result().podcast.feedUrl}</text>
<text fg={theme.textMuted}>
Updated: {format(result().podcast.lastUpdated, "MMM d, yyyy")}
</text>
<Show when={!result().podcast.isSubscribed}>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
width={18}
onMouseDown={() => props.onSubscribe?.(result())}
>
<text fg={theme.primary}>[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg={theme.success}>Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -0,0 +1,89 @@
/**
* SearchHistory component for displaying and managing search history
*/
import { For, Show } from "solid-js"
import { useTheme } from "@/context/ThemeContext"
import { SelectableBox, SelectableText } from "@/components/Selectable"
type SearchHistoryProps = {
history: string[]
focused: boolean
selectedIndex: number
onSelect?: (query: string) => void
onRemove?: (query: string) => void
onClear?: () => void
onChange?: (index: number) => void
}
export function SearchHistory(props: SearchHistoryProps) {
const { theme } = useTheme();
const handleSearchClick = (index: number, query: string) => {
props.onChange?.(index)
props.onSelect?.(query)
}
const handleRemoveClick = (query: string) => {
props.onRemove?.(query)
}
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Recent Searches</text>
<Show when={props.history.length > 0}>
<box onMouseDown={() => props.onClear?.()} padding={0}>
<text fg={theme.error}>[Clear All]</text>
</box>
</Show>
</box>
<Show
when={props.history.length > 0}
fallback={
<box padding={1}>
<text fg={theme.textMuted}>No recent searches</text>
</box>
}
>
<scrollbox height={10}>
<box flexDirection="column">
<For each={props.history}>
{(query, index) => {
const isSelected = () => index() === props.selectedIndex && props.focused
return (
<SelectableBox
selected={isSelected}
flexDirection="row"
justifyContent="space-between"
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={() => handleSearchClick(index(), query)}
>
<SelectableText
selected={isSelected}
tertiary
>
{">"}
</SelectableText>
<SelectableText
selected={isSelected}
primary
>
{query}
</SelectableText>
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
<text fg={theme.error}>[x]</text>
</box>
</SelectableBox>
)
}}
</For>
</box>
</scrollbox>
</Show>
</box>
)
}

View File

@@ -0,0 +1,210 @@
/**
* SearchPage component - Main search interface for PodTUI
*/
import { createSignal, createEffect, Show, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useSearchStore } from "@/stores/search";
import { SearchResults } from "./SearchResults";
import { SearchHistory } from "./SearchHistory";
import type { SearchResult } from "@/types/source";
import { MyShowsPage } from "../MyShows/MyShowsPage";
import { useTheme } from "@/context/ThemeContext";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum SearchPaneType {
INPUT = 1,
RESULTS = 2,
HISTORY = 3,
}
export const SearchPaneCount = 3;
export function SearchPage() {
const searchStore = useSearchStore();
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
if (isSelect) {
const results = searchStore.results();
if (results.length > 0 && resultIndex() < results.length) {
setResultIndex(resultIndex() + 1);
}
return;
}
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() !== SearchPaneType.RESULTS) return;
const results = searchStore.results();
if (results.length === 0) return;
if (isDown && !isInverting()) {
setResultIndex((i) => (i + 1) % results.length);
} else if (isUp && isInverting()) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
setResultIndex((i) => (i + 1) % results.length);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
setResultIndex((i) => (i - 1 + results.length) % results.length);
}
},
{ release: false },
);
});
const handleSearch = async () => {
const query = inputValue().trim();
if (query) {
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
}
};
const handleHistorySelect = async (query: string) => {
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
//setFocusArea("results"); //TODO: move level
setResultIndex(0);
}
};
const handleResultSelect = (result: SearchResult) => {
//props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
return (
<box flexDirection="column" height="100%" gap={1} width="100%">
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text fg={theme.text}>
<strong>Search Podcasts</strong>
</text>
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Search:</text>
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={nav.activeDepth() === SearchPaneType.INPUT}
width={50}
/>
<box
border
padding={0}
paddingLeft={1}
paddingRight={1}
onMouseDown={handleSearch}
>
<text fg={theme.primary}>[Enter] Search</text>
</box>
</box>
{/* Status */}
<Show when={searchStore.isSearching()}>
<text fg={theme.warning}>Searching...</text>
</Show>
<Show when={searchStore.error()}>
<text fg={theme.error}>{searchStore.error()}</text>
</Show>
</box>
{/* Main Content - Results or History */}
<box flexDirection="row" height="100%" gap={2}>
{/* Results Panel */}
<box
flexDirection="column"
flexGrow={1}
border
borderColor={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.accent
: theme.border
}
>
<box padding={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.RESULTS
? theme.primary
: theme.muted
}
>
Results ({searchStore.results().length})
</text>
</box>
<Show
when={searchStore.results().length > 0}
fallback={
<box padding={2}>
<text fg={theme.muted}>
{searchStore.query()
? "No results found"
: "Enter a search term to find podcasts"}
</text>
</box>
}
>
<SearchResults
results={searchStore.results()}
selectedIndex={resultIndex()}
focused={nav.activeDepth() === SearchPaneType.RESULTS}
onSelect={handleResultSelect}
onChange={setResultIndex}
isSearching={searchStore.isSearching()}
error={searchStore.error()}
/>
</Show>
</box>
{/* History Sidebar */}
<box width={30} border borderColor={theme.border}>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text
fg={
nav.activeDepth() === SearchPaneType.HISTORY
? theme.primary
: theme.muted
}
>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={nav.activeDepth() === SearchPaneType.HISTORY}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
onChange={setHistoryIndex}
/>
</box>
</box>
</box>
</box>
);
}

View File

@@ -0,0 +1,80 @@
/**
* SearchResults component for displaying podcast search results
*/
import { For, Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { ResultCard } from "./ResultCard";
import { ResultDetail } from "./ResultDetail";
type SearchResultsProps = {
results: SearchResult[];
selectedIndex: number;
focused: boolean;
onSelect?: (result: SearchResult) => void;
onChange?: (index: number) => void;
isSearching?: boolean;
error?: string | null;
};
export function SearchResults(props: SearchResultsProps) {
const handleSelect = (index: number) => {
props.onChange?.(index);
};
return (
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
<box padding={1}>
<text fg="red">{props.error}</text>
</box>
}
>
<Show
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
<box flexDirection="row" gap={1} height="100%">
<box flexDirection="column" flexGrow={1}>
<scrollbox height="100%">
<box flexDirection="column" gap={1}>
<For each={props.results}>
{(result, index) => (
<ResultCard
result={result}
selected={index() === props.selectedIndex}
onSelect={() => handleSelect(index())}
onSubscribe={() => props.onSelect?.(result)}
/>
)}
</For>
</box>
</scrollbox>
</box>
<box width={36}>
<ResultDetail
result={props.results[props.selectedIndex]}
onSubscribe={(result) => props.onSelect?.(result)}
/>
</box>
</box>
</Show>
</Show>
</Show>
);
}

View File

@@ -0,0 +1,43 @@
import { SourceType } from "@/types/source";
import { useTheme } from "@/context/ThemeContext";
type SourceBadgeProps = {
sourceId: string;
sourceName?: string;
sourceType?: SourceType;
};
const typeLabel = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return "API";
if (sourceType === SourceType.RSS) return "RSS";
if (sourceType === SourceType.CUSTOM) return "Custom";
return "Source";
};
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
export function SourceBadge(props: SourceBadgeProps) {
const { theme } = useTheme();
const label = () => props.sourceName || props.sourceId;
const typeColor = (sourceType?: SourceType) => {
if (sourceType === SourceType.API) return theme.primary;
if (sourceType === SourceType.RSS) return theme.success;
if (sourceType === SourceType.CUSTOM) return theme.warning;
return theme.textMuted;
};
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg={theme.textMuted}>{label()}</text>
</box>
);
}

View File

@@ -0,0 +1,38 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { SyncStatus } from "./SyncStatus"
import { useTheme } from "@/context/ThemeContext"
export function ExportDialog() {
const { theme } = useTheme();
const filename = createSignal("podcast-sync.json")
const format = createSignal<"json" | "xml">("json")
return (
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>File:</text>
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
</box>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Format:</text>
<tab_select
options={[
{ name: "JSON", description: "Portable" },
{ name: "XML", description: "Structured" },
]}
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
/>
</box>
<box border borderColor={theme.border}>
<text fg={theme.text}>Export {format[0]()} to {filename[0]()}</text>
</box>
<SyncStatus />
</box>
)
}

View File

@@ -0,0 +1,24 @@
import { detectFormat } from "@/utils/file-detector";
import { useTheme } from "@/context/ThemeContext";
type FilePickerProps = {
value: string;
onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
const { theme } = useTheme();
const format = detectFormat(props.value);
return (
<box style={{ flexDirection: "column", gap: 1 }}>
<input
value={props.value}
onInput={props.onChange}
placeholder="/path/to/sync-file.json"
style={{ width: 40 }}
/>
<text fg={theme.text}>Format: {format}</text>
</box>
);
}

View File

@@ -0,0 +1,23 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { FilePicker } from "./FilePicker"
import { useTheme } from "@/context/ThemeContext"
export function ImportDialog() {
const { theme } = useTheme();
const filePath = createSignal("")
return (
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
<box border borderColor={theme.border}>
<text fg={theme.text}>Import selected file</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,178 @@
/**
* Login screen component for PodTUI
* Email/password login with links to code validation and OAuth
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { useTheme } from "@/context/ThemeContext";
import { AUTH_CONFIG } from "@/config/auth";
interface LoginScreenProps {
focused?: boolean;
onNavigateToCode?: () => void;
onNavigateToOAuth?: () => void;
}
type FocusField = "email" | "password" | "submit" | "code" | "oauth";
export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [email, setEmail] = createSignal("");
const [password, setPassword] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("email");
const [emailError, setEmailError] = createSignal<string | null>(null);
const [passwordError, setPasswordError] = createSignal<string | null>(null);
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
const validateEmail = (value: string): boolean => {
if (!value) {
setEmailError("Email is required");
return false;
}
if (!AUTH_CONFIG.email.pattern.test(value)) {
setEmailError("Invalid email format");
return false;
}
setEmailError(null);
return true;
};
const validatePassword = (value: string): boolean => {
if (!value) {
setPasswordError("Password is required");
return false;
}
if (value.length < AUTH_CONFIG.password.minLength) {
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
return false;
}
setPasswordError(null);
return true;
};
const handleSubmit = async () => {
const isEmailValid = validateEmail(email());
const isPasswordValid = validatePassword(password());
if (!isEmailValid || !isPasswordValid) {
return;
}
await auth.login({ email: email(), password: password() });
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "submit") {
handleSubmit();
} else if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
props.onNavigateToOAuth();
}
}
};
return (
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
<text fg={theme.text}>
<strong>Sign In</strong>
</text>
<box height={1} />
{/* Email field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
Email:
</text>
<input
value={email()}
onInput={setEmail}
placeholder="your@email.com"
focused={props.focused && focusField() === "email"}
width={30}
/>
{emailError() && <text fg={theme.error}>{emailError()}</text>}
</box>
{/* Password field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
Password:
</text>
<input
value={password()}
onInput={setPassword}
placeholder="********"
focused={props.focused && focusField() === "password"}
width={30}
/>
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
</box>
<box height={1} />
{/* Submit button */}
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={
focusField() === "submit" ? theme.primary : undefined
}
>
<text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
{/* Alternative auth options */}
<text fg={theme.textMuted}>Or authenticate with:</text>
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "code" ? theme.primary : undefined}
>
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
[C] Sync Code
</text>
</box>
<box
border
borderColor={theme.border}
padding={1}
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
>
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
[O] OAuth Info
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
);
}

View File

@@ -0,0 +1,123 @@
/**
* OAuth placeholder component for PodTUI
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
import { useTheme } from "@/context/ThemeContext";
interface OAuthPlaceholderProps {
focused?: boolean;
onBack?: () => void;
onNavigateToCode?: () => void;
}
type FocusField = "code" | "back";
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("code");
const fields: FocusField[] = ["code", "back"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) {
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack();
}
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>OAuth Authentication</strong>
</text>
<box height={1} />
{/* OAuth providers list */}
<text fg={theme.primary}>Available OAuth Providers:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
{OAUTH_PROVIDERS.map((provider) => (
<box flexDirection="row" gap={1}>
<text fg={provider.enabled ? theme.success : theme.textMuted}>
{provider.enabled ? "[+]" : "[-]"} {provider.name}
</text>
<text fg={theme.textMuted}>- {provider.description}</text>
</box>
))}
</box>
<box height={1} />
{/* Limitation message */}
<box border padding={1} borderColor={theme.warning}>
<text fg={theme.warning}>Terminal Limitations</text>
</box>
<box paddingLeft={1}>
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
<text fg={theme.textMuted}>{line}</text>
))}
</box>
<box height={1} />
{/* Alternative options */}
<text fg={theme.primary}>Recommended Alternatives:</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
<box flexDirection="row" gap={1}>
<text fg={theme.success}>[1]</text>
<text fg={theme.text}>Use a sync code from the web portal</text>
<text fg={theme.success}>[2]</text>
<text fg={theme.text}>Use email/password authentication</text>
<text fg={theme.success}>[3]</text>
<text fg={theme.text}>Use file-based sync (no account needed)</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "code" ? theme.primary : undefined}>
[C] Enter Sync Code
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
[Esc] Back to Login
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
</box>
);
}

View File

@@ -0,0 +1,159 @@
import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
import type { ThemeName } from "@/types/settings";
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" },
{ value: "catppuccin", label: "Catppuccin" },
{ value: "gruvbox", label: "Gruvbox" },
{ value: "tokyo", label: "Tokyo" },
{ value: "nord", label: "Nord" },
{ value: "custom", label: "Custom" },
];
export function PreferencesPanel() {
const appStore = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("theme");
const settings = () => appStore.state().settings;
const preferences = () => appStore.state().preferences;
const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const fields: FocusField[] = [
"theme",
"font",
"speed",
"explicit",
"auto",
];
const idx = fields.indexOf(focusField());
const next = key.shift
? (idx - 1 + fields.length) % fields.length
: (idx + 1) % fields.length;
setFocusField(fields[next]);
return;
}
if (key.name === "left" || key.name === "h") {
stepValue(-1);
}
if (key.name === "right" || key.name === "l") {
stepValue(1);
}
if (key.name === "space" || key.name === "return") {
toggleValue();
}
};
const stepValue = (delta: number) => {
const field = focusField();
if (field === "theme") {
const idx = THEME_LABELS.findIndex((t) => t.value === settings().theme);
const next = (idx + delta + THEME_LABELS.length) % THEME_LABELS.length;
appStore.setTheme(THEME_LABELS[next].value);
return;
}
if (field === "font") {
const next = Math.min(20, Math.max(10, settings().fontSize + delta));
appStore.updateSettings({ fontSize: next });
return;
}
if (field === "speed") {
const next = Math.min(
2,
Math.max(0.5, settings().playbackSpeed + delta * 0.1),
);
appStore.updateSettings({ playbackSpeed: Number(next.toFixed(1)) });
}
};
const toggleValue = () => {
const field = focusField();
if (field === "explicit") {
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
}
if (field === "auto") {
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
}
};
useKeyboard(handleKey);
return (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Preferences</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
Theme:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>
{THEME_LABELS.find((t) => t.value === settings().theme)?.label}
</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
Font Size:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{settings().fontSize}px</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
Playback:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{settings().playbackSpeed}x</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
>
Show Explicit:
</text>
<box border borderColor={theme.border} padding={0}>
<text
fg={preferences().showExplicit ? theme.success : theme.textMuted}
>
{preferences().showExplicit ? "On" : "Off"}
</text>
</box>
<text fg={theme.textMuted}>[Space]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
Auto Download:
</text>
<box border borderColor={theme.border} padding={0}>
<text
fg={preferences().autoDownload ? theme.success : theme.textMuted}
>
{preferences().autoDownload ? "On" : "Off"}
</text>
</box>
<text fg={theme.textMuted}>[Space]</text>
</box>
</box>
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
);
}

View File

@@ -0,0 +1,119 @@
import { createSignal, For, onMount } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { SourceManager } from "./SourceManager";
import { useTheme } from "@/context/ThemeContext";
import { PreferencesPanel } from "./PreferencesPanel";
import { SyncPanel } from "./SyncPanel";
import { VisualizerSettings } from "./VisualizerSettings";
import { useNavigation } from "@/context/NavigationContext";
import { KeybindProvider, useKeybinds } from "@/context/KeybindContext";
enum SettingsPaneType {
SYNC = 1,
SOURCES = 2,
PREFERENCES = 3,
VISUALIZER = 4,
ACCOUNT = 5,
}
export const SettingsPaneCount = 5;
const SECTIONS: Array<{ id: SettingsPaneType; label: string }> = [
{ id: SettingsPaneType.SYNC, label: "Sync" },
{ id: SettingsPaneType.SOURCES, label: "Sources" },
{ id: SettingsPaneType.PREFERENCES, label: "Preferences" },
{ id: SettingsPaneType.VISUALIZER, label: "Visualizer" },
{ id: SettingsPaneType.ACCOUNT, label: "Account" },
];
export function SettingsPage() {
const { theme } = useTheme();
const nav = useNavigation();
const keybind = useKeybinds();
// Helper function to check if a depth is active
const isActive = (depth: SettingsPaneType): boolean => {
return nav.activeDepth() === depth;
};
// Helper function to get the current depth as a number
const currentDepth = () => nav.activeDepth() as number;
onMount(() => {
useKeyboard(
(keyEvent: any) => {
const isDown = keybind.match("down", keyEvent);
const isUp = keybind.match("up", keyEvent);
const isCycle = keybind.match("cycle", keyEvent);
const isSelect = keybind.match("select", keyEvent);
const isInverting = keybind.isInverting(keyEvent);
// don't handle pane navigation here - unified in App.tsx
if (nav.activeDepth() < 1 || nav.activeDepth() > SettingsPaneCount) return;
if (isDown && !isInverting()) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if (isUp && isInverting()) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
} else if ((isCycle && !isInverting()) || (isDown && !isInverting())) {
nav.setActiveDepth((nav.activeDepth() % SettingsPaneCount) + 1);
} else if ((isCycle && isInverting()) || (isUp && isInverting())) {
nav.setActiveDepth((nav.activeDepth() - 2 + SettingsPaneCount) % SettingsPaneCount + 1);
}
},
{ release: false },
);
});
return (
<box flexDirection="column" gap={1} height="100%" width="100%">
<box flexDirection="row" gap={1}>
<For each={SECTIONS}>
{(section, index) => (
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
currentDepth() === section.id ? theme.primary : undefined
}
onMouseDown={() => nav.setActiveDepth(section.id)}
>
<text
fg={
currentDepth() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label}
</text>
</box>
)}
</For>
</box>
<box
border
borderColor={isActive(SettingsPaneType.SYNC) || isActive(SettingsPaneType.SOURCES) || isActive(SettingsPaneType.PREFERENCES) || isActive(SettingsPaneType.VISUALIZER) || isActive(SettingsPaneType.ACCOUNT) ? theme.accent : theme.border}
flexGrow={1}
padding={1}
flexDirection="column"
gap={1}
>
{isActive(SettingsPaneType.SYNC) && <SyncPanel />}
{isActive(SettingsPaneType.SOURCES) && (
<SourceManager focused />
)}
{isActive(SettingsPaneType.PREFERENCES) && (
<PreferencesPanel />
)}
{isActive(SettingsPaneType.VISUALIZER) && (
<VisualizerSettings />
)}
{isActive(SettingsPaneType.ACCOUNT) && (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Account</text>
</box>
)}
</box>
</box>
);
}

View File

@@ -0,0 +1,317 @@
/**
* Source management component for PodTUI
* Add, remove, and configure podcast sources
*/
import { createSignal, For } from "solid-js";
import { useFeedStore } from "@/stores/feed";
import { useTheme } from "@/context/ThemeContext";
import { SourceType } from "@/types/source";
import type { PodcastSource } from "@/types/source";
import { SelectableBox, SelectableText } from "@/components/Selectable";
interface SourceManagerProps {
focused?: boolean;
onClose?: () => void;
}
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language";
export function SourceManager(props: SourceManagerProps) {
const feedStore = useFeedStore();
const { theme } = useTheme();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [focusArea, setFocusArea] = createSignal<FocusArea>("list");
const [newSourceUrl, setNewSourceUrl] = createSignal("");
const [newSourceName, setNewSourceName] = createSignal("");
const [error, setError] = createSignal<string | null>(null);
const sources = () => feedStore.sources();
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "escape") {
if (focusArea() !== "list") {
setFocusArea("list");
setError(null);
} else if (props.onClose) {
props.onClose();
}
return;
}
if (key.name === "tab") {
const areas: FocusArea[] = [
"list",
"country",
"language",
"explicit",
"add",
"url",
];
const idx = areas.indexOf(focusArea());
const nextIdx = key.shift
? (idx - 1 + areas.length) % areas.length
: (idx + 1) % areas.length;
setFocusArea(areas[nextIdx]);
return;
}
if (focusArea() === "list") {
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
} else if (
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source) {
feedStore.toggleSource(source.id);
}
} else if (key.name === "d" || key.name === "delete") {
const source = sources()[selectedIndex()];
if (source) {
const removed = feedStore.removeSource(source.id);
if (!removed) {
setError("Cannot remove default sources");
}
}
} else if (key.name === "a") {
setFocusArea("add");
}
}
if (focusArea() === "country") {
if (
key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
const next = source.country === "US" ? "GB" : "US";
feedStore.updateSource(source.id, { country: next });
}
}
}
if (focusArea() === "explicit") {
if (
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
feedStore.updateSource(source.id, {
allowExplicit: !source.allowExplicit,
});
}
}
}
if (focusArea() === "language") {
if (
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
const next = source.language === "ja_jp" ? "en_us" : "ja_jp";
feedStore.updateSource(source.id, { language: next });
}
}
}
};
const handleAddSource = () => {
const url = newSourceUrl().trim();
const name = newSourceName().trim() || `Custom Source`;
if (!url) {
setError("URL is required");
return;
}
try {
new URL(url);
} catch {
setError("Invalid URL format");
return;
}
feedStore.addSource({
name,
type: "rss" as SourceType,
baseUrl: url,
enabled: true,
description: `Custom RSS feed: ${url}`,
});
setNewSourceUrl("");
setNewSourceName("");
setFocusArea("list");
setError(null);
};
const getSourceIcon = (source: PodcastSource) => {
if (source.type === SourceType.API) return "[API]";
if (source.type === SourceType.RSS) return "[RSS]";
return "[?]";
};
const selectedSource = () => sources()[selectedIndex()];
const isApiSource = () => selectedSource()?.type === SourceType.API;
const sourceCountry = () => selectedSource()?.country || "US";
const sourceExplicit = () => selectedSource()?.allowExplicit !== false;
const sourceLanguage = () => selectedSource()?.language || "en_us";
return (
<box flexDirection="column" border borderColor={theme.border} padding={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<strong>Podcast Sources</strong>
</text>
<box border borderColor={theme.border} padding={0} onMouseDown={props.onClose}>
<text fg={theme.primary}>[Esc] Close</text>
</box>
</box>
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
{/* Source list */}
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
Sources:
</text>
<scrollbox height={6}>
<For each={sources()}>
{(source, index) => (
<SelectableBox
selected={() => focusArea() === "list" && index() === selectedIndex()}
flexDirection="row"
gap={1}
padding={0}
onMouseDown={() => {
setSelectedIndex(index());
setFocusArea("list");
feedStore.toggleSource(source.id);
}}
>
<SelectableText
selected={() => focusArea() === "list" && index() === selectedIndex()}
primary
>
{focusArea() === "list" && index() === selectedIndex()
? ">"
: " "}
</SelectableText>
<SelectableText
selected={() => focusArea() === "list" && index() === selectedIndex()}
primary
>
{source.name}
</SelectableText>
</SelectableBox>
)}
</For>
</scrollbox>
<text fg={theme.textMuted}>
Space/Enter to toggle, d to delete, a to add
</text>
{/* API settings */}
<box flexDirection="column" gap={1}>
<SelectableText selected={() => false} primary={isApiSource()}>
{isApiSource()
? "API Settings"
: "API Settings (select an API source)"}
</SelectableText>
<box flexDirection="row" gap={2}>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "country" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "country"}>
Country: {sourceCountry()}
</SelectableText>
</box>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "language" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "language"}>
Language:{" "}
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</SelectableText>
</box>
<box
border
borderColor={theme.border}
padding={0}
backgroundColor={
focusArea() === "explicit" ? theme.primary : undefined
}
>
<SelectableText selected={() => false} primary={focusArea() === "explicit"}>
Explicit: {sourceExplicit() ? "Yes" : "No"}
</SelectableText>
</box>
</box>
<SelectableText selected={() => false} tertiary>
Enter/Space to toggle focused setting
</SelectableText>
</box>
</box>
{/* Add new source form */}
<box border borderColor={theme.border} padding={1} flexDirection="column" gap={1}>
<SelectableText selected={() => false} primary={focusArea() === "add" || focusArea() === "url"}>
Add New Source:
</SelectableText>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>Name:</SelectableText>
<input
value={newSourceName()}
onInput={setNewSourceName}
placeholder="My Custom Feed"
focused={props.focused && focusArea() === "add"}
width={25}
/>
</box>
<box flexDirection="row" gap={1}>
<SelectableText selected={() => false} tertiary>URL:</SelectableText>
<input
value={newSourceUrl()}
onInput={(v) => {
setNewSourceUrl(v);
setError(null);
}}
placeholder="https://example.com/feed.rss"
focused={props.focused && focusArea() === "url"}
width={35}
/>
</box>
<box border borderColor={theme.border} padding={0} width={15} onMouseDown={handleAddSource}>
<SelectableText selected={() => false} primary>[+] Add Source</SelectableText>
</box>
</box>
{/* Error message */}
{error() && <SelectableText selected={() => false} tertiary>{error()}</SelectableText>}
<SelectableText selected={() => false} tertiary>Tab to switch sections, Esc to close</SelectableText>
</box>
);
}

View File

@@ -0,0 +1,18 @@
import { useTheme } from "@/context/ThemeContext"
type SyncErrorProps = {
message: string
onRetry: () => void
}
export function SyncError(props: SyncErrorProps) {
const { theme } = useTheme();
return (
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<text fg={theme.text}>{props.message}</text>
<box border borderColor={theme.border} onMouseDown={props.onRetry}>
<text fg={theme.text}>Retry</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,32 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { ImportDialog } from "./ImportDialog"
import { ExportDialog } from "./ExportDialog"
import { SyncStatus } from "./SyncStatus"
import { useTheme } from "@/context/ThemeContext"
export function SyncPanel() {
const { theme } = useTheme();
const mode = createSignal<"import" | "export" | null>(null)
return (
<box style={{ flexDirection: "column", gap: 1 }}>
<box style={{ flexDirection: "row", gap: 1 }}>
<box border borderColor={theme.border} onMouseDown={() => mode[1]("import")}>
<text fg={theme.text}>Import</text>
</box>
<box border borderColor={theme.border} onMouseDown={() => mode[1]("export")}>
<text fg={theme.text}>Export</text>
</box>
</box>
<SyncStatus />
{mode[0]() === "import" ? <ImportDialog /> : null}
{mode[0]() === "export" ? <ExportDialog /> : null}
</box>
)
}

View File

@@ -0,0 +1,157 @@
/**
* Sync profile component for PodTUI
* Displays user profile information and sync status
*/
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { format } from "date-fns";
import { useTheme } from "@/context/ThemeContext";
interface SyncProfileProps {
focused?: boolean;
onLogout?: () => void;
onManageSync?: () => void;
}
type FocusField = "sync" | "export" | "logout";
export function SyncProfile(props: SyncProfileProps) {
const auth = useAuthStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("sync");
const [lastSyncTime] = createSignal<Date | null>(new Date());
const fields: FocusField[] = ["sync", "export", "logout"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return") {
if (focusField() === "sync" && props.onManageSync) {
props.onManageSync();
} else if (focusField() === "logout" && props.onLogout) {
handleLogout();
}
}
};
const handleLogout = () => {
auth.logout();
if (props.onLogout) {
props.onLogout();
}
};
const formatDate = (date: Date | null | undefined): string => {
if (!date) return "Never";
return format(date, "MMM d, yyyy HH:mm");
};
const user = () => auth.state().user;
// Get user initials for avatar
const userInitials = () => {
const name = user()?.name || "?";
return name.slice(0, 2).toUpperCase();
};
return (
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
<text fg={theme.text}>
<strong>User Profile</strong>
</text>
<box height={1} />
{/* User avatar and info */}
<box flexDirection="row" gap={2}>
{/* ASCII avatar */}
<box
border
padding={1}
width={8}
height={4}
justifyContent="center"
alignItems="center"
>
<text fg={theme.primary}>{userInitials()}</text>
</box>
{/* User details */}
<box flexDirection="column" gap={0}>
<text fg={theme.text}>{user()?.name || "Guest User"}</text>
<text fg={theme.textMuted}>{user()?.email || "No email"}</text>
<text fg={theme.textMuted}>Joined: {formatDate(user()?.createdAt)}</text>
</box>
</box>
<box height={1} />
{/* Sync status section */}
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
<text fg={theme.primary}>Sync Status</text>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Status:</text>
<text fg={user()?.syncEnabled ? theme.success : theme.warning}>
{user()?.syncEnabled ? "Enabled" : "Disabled"}
</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Last Sync:</text>
<text fg={theme.text}>{formatDate(lastSyncTime())}</text>
</box>
<box flexDirection="row" gap={1}>
<text fg={theme.textMuted}>Method:</text>
<text fg={theme.text}>File-based (JSON/XML)</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "sync" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "sync" ? theme.primary : undefined}>
[S] Manage Sync
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "export" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "export" ? theme.primary : undefined}>
[E] Export Data
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "logout" ? theme.backgroundElement : undefined}
>
<text fg={focusField() === "logout" ? theme.error : theme.textMuted}>
[L] Logout
</text>
</box>
</box>
<box height={1} />
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
);
}

View File

@@ -0,0 +1,28 @@
import { useTheme } from "@/context/ThemeContext"
type SyncProgressProps = {
value: number
}
export function SyncProgress(props: SyncProgressProps) {
const { theme } = useTheme();
const width = 30
let filled = (props.value / 100) * width
filled = filled >= 0 ? filled : 0
filled = filled <= width ? filled : width
filled = filled | 0
if (filled < 0) filled = 0
if (filled > width) filled = width
let bar = ""
for (let i = 0; i < width; i += 1) {
bar += i < filled ? "#" : "-"
}
return (
<box style={{ flexDirection: "column" }}>
<text fg={theme.text}>{bar}</text>
<text fg={theme.text}>{props.value}%</text>
</box>
)
}

View File

@@ -0,0 +1,52 @@
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
let current = value
return [() => current, (next) => {
current = next
}]
}
import { SyncProgress } from "./SyncProgress"
import { SyncError } from "./SyncError"
import { useTheme } from "@/context/ThemeContext"
type SyncState = "idle" | "syncing" | "complete" | "error"
export function SyncStatus() {
const { theme } = useTheme();
const state = createSignal<SyncState>("idle")
const message = createSignal("Idle")
const progress = createSignal(0)
const toggle = () => {
if (state[0]() === "idle") {
state[1]("syncing")
message[1]("Syncing...")
progress[1](40)
} else if (state[0]() === "syncing") {
state[1]("complete")
message[1]("Sync complete")
progress[1](100)
} else if (state[0]() === "complete") {
state[1]("error")
message[1]("Sync failed")
} else {
state[1]("idle")
message[1]("Idle")
progress[1](0)
}
}
return (
<box border title="Sync Status" borderColor={theme.border} style={{ padding: 1, flexDirection: "column", gap: 1 }}>
<box style={{ flexDirection: "row", gap: 1 }}>
<text fg={theme.text}>Status:</text>
<text fg={theme.text}>{message[0]()}</text>
</box>
<SyncProgress value={progress[0]()} />
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
<box border borderColor={theme.border} onMouseDown={toggle}>
<text fg={theme.text}>Cycle Status</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,164 @@
/**
* VisualizerSettings — settings panel for the real-time audio visualizer.
*
* Allows adjusting bar count, noise reduction, sensitivity, and
* frequency cutoffs. All changes persist via the app store.
*/
import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useAppStore } from "@/stores/app";
import { useTheme } from "@/context/ThemeContext";
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
const FIELDS: FocusField[] = [
"bars",
"sensitivity",
"noise",
"lowCut",
"highCut",
];
export function VisualizerSettings() {
const appStore = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("bars");
const viz = () => appStore.state().settings.visualizer;
const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const idx = FIELDS.indexOf(focusField());
const next = key.shift
? (idx - 1 + FIELDS.length) % FIELDS.length
: (idx + 1) % FIELDS.length;
setFocusField(FIELDS[next]);
return;
}
if (key.name === "left" || key.name === "h") {
stepValue(-1);
}
if (key.name === "right" || key.name === "l") {
stepValue(1);
}
};
const stepValue = (delta: number) => {
const field = focusField();
const v = viz();
switch (field) {
case "bars": {
// Step by 8: 8, 16, 24, 32, ..., 128
const next = Math.min(128, Math.max(8, v.bars + delta * 8));
appStore.updateVisualizer({ bars: next });
break;
}
case "sensitivity": {
// Toggle: 0 (manual) or 1 (auto)
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 });
break;
}
case "noise": {
// Step by 0.05: 0.0 1.0
const next = Math.min(
1,
Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))),
);
appStore.updateVisualizer({ noiseReduction: next });
break;
}
case "lowCut": {
// Step by 10: 20 500 Hz
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10));
appStore.updateVisualizer({ lowCutOff: next });
break;
}
case "highCut": {
// Step by 500: 1000 20000 Hz
const next = Math.min(
20000,
Math.max(1000, v.highCutOff + delta * 500),
);
appStore.updateVisualizer({ highCutOff: next });
break;
}
}
};
useKeyboard(handleKey);
return (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Visualizer</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
Bars:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{viz().bars}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text
fg={
focusField() === "sensitivity" ? theme.primary : theme.textMuted
}
>
Auto Sensitivity:
</text>
<box border borderColor={theme.border} padding={0}>
<text
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
>
{viz().sensitivity === 1 ? "On" : "Off"}
</text>
</box>
<text fg={theme.textMuted}>[Left/Right]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
Noise Reduction:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
>
Low Cutoff:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
>
High Cutoff:
</text>
<box border borderColor={theme.border} padding={0}>
<text fg={theme.text}>{viz().highCutOff} Hz</text>
</box>
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
</box>
</box>
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
);
}

130
src/stores/app.ts Normal file
View File

@@ -0,0 +1,130 @@
import { createSignal } from "solid-js";
import { DEFAULT_THEME, THEME_JSON } from "../constants/themes";
import type {
AppSettings,
AppState,
ThemeColors,
ThemeName,
ThemeMode,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { resolveTheme } from "../utils/theme-resolver";
import type { ThemeJson } from "../types/theme-schema";
import {
loadAppStateFromFile,
saveAppStateToFile,
} from "../utils/app-persistence";
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
export function createAppStore() {
// Start with defaults; async load will update once ready
const [state, setState] = createSignal<AppState>(defaultState);
// Fire-and-forget async initialisation
const init = async () => {
const loaded = await loadAppStateFromFile();
setState(loaded);
};
init();
const saveState = (next: AppState) => {
saveAppStateToFile(next).catch(() => {});
};
const updateState = (next: AppState) => {
setState(next);
saveState(next);
};
const updateSettings = (updates: Partial<AppSettings>) => {
const next = {
...state(),
settings: { ...state().settings, ...updates },
};
updateState(next);
};
const updatePreferences = (updates: Partial<UserPreferences>) => {
const next = {
...state(),
preferences: { ...state().preferences, ...updates },
};
updateState(next);
};
const updateCustomTheme = (updates: Partial<ThemeColors>) => {
const next = {
...state(),
customTheme: { ...state().customTheme, ...updates },
};
updateState(next);
};
const updateVisualizer = (updates: Partial<VisualizerSettings>) => {
updateSettings({
visualizer: { ...state().settings.visualizer, ...updates },
});
};
const setTheme = (theme: ThemeName) => {
updateSettings({ theme });
};
const resolveThemeColors = (): ThemeColors => {
const theme = state().settings.theme;
if (theme === "custom") return state().customTheme;
if (theme === "system") return DEFAULT_THEME;
const json = THEME_JSON[theme];
if (!json) return DEFAULT_THEME;
return resolveTheme(
json as ThemeJson,
"dark" as ThemeMode,
) as unknown as ThemeColors;
};
return {
state,
updateSettings,
updatePreferences,
updateCustomTheme,
updateVisualizer,
setTheme,
resolveTheme: resolveThemeColors,
};
}
let appStoreInstance: ReturnType<typeof createAppStore> | null = null;
export function useAppStore() {
if (!appStoreInstance) {
appStoreInstance = createAppStore();
}
return appStoreInstance;
}

126
src/stores/audio-nav.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* Audio navigation store for tracking episode order and position
* Persists the current episode context (source type, index, and podcastId)
*/
import { createSignal } from "solid-js";
import {
loadAudioNavFromFile,
saveAudioNavToFile,
} from "../utils/app-persistence";
/** Source type for audio navigation */
export enum AudioSource {
FEED = "feed",
MY_SHOWS = "my_shows",
SEARCH = "search",
}
/** Audio navigation state */
export interface AudioNavState {
/** Current source type */
source: AudioSource;
/** Index of current episode in the ordered list */
currentIndex: number;
/** Podcast ID for My Shows source */
podcastId?: string;
/** Timestamp when navigation state was last saved */
lastUpdated: Date;
}
/** Default navigation state */
const defaultNavState: AudioNavState = {
source: AudioSource.FEED,
currentIndex: 0,
lastUpdated: new Date(),
};
/** Create audio navigation store */
export function createAudioNavStore() {
const [navState, setNavState] = createSignal<AudioNavState>(defaultNavState);
/** Persist current navigation state to file (fire-and-forget) */
function persist(): void {
saveAudioNavToFile(navState()).catch(() => {});
}
/** Load navigation state from file */
async function init(): Promise<void> {
const loaded = await loadAudioNavFromFile<AudioNavState>();
if (loaded) {
setNavState(loaded);
}
}
/** Fire-and-forget initialization */
init();
return {
/** Get current navigation state */
get state(): AudioNavState {
return navState();
},
/** Update source type */
setSource: (source: AudioSource, podcastId?: string) => {
setNavState((prev) => ({
...prev,
source,
podcastId,
lastUpdated: new Date(),
}));
persist();
},
/** Move to next episode */
next: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
currentIndex,
lastUpdated: new Date(),
}));
persist();
},
/** Move to previous episode */
prev: (currentIndex: number) => {
setNavState((prev) => ({
...prev,
currentIndex,
lastUpdated: new Date(),
}));
persist();
},
/** Reset to default state */
reset: () => {
setNavState(defaultNavState);
persist();
},
/** Get current index */
getCurrentIndex: (): number => {
return navState().currentIndex;
},
/** Get current source */
getSource: (): AudioSource => {
return navState().source;
},
/** Get current podcast ID */
getPodcastId: (): string | undefined => {
return navState().podcastId;
},
};
}
/** Singleton instance */
let audioNavInstance: ReturnType<typeof createAudioNavStore> | null = null;
export function useAudioNavStore() {
if (!audioNavInstance) {
audioNavInstance = createAudioNavStore();
}
return audioNavInstance;
}

244
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,244 @@
/**
* Authentication store for PodTUI
* Uses Zustand for state management with localStorage persistence
* Authentication is DISABLED by default
*/
import { createSignal } from "solid-js"
import type {
User,
AuthState,
AuthError,
AuthErrorCode,
LoginCredentials,
AuthScreen,
} from "../types/auth"
import { AUTH_CONFIG, DEFAULT_AUTH_ENABLED } from "../config/auth"
/** Initial auth state */
const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
}
/** Load auth state from localStorage */
function loadAuthState(): AuthState {
if (typeof localStorage === "undefined") {
return initialState
}
try {
const stored = localStorage.getItem(AUTH_CONFIG.storage.authState)
if (stored) {
const parsed = JSON.parse(stored)
// Convert date strings back to Date objects
if (parsed.user?.createdAt) {
parsed.user.createdAt = new Date(parsed.user.createdAt)
}
if (parsed.user?.lastLoginAt) {
parsed.user.lastLoginAt = new Date(parsed.user.lastLoginAt)
}
return parsed
}
} catch {
// Ignore parse errors, use initial state
}
return initialState
}
/** Save auth state to localStorage */
function saveAuthState(state: AuthState): void {
if (typeof localStorage === "undefined") {
return
}
try {
localStorage.setItem(AUTH_CONFIG.storage.authState, JSON.stringify(state))
} catch {
// Ignore storage errors
}
}
/** Create auth store using Solid signals */
export function createAuthStore() {
const [state, setState] = createSignal<AuthState>(loadAuthState())
const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED)
const [currentScreen, setCurrentScreen] = createSignal<AuthScreen>("login")
/** Update state and persist */
const updateState = (updates: Partial<AuthState>) => {
setState((prev) => {
const next = { ...prev, ...updates }
saveAuthState(next)
return next
})
}
/** Login with email/password (placeholder - no real backend) */
const login = async (credentials: LoginCredentials): Promise<boolean> => {
updateState({ isLoading: true, error: null })
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
// Validate email format
if (!AUTH_CONFIG.email.pattern.test(credentials.email)) {
updateState({
isLoading: false,
error: {
code: "INVALID_CREDENTIALS" as AuthErrorCode,
message: "Invalid email format",
},
})
return false
}
// Validate password length
if (credentials.password.length < AUTH_CONFIG.password.minLength) {
updateState({
isLoading: false,
error: {
code: "INVALID_CREDENTIALS" as AuthErrorCode,
message: `Password must be at least ${AUTH_CONFIG.password.minLength} characters`,
},
})
return false
}
// Create mock user (in real app, this would validate against backend)
const user: User = {
id: crypto.randomUUID(),
email: credentials.email,
name: credentials.email.split("@")[0],
createdAt: new Date(),
lastLoginAt: new Date(),
syncEnabled: true,
}
updateState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
})
return true
}
/** Logout and clear state */
const logout = () => {
updateState({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
})
setCurrentScreen("login")
}
/** Validate 8-character code */
const validateCode = async (code: string): Promise<boolean> => {
updateState({ isLoading: true, error: null })
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "")
// Check code length
if (normalizedCode.length !== AUTH_CONFIG.codeValidation.codeLength) {
updateState({
isLoading: false,
error: {
code: "INVALID_CODE" as AuthErrorCode,
message: `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`,
},
})
return false
}
// Check code format
if (!AUTH_CONFIG.codeValidation.allowedChars.test(normalizedCode)) {
updateState({
isLoading: false,
error: {
code: "INVALID_CODE" as AuthErrorCode,
message: "Code must contain only letters and numbers",
},
})
return false
}
// Mock successful code validation
const user: User = {
id: crypto.randomUUID(),
email: `sync-${normalizedCode.toLowerCase()}@podtui.local`,
name: `Sync User (${normalizedCode.slice(0, 4)})`,
createdAt: new Date(),
lastLoginAt: new Date(),
syncEnabled: true,
}
updateState({
user,
isAuthenticated: true,
isLoading: false,
error: null,
})
return true
}
/** Clear error */
const clearError = () => {
updateState({ error: null })
}
/** Enable/disable auth */
const toggleAuthEnabled = () => {
setAuthEnabled((prev) => !prev)
}
return {
// State accessors (signals)
state,
authEnabled,
currentScreen,
// Actions
login,
logout,
validateCode,
clearError,
setCurrentScreen,
toggleAuthEnabled,
// Computed
get user() {
return state().user
},
get isAuthenticated() {
return state().isAuthenticated
},
get isLoading() {
return state().isLoading
},
get error() {
return state().error
},
}
}
/** Singleton auth store instance */
let authStoreInstance: ReturnType<typeof createAuthStore> | null = null
/** Get or create auth store */
export function useAuthStore() {
if (!authStoreInstance) {
authStoreInstance = createAuthStore()
}
return authStoreInstance
}

215
src/stores/discover.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* Discover store for PodTUI
* Manages trending/popular podcasts and category filtering
*/
import { createSignal } from "solid-js"
import type { Podcast } from "../types/podcast"
export interface DiscoverCategory {
id: string
name: string
icon: string
}
export const DISCOVER_CATEGORIES: DiscoverCategory[] = [
{ id: "all", name: "All", icon: "*" },
{ id: "technology", name: "Technology", icon: ">" },
{ id: "science", name: "Science", icon: "~" },
{ id: "comedy", name: "Comedy", icon: ")" },
{ id: "news", name: "News", icon: "!" },
{ id: "business", name: "Business", icon: "$" },
{ id: "health", name: "Health", icon: "+" },
{ id: "education", name: "Education", icon: "?" },
{ id: "sports", name: "Sports", icon: "#" },
{ id: "true-crime", name: "True Crime", icon: "%" },
{ id: "arts", name: "Arts", icon: "@" },
]
/** Mock trending podcasts */
const TRENDING_PODCASTS: Podcast[] = [
{
id: "trend-1",
title: "AI Today",
description: "The latest developments in artificial intelligence, machine learning, and their impact on society.",
feedUrl: "https://example.com/aitoday.rss",
author: "Tech Futures",
categories: ["Technology", "Science"],
coverUrl: undefined,
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-2",
title: "The History Hour",
description: "Fascinating stories from history that shaped our world today.",
feedUrl: "https://example.com/historyhour.rss",
author: "History Channel",
categories: ["Education", "History"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-3",
title: "Comedy Gold",
description: "Weekly stand-up comedy, sketches, and hilarious conversations.",
feedUrl: "https://example.com/comedygold.rss",
author: "Laugh Factory",
categories: ["Comedy", "Entertainment"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-4",
title: "Market Watch",
description: "Daily financial news, stock analysis, and investing tips.",
feedUrl: "https://example.com/marketwatch.rss",
author: "Finance Daily",
categories: ["Business", "News"],
lastUpdated: new Date(),
isSubscribed: true,
},
{
id: "trend-5",
title: "Science Weekly",
description: "Breaking science news and in-depth analysis of the latest research.",
feedUrl: "https://example.com/scienceweekly.rss",
author: "Science Network",
categories: ["Science", "Education"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-6",
title: "True Crime Files",
description: "Investigative journalism into real criminal cases and unsolved mysteries.",
feedUrl: "https://example.com/truecrime.rss",
author: "Crime Network",
categories: ["True Crime", "Documentary"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-7",
title: "Wellness Journey",
description: "Tips for mental and physical health, meditation, and mindful living.",
feedUrl: "https://example.com/wellness.rss",
author: "Health Media",
categories: ["Health", "Self-Help"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-8",
title: "Sports Talk Live",
description: "Live commentary, analysis, and interviews from the world of sports.",
feedUrl: "https://example.com/sportstalk.rss",
author: "Sports Network",
categories: ["Sports", "News"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-9",
title: "Creative Minds",
description: "Interviews with artists, designers, and creative professionals.",
feedUrl: "https://example.com/creativeminds.rss",
author: "Arts Weekly",
categories: ["Arts", "Culture"],
lastUpdated: new Date(),
isSubscribed: false,
},
{
id: "trend-10",
title: "Dev Talk",
description: "Software development, programming tutorials, and tech career advice.",
feedUrl: "https://example.com/devtalk.rss",
author: "Code Academy",
categories: ["Technology", "Education"],
lastUpdated: new Date(),
isSubscribed: true,
},
]
/** Create discover store */
export function createDiscoverStore() {
const [selectedCategory, setSelectedCategory] = createSignal<string>("all")
const [isLoading, setIsLoading] = createSignal(false)
const [podcasts, setPodcasts] = createSignal<Podcast[]>(TRENDING_PODCASTS)
/** Get filtered podcasts by category */
const filteredPodcasts = () => {
const category = selectedCategory()
if (category === "all") {
return podcasts()
}
return podcasts().filter((p) => {
const cats = p.categories?.map((c) => c.toLowerCase()) ?? []
return cats.some((c) => c.includes(category.toLowerCase().replace("-", " ")))
})
}
/** Subscribe to a podcast */
const subscribe = (podcastId: string) => {
setPodcasts((prev) =>
prev.map((p) =>
p.id === podcastId ? { ...p, isSubscribed: true } : p
)
)
}
/** Unsubscribe from a podcast */
const unsubscribe = (podcastId: string) => {
setPodcasts((prev) =>
prev.map((p) =>
p.id === podcastId ? { ...p, isSubscribed: false } : p
)
)
}
/** Toggle subscription */
const toggleSubscription = (podcastId: string) => {
const podcast = podcasts().find((p) => p.id === podcastId)
if (podcast?.isSubscribed) {
unsubscribe(podcastId)
} else {
subscribe(podcastId)
}
}
/** Refresh trending podcasts (mock) */
const refresh = async () => {
setIsLoading(true)
// Simulate network delay
await new Promise((r) => setTimeout(r, 500))
// In real app, would fetch from API
setIsLoading(false)
}
return {
// State
selectedCategory,
isLoading,
podcasts,
filteredPodcasts,
categories: DISCOVER_CATEGORIES,
// Actions
setSelectedCategory,
subscribe,
unsubscribe,
toggleSubscription,
refresh,
}
}
/** Singleton discover store */
let discoverStoreInstance: ReturnType<typeof createDiscoverStore> | null = null
export function useDiscoverStore() {
if (!discoverStoreInstance) {
discoverStoreInstance = createDiscoverStore()
}
return discoverStoreInstance
}

360
src/stores/download.ts Normal file
View File

@@ -0,0 +1,360 @@
/**
* Download store for PodTUI
*
* Manages per-episode download state with SolidJS signals, persists download
* metadata to downloads.json in XDG_CONFIG_HOME, and provides a sequential
* download queue (max 2 concurrent).
*/
import { createSignal } from "solid-js"
import { DownloadStatus } from "../types/episode"
import type { DownloadedEpisode } from "../types/episode"
import type { Episode } from "../types/episode"
import { downloadEpisode } from "../utils/episode-downloader"
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"
import { backupConfigFile } from "../utils/config-backup"
const DOWNLOADS_FILE = "downloads.json"
const MAX_CONCURRENT = 2
/** Serializable download record for persistence */
interface DownloadRecord {
episodeId: string
feedId: string
status: DownloadStatus
filePath: string | null
downloadedAt: string | null
fileSize: number
error: string | null
audioUrl: string
episodeTitle: string
}
/** Queue item for pending downloads */
interface QueueItem {
episodeId: string
feedId: string
audioUrl: string
episodeTitle: string
}
/** Create download store */
export function createDownloadStore() {
const [downloads, setDownloads] = createSignal<Map<string, DownloadedEpisode>>(new Map())
const [queue, setQueue] = createSignal<QueueItem[]>([])
const [activeCount, setActiveCount] = createSignal(0)
/** Active AbortControllers keyed by episodeId */
const abortControllers = new Map<string, AbortController>()
// Load persisted downloads on init
;(async () => {
const loaded = await loadDownloads()
if (loaded.size > 0) setDownloads(loaded)
// Resume any queued downloads from previous session
resumeIncomplete()
})()
/** Load downloads from JSON file */
async function loadDownloads(): Promise<Map<string, DownloadedEpisode>> {
try {
const filePath = getConfigFilePath(DOWNLOADS_FILE)
const file = Bun.file(filePath)
if (!(await file.exists())) return new Map()
const raw: DownloadRecord[] = await file.json()
if (!Array.isArray(raw)) return new Map()
const map = new Map<string, DownloadedEpisode>()
for (const rec of raw) {
map.set(rec.episodeId, {
episodeId: rec.episodeId,
feedId: rec.feedId,
status: rec.status === DownloadStatus.DOWNLOADING ? DownloadStatus.QUEUED : rec.status,
progress: rec.status === DownloadStatus.COMPLETED ? 100 : 0,
filePath: rec.filePath,
downloadedAt: rec.downloadedAt ? new Date(rec.downloadedAt) : null,
speed: 0,
fileSize: rec.fileSize,
error: rec.error,
})
}
return map
} catch {
return new Map()
}
}
/** Persist downloads to JSON file */
async function saveDownloads(): Promise<void> {
try {
await ensureConfigDir()
await backupConfigFile(DOWNLOADS_FILE)
const map = downloads()
const records: DownloadRecord[] = []
for (const [, dl] of map) {
// Find the audioUrl from queue or use empty string
const qItem = queue().find((q) => q.episodeId === dl.episodeId)
records.push({
episodeId: dl.episodeId,
feedId: dl.feedId,
status: dl.status,
filePath: dl.filePath,
downloadedAt: dl.downloadedAt?.toISOString() ?? null,
fileSize: dl.fileSize,
error: dl.error,
audioUrl: qItem?.audioUrl ?? "",
episodeTitle: qItem?.episodeTitle ?? "",
})
}
const filePath = getConfigFilePath(DOWNLOADS_FILE)
await Bun.write(filePath, JSON.stringify(records, null, 2))
} catch {
// Silently ignore write errors
}
}
/** Resume incomplete downloads from a previous session */
function resumeIncomplete(): void {
const map = downloads()
for (const [, dl] of map) {
if (dl.status === DownloadStatus.QUEUED) {
// Re-queue — but we lack audioUrl from persistence alone.
// These will sit as QUEUED until the user re-triggers them.
}
}
}
/** Update a single download entry and trigger reactivity */
function updateDownload(episodeId: string, updates: Partial<DownloadedEpisode>): void {
setDownloads((prev) => {
const next = new Map(prev)
const existing = next.get(episodeId)
if (existing) {
next.set(episodeId, { ...existing, ...updates })
}
return next
})
}
/** Process the download queue — starts downloads up to MAX_CONCURRENT */
function processQueue(): void {
const current = activeCount()
const q = queue()
if (current >= MAX_CONCURRENT || q.length === 0) return
const slotsAvailable = MAX_CONCURRENT - current
const toStart = q.slice(0, slotsAvailable)
// Remove started items from queue
if (toStart.length > 0) {
setQueue((prev) => prev.slice(toStart.length))
}
for (const item of toStart) {
executeDownload(item)
}
}
/** Execute a single download */
async function executeDownload(item: QueueItem): Promise<void> {
const controller = new AbortController()
abortControllers.set(item.episodeId, controller)
setActiveCount((c) => c + 1)
updateDownload(item.episodeId, {
status: DownloadStatus.DOWNLOADING,
progress: 0,
speed: 0,
error: null,
})
const result = await downloadEpisode(
item.audioUrl,
item.episodeTitle,
item.feedId,
(progress) => {
updateDownload(item.episodeId, {
progress: progress.percent >= 0 ? progress.percent : 0,
speed: progress.speed,
fileSize: progress.totalBytes,
})
},
controller.signal,
)
abortControllers.delete(item.episodeId)
setActiveCount((c) => Math.max(0, c - 1))
if (result.success) {
updateDownload(item.episodeId, {
status: DownloadStatus.COMPLETED,
progress: 100,
filePath: result.filePath,
fileSize: result.fileSize,
downloadedAt: new Date(),
speed: 0,
error: null,
})
} else {
updateDownload(item.episodeId, {
status: DownloadStatus.FAILED,
speed: 0,
error: result.error ?? "Unknown error",
})
}
saveDownloads().catch(() => {})
// Process next items in queue
processQueue()
}
/** Get download status for an episode */
const getDownloadStatus = (episodeId: string): DownloadStatus => {
return downloads().get(episodeId)?.status ?? DownloadStatus.NONE
}
/** Get download progress for an episode (0-100) */
const getDownloadProgress = (episodeId: string): number => {
return downloads().get(episodeId)?.progress ?? 0
}
/** Get full download info for an episode */
const getDownload = (episodeId: string): DownloadedEpisode | undefined => {
return downloads().get(episodeId)
}
/** Get the local file path for a completed download */
const getDownloadedFilePath = (episodeId: string): string | null => {
const dl = downloads().get(episodeId)
if (dl?.status === DownloadStatus.COMPLETED && dl.filePath) {
return dl.filePath
}
return null
}
/** Start downloading an episode */
const startDownload = (episode: Episode, feedId: string): void => {
const existing = downloads().get(episode.id)
if (existing?.status === DownloadStatus.DOWNLOADING || existing?.status === DownloadStatus.QUEUED) {
return // Already downloading or queued
}
// Create download entry
const entry: DownloadedEpisode = {
episodeId: episode.id,
feedId,
status: DownloadStatus.QUEUED,
progress: 0,
filePath: null,
downloadedAt: null,
speed: 0,
fileSize: episode.fileSize ?? 0,
error: null,
}
setDownloads((prev) => {
const next = new Map(prev)
next.set(episode.id, entry)
return next
})
// Add to queue
const queueItem: QueueItem = {
episodeId: episode.id,
feedId,
audioUrl: episode.audioUrl,
episodeTitle: episode.title,
}
setQueue((prev) => [...prev, queueItem])
saveDownloads().catch(() => {})
processQueue()
}
/** Cancel a download */
const cancelDownload = (episodeId: string): void => {
// Abort active download
const controller = abortControllers.get(episodeId)
if (controller) {
controller.abort()
abortControllers.delete(episodeId)
}
// Remove from queue
setQueue((prev) => prev.filter((q) => q.episodeId !== episodeId))
// Update status
updateDownload(episodeId, {
status: DownloadStatus.NONE,
progress: 0,
speed: 0,
error: null,
})
saveDownloads().catch(() => {})
}
/** Remove a completed download (delete file and metadata) */
const removeDownload = async (episodeId: string): Promise<void> => {
const dl = downloads().get(episodeId)
if (dl?.filePath) {
try {
const { unlink } = await import("fs/promises")
await unlink(dl.filePath)
} catch {
// File may already be gone
}
}
setDownloads((prev) => {
const next = new Map(prev)
next.delete(episodeId)
return next
})
saveDownloads().catch(() => {})
}
/** Get all downloads as an array */
const getAllDownloads = (): DownloadedEpisode[] => {
return Array.from(downloads().values())
}
/** Get the current queue */
const getQueue = (): QueueItem[] => {
return queue()
}
/** Get count of active downloads */
const getActiveCount = (): number => {
return activeCount()
}
return {
// Getters
getDownloadStatus,
getDownloadProgress,
getDownload,
getDownloadedFilePath,
getAllDownloads,
getQueue,
getActiveCount,
// Actions
startDownload,
cancelDownload,
removeDownload,
}
}
/** Singleton download store */
let downloadStoreInstance: ReturnType<typeof createDownloadStore> | null = null
export function useDownloadStore() {
if (!downloadStoreInstance) {
downloadStoreInstance = createDownloadStore()
}
return downloadStoreInstance
}

495
src/stores/feed.ts Normal file
View File

@@ -0,0 +1,495 @@
/**
* Feed store for PodTUI
* Manages feed data, sources, and filtering
*/
import { createSignal } from "solid-js";
import { FeedVisibility } from "../types/feed";
import type { Feed, FeedFilter, FeedSortField } from "../types/feed";
import type { Podcast } from "../types/podcast";
import type { Episode, EpisodeStatus } from "../types/episode";
import type { PodcastSource, SourceType } from "../types/source";
import { DEFAULT_SOURCES } from "../types/source";
import { parseRSSFeed } from "../api/rss-parser";
import {
loadFeedsFromFile,
saveFeedsToFile,
loadSourcesFromFile,
saveSourcesToFile,
} from "../utils/feeds-persistence";
import { useDownloadStore } from "./download";
import { DownloadStatus } from "../types/episode";
import { useAuthStore } from "./auth";
/** Max episodes to load per page/chunk */
const MAX_EPISODES_REFRESH = 50;
/** Max episodes to fetch on initial subscribe */
const MAX_EPISODES_SUBSCRIBE = 20;
/** Cache of all parsed episodes per feed (feedId -> Episode[]) */
const fullEpisodeCache = new Map<string, Episode[]>();
/** Track how many episodes are currently loaded per feed */
const episodeLoadCount = new Map<string, number>();
/** Save feeds to file (async, fire-and-forget) */
function saveFeeds(feeds: Feed[]): void {
saveFeedsToFile(feeds).catch(() => {});
}
/** Save sources to file (async, fire-and-forget) */
function saveSources(sources: PodcastSource[]): void {
saveSourcesToFile(sources).catch(() => {});
}
/** Create feed store */
export function createFeedStore() {
const [feeds, setFeeds] = createSignal<Feed[]>([]);
const [sources, setSources] = createSignal<PodcastSource[]>([
...DEFAULT_SOURCES,
]);
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
});
const [selectedFeedId, setSelectedFeedId] = createSignal<string | null>(null);
const [isLoadingMore, setIsLoadingMore] = createSignal(false);
const [isLoadingFeeds, setIsLoadingFeeds] = createSignal(false);
/** Get filtered and sorted feeds */
const getFilteredFeeds = (): Feed[] => {
let result = [...feeds()];
const f = filter();
const authStore = useAuthStore();
// Filter by visibility
if (f.visibility && f.visibility !== "all") {
result = result.filter((feed) => feed.visibility === f.visibility);
} else if (f.visibility === "all") {
// Only show private feeds if authenticated
result = result.filter((feed) => feed.visibility === FeedVisibility.PUBLIC || authStore.isAuthenticated);
}
// Filter by source
if (f.sourceId) {
result = result.filter((feed) => feed.sourceId === f.sourceId);
}
// Filter by pinned
if (f.pinnedOnly) {
result = result.filter((feed) => feed.isPinned);
}
// Filter by search query
if (f.searchQuery) {
const query = f.searchQuery.toLowerCase();
result = result.filter(
(feed) =>
feed.podcast.title.toLowerCase().includes(query) ||
feed.customName?.toLowerCase().includes(query) ||
feed.podcast.description?.toLowerCase().includes(query),
);
}
// Sort by selected field
const sortDir = f.sortDirection === "asc" ? 1 : -1;
result.sort((a, b) => {
switch (f.sortBy) {
case "title":
return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title,
)
);
case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length);
case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0;
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0;
return sortDir * (aLatest - bLatest);
case "updated":
default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime());
}
});
// Pinned feeds always first
result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return 0;
});
return result;
};
/** Get episodes in reverse chronological order across all feeds */
const getAllEpisodesChronological = (): Array<{
episode: Episode;
feed: Feed;
}> => {
const allEpisodes: Array<{ episode: Episode; feed: Feed }> = [];
for (const feed of feeds()) {
for (const episode of feed.episodes) {
allEpisodes.push({ episode, feed });
}
}
// Sort by publication date (newest first)
allEpisodes.sort(
(a, b) => b.episode.pubDate.getTime() - a.episode.pubDate.getTime(),
);
return allEpisodes;
};
/** Sort episodes in reverse chronological order (newest first) */
const sortEpisodesReverseChronological = (episodes: Episode[]): Episode[] => {
return [...episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
};
/** Fetch latest episodes from an RSS feed URL, caching all parsed episodes */
const fetchEpisodes = async (
feedUrl: string,
limit: number,
feedId?: string,
): Promise<Episode[]> => {
try {
const response = await fetch(feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return [];
const xml = await response.text();
const parsed = parseRSSFeed(xml, feedUrl);
const allEpisodes = sortEpisodesReverseChronological(parsed.episodes);
// Cache all parsed episodes for pagination
if (feedId) {
fullEpisodeCache.set(feedId, allEpisodes);
episodeLoadCount.set(feedId, Math.min(limit, allEpisodes.length));
}
return allEpisodes.slice(0, limit);
} catch {
return [];
}
};
/** Add a new feed and auto-fetch latest 20 episodes */
const addFeed = async (
podcast: Podcast,
sourceId: string,
visibility: FeedVisibility = FeedVisibility.PUBLIC,
) => {
const feedId = crypto.randomUUID();
const episodes = await fetchEpisodes(
podcast.feedUrl,
MAX_EPISODES_SUBSCRIBE,
feedId,
);
const newFeed: Feed = {
id: feedId,
podcast,
episodes,
visibility,
sourceId,
lastUpdated: new Date(),
isPinned: false,
};
setFeeds((prev) => {
const updated = [...prev, newFeed];
saveFeeds(updated);
return updated;
});
return newFeed;
};
/** Auto-download newest episodes for a feed */
const autoDownloadEpisodes = (
feedId: string,
newEpisodes: Episode[],
count: number,
) => {
try {
const dlStore = useDownloadStore();
// Sort by pubDate descending (newest first)
const sorted = [...newEpisodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
// count = 0 means download all new episodes
const toDownload = count > 0 ? sorted.slice(0, count) : sorted;
for (const ep of toDownload) {
const status = dlStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
dlStore.startDownload(ep, feedId);
}
}
} catch {
// Download store may not be available yet
}
};
/** Refresh a single feed - re-fetch latest 50 episodes */
const refreshFeed = async (feedId: string) => {
const feed = getFeed(feedId);
if (!feed) return;
const oldEpisodeIds = new Set(feed.episodes.map((e) => e.id));
const episodes = await fetchEpisodes(
feed.podcast.feedUrl,
MAX_EPISODES_REFRESH,
feedId,
);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
// Auto-download new episodes if enabled for this feed
if (feed.autoDownload) {
const newEpisodes = episodes.filter((e) => !oldEpisodeIds.has(e.id));
if (newEpisodes.length > 0) {
autoDownloadEpisodes(feedId, newEpisodes, feed.autoDownloadCount ?? 0);
}
}
};
/** Refresh all feeds */
const refreshAllFeeds = async () => {
setIsLoadingFeeds(true);
try {
const currentFeeds = feeds();
for (const feed of currentFeeds) {
await refreshFeed(feed.id);
}
} finally {
setIsLoadingFeeds(false);
}
};
(async () => {
const loadedFeeds = await loadFeedsFromFile();
if (loadedFeeds.length > 0) setFeeds(loadedFeeds);
const loadedSources = await loadSourcesFromFile<PodcastSource>();
if (loadedSources && loadedSources.length > 0) setSources(loadedSources);
await refreshAllFeeds();
})();
/** Remove a feed */
const removeFeed = (feedId: string) => {
fullEpisodeCache.delete(feedId);
episodeLoadCount.delete(feedId);
setFeeds((prev) => {
const updated = prev.filter((f) => f.id !== feedId);
saveFeeds(updated);
return updated;
});
};
/** Update a feed */
const updateFeed = (feedId: string, updates: Partial<Feed>) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, ...updates, lastUpdated: new Date() } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Toggle feed pinned status */
const togglePinned = (feedId: string) => {
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, isPinned: !f.isPinned } : f,
);
saveFeeds(updated);
return updated;
});
};
/** Add a source */
const addSource = (source: Omit<PodcastSource, "id">) => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSources(updated);
return updated;
});
return newSource;
};
/** Update a source */
const updateSource = (sourceId: string, updates: Partial<PodcastSource>) => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSources(updated);
return updated;
});
};
/** Remove a source */
const removeSource = (sourceId: string) => {
// Don't remove default sources
if (sourceId === "itunes" || sourceId === "rss") return false;
setSources((prev) => {
const updated = prev.filter((s) => s.id !== sourceId);
saveSources(updated);
return updated;
});
return true;
};
/** Toggle source enabled status */
const toggleSource = (sourceId: string) => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSources(updated);
return updated;
});
};
/** Get feed by ID */
const getFeed = (feedId: string): Feed | undefined => {
return feeds().find((f) => f.id === feedId);
};
/** Get selected feed */
const getSelectedFeed = (): Feed | undefined => {
const id = selectedFeedId();
return id ? getFeed(id) : undefined;
};
/** Check if a feed has more episodes available beyond what's currently loaded */
const hasMoreEpisodes = (feedId: string): boolean => {
const cached = fullEpisodeCache.get(feedId);
if (!cached) return false;
const loaded = episodeLoadCount.get(feedId) ?? 0;
return loaded < cached.length;
};
/** Load the next chunk of episodes for a feed from the cache.
* If no cache exists (e.g. app restart), re-fetches from the RSS feed. */
const loadMoreEpisodes = async (feedId: string) => {
if (isLoadingMore()) return;
const feed = getFeed(feedId);
if (!feed) return;
setIsLoadingMore(true);
try {
let cached = fullEpisodeCache.get(feedId);
// If no cache, re-fetch and parse the full feed
if (!cached) {
const response = await fetch(feed.podcast.feedUrl, {
headers: {
"Accept-Encoding": "identity",
Accept: "application/rss+xml, application/xml, text/xml, */*",
},
});
if (!response.ok) return;
const xml = await response.text();
const parsed = parseRSSFeed(xml, feed.podcast.feedUrl);
cached = parsed.episodes;
fullEpisodeCache.set(feedId, cached);
// Set current load count to match what's already displayed
episodeLoadCount.set(feedId, feed.episodes.length);
}
const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length;
const newCount = Math.min(
currentCount + MAX_EPISODES_REFRESH,
cached.length,
);
if (newCount <= currentCount) return; // nothing more to load
episodeLoadCount.set(feedId, newCount);
const episodes = cached.slice(0, newCount);
setFeeds((prev) => {
const updated = prev.map((f) =>
f.id === feedId ? { ...f, episodes } : f,
);
saveFeeds(updated);
return updated;
});
} finally {
setIsLoadingMore(false);
}
};
/** Set auto-download settings for a feed */
const setAutoDownload = (
feedId: string,
enabled: boolean,
count: number = 0,
) => {
updateFeed(feedId, { autoDownload: enabled, autoDownloadCount: count });
};
return {
// State
feeds,
sources,
filter,
selectedFeedId,
isLoadingMore,
// Computed
getFilteredFeeds,
getAllEpisodesChronological,
getFeed,
getSelectedFeed,
hasMoreEpisodes,
isLoadingFeeds,
// Actions
setFilter,
setSelectedFeedId,
addFeed,
removeFeed,
updateFeed,
togglePinned,
refreshFeed,
refreshAllFeeds,
loadMoreEpisodes,
addSource,
removeSource,
toggleSource,
updateSource,
setAutoDownload,
};
}
/** Singleton feed store */
let feedStoreInstance: ReturnType<typeof createFeedStore> | null = null;
export function useFeedStore() {
if (!feedStoreInstance) {
feedStoreInstance = createFeedStore();
}
return feedStoreInstance;
}

166
src/stores/progress.ts Normal file
View File

@@ -0,0 +1,166 @@
/**
* Episode progress store for PodTUI
*
* Persists per-episode playback progress to a JSON file in XDG_CONFIG_HOME.
* Tracks position, duration, completion, and last-played timestamp.
*/
import { createSignal } from "solid-js";
import type { Progress } from "../types/episode";
import {
loadProgressFromFile,
saveProgressToFile,
} from "../utils/app-persistence";
/** Threshold (fraction 0-1) at which an episode is considered completed */
const COMPLETION_THRESHOLD = 0.95;
/** Minimum seconds of progress before persisting */
const MIN_POSITION_TO_SAVE = 5;
// --- Singleton store ---
const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
{},
);
/** Persist current progress map to file (fire-and-forget) */
function persist(): void {
saveProgressToFile(progressMap()).catch(() => {});
}
/** Parse raw progress entries from file, reviving Date objects */
function parseProgressEntries(
raw: Record<string, unknown>,
): Record<string, Progress> {
const result: Record<string, Progress> = {};
for (const [key, value] of Object.entries(raw)) {
const p = value as Record<string, unknown>;
result[key] = {
episodeId: p.episodeId as string,
position: p.position as number,
duration: p.duration as number,
timestamp: new Date(p.timestamp as string),
playbackSpeed: p.playbackSpeed as number | undefined,
};
}
return result;
}
async function initProgress(): Promise<void> {
const raw = await loadProgressFromFile();
const parsed = parseProgressEntries(raw as Record<string, unknown>);
setProgressMap(parsed);
}
// Fire-and-forget init
initProgress();
function createProgressStore() {
return {
/**
* Get progress for a specific episode.
*/
get(episodeId: string): Progress | undefined {
return progressMap()[episodeId];
},
/**
* Get all progress entries.
*/
all(): Record<string, Progress> {
return progressMap();
},
/**
* Update progress for an episode. Only persists if position is meaningful.
*/
update(
episodeId: string,
position: number,
duration: number,
playbackSpeed?: number,
): void {
if (position < MIN_POSITION_TO_SAVE && duration > 0) return;
setProgressMap((prev) => ({
...prev,
[episodeId]: {
episodeId,
position,
duration,
timestamp: new Date(),
playbackSpeed,
},
}));
persist();
},
/**
* Check if an episode is completed.
*/
isCompleted(episodeId: string): boolean {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return false;
return p.position / p.duration >= COMPLETION_THRESHOLD;
},
/**
* Get progress percentage (0-100) for an episode.
*/
getPercent(episodeId: string): number {
const p = progressMap()[episodeId];
if (!p || p.duration <= 0) return 0;
return Math.min(100, Math.round((p.position / p.duration) * 100));
},
/**
* Mark an episode as completed (set position to duration).
*/
markCompleted(episodeId: string): void {
const p = progressMap()[episodeId];
const duration = p?.duration ?? 0;
setProgressMap((prev) => ({
...prev,
[episodeId]: {
episodeId,
position: duration,
duration,
timestamp: new Date(),
playbackSpeed: p?.playbackSpeed,
},
}));
persist();
},
/**
* Remove progress for an episode (e.g. "mark as new").
*/
remove(episodeId: string): void {
setProgressMap((prev) => {
const next = { ...prev };
delete next[episodeId];
return next;
});
persist();
},
/**
* Clear all progress data.
*/
clear(): void {
setProgressMap({});
persist();
},
};
}
// Singleton instance
let instance: ReturnType<typeof createProgressStore> | null = null;
export function useProgressStore() {
if (!instance) {
instance = createProgressStore();
}
return instance;
}

187
src/stores/search.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* Search store for PodTUI
* Manages search state, history, and results
*/
import { createSignal } from "solid-js"
import { searchPodcasts } from "../utils/search"
import { useFeedStore } from "./feed"
import type { SearchResult } from "../types/source"
const STORAGE_KEY = "podtui_search_history"
const MAX_HISTORY = 20
export interface SearchState {
query: string
isSearching: boolean
results: SearchResult[]
error: string | null
}
const CACHE_TTL = 1000 * 60 * 5
/** Load search history from localStorage */
function loadHistory(): string[] {
if (typeof localStorage === "undefined") return []
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
/** 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
}
}
/** Create search store */
export function createSearchStore() {
const feedStore = useFeedStore()
const [query, setQuery] = createSignal("")
const [isSearching, setIsSearching] = createSignal(false)
const [results, setResults] = createSignal<SearchResult[]>([])
const [error, setError] = createSignal<string | null>(null)
const [history, setHistory] = createSignal<string[]>(loadHistory())
const [selectedSources, setSelectedSources] = createSignal<string[]>([])
const applySubscribedStatus = (items: SearchResult[]): SearchResult[] => {
const feeds = feedStore.feeds()
const subscribedUrls = new Set(feeds.map((feed) => feed.podcast.feedUrl))
const subscribedIds = new Set(feeds.map((feed) => feed.podcast.id))
return items.map((item) => ({
...item,
podcast: {
...item.podcast,
isSubscribed:
item.podcast.isSubscribed ||
subscribedUrls.has(item.podcast.feedUrl) ||
subscribedIds.has(item.podcast.id),
},
}))
}
/** Perform search (multi-source implementation) */
const search = async (searchQuery: string): Promise<void> => {
const q = searchQuery.trim()
if (!q) {
setResults([])
return
}
setQuery(q)
setIsSearching(true)
setError(null)
// Add to history
addToHistory(q)
try {
const sources = feedStore.sources()
const enabledSourceIds = sources.filter((s) => s.enabled).map((s) => s.id)
const sourceIds = selectedSources().length > 0
? selectedSources()
: enabledSourceIds
const searchResults = await searchPodcasts(q, sourceIds, sources, {
cacheTtl: CACHE_TTL,
})
setResults(applySubscribedStatus(searchResults))
} catch (e) {
setError("Search failed. Please try again.")
setResults([])
} finally {
setIsSearching(false)
}
}
/** Add query to history */
const addToHistory = (q: string) => {
setHistory((prev) => {
// Remove duplicates and add to front
const filtered = prev.filter((h) => h.toLowerCase() !== q.toLowerCase())
const updated = [q, ...filtered].slice(0, MAX_HISTORY)
saveHistory(updated)
return updated
})
}
/** Clear search history */
const clearHistory = () => {
setHistory([])
saveHistory([])
}
/** Remove single history item */
const removeFromHistory = (q: string) => {
setHistory((prev) => {
const updated = prev.filter((h) => h !== q)
saveHistory(updated)
return updated
})
}
/** Clear results */
const clearResults = () => {
setResults([])
setQuery("")
setError(null)
}
/** Mark a podcast as subscribed in results */
const markSubscribed = (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: true,
},
}
}
return result
})
)
}
return {
// State
query,
isSearching,
results,
error,
history,
selectedSources,
// Actions
search,
setQuery,
clearResults,
clearHistory,
removeFromHistory,
setSelectedSources,
markSubscribed,
}
}
/** Singleton search store */
let searchStoreInstance: ReturnType<typeof createSearchStore> | null = null
export function useSearchStore() {
if (!searchStoreInstance) {
searchStoreInstance = createSearchStore()
}
return searchStoreInstance
}

138
src/styles/theme.css Normal file
View File

@@ -0,0 +1,138 @@
/* Theme CSS Variables */
:root {
/* Base Colors */
--color-background: transparent;
--color-surface: #1b1f27;
--color-primary: #6fa8ff;
--color-secondary: #a9b1d6;
--color-accent: #f6c177;
--color-text: #e6edf3;
--color-muted: #7d8590;
--color-warning: #f0b429;
--color-error: #f47067;
--color-success: #3fb950;
/* Layer Backgrounds */
--color-layer0: transparent;
--color-layer1: #1e222e;
--color-layer2: #161b22;
--color-layer3: #0d1117;
}
/* Dark Theme (Catppuccin default) */
[data-theme="dark"] {
--color-background: transparent;
--color-surface: #1e1e2e;
--color-primary: #89b4fa;
--color-secondary: #cba6f7;
--color-accent: #f9e2af;
--color-text: #cdd6f4;
--color-muted: #7f849c;
--color-warning: #fab387;
--color-error: #f38ba8;
--color-success: #a6e3a1;
--color-layer0: transparent;
--color-layer1: #181825;
--color-layer2: #11111b;
--color-layer3: #0a0a0f;
}
/* Light Theme (Gruvbox) */
[data-theme="light"] {
--color-background: transparent;
--color-surface: #282828;
--color-primary: #fabd2f;
--color-secondary: #83a598;
--color-accent: #fe8019;
--color-text: #ebdbb2;
--color-muted: #928374;
--color-warning: #fabd2f;
--color-error: #fb4934;
--color-success: #b8bb26;
--color-layer0: transparent;
--color-layer1: #32302a;
--color-layer2: #1d2021;
--color-layer3: #0d0c0c;
}
/* Tokyo Theme */
[data-theme="tokyo"] {
--color-background: transparent;
--color-surface: #1a1b26;
--color-primary: #7aa2f7;
--color-secondary: #bb9af7;
--color-accent: #e0af68;
--color-text: #c0caf5;
--color-muted: #565f89;
--color-warning: #e0af68;
--color-error: #f7768e;
--color-success: #9ece6a;
--color-layer0: transparent;
--color-layer1: #16161e;
--color-layer2: #0f0f15;
--color-layer3: #08080b;
}
/* Nord Theme */
[data-theme="nord"] {
--color-background: transparent;
--color-surface: #2e3440;
--color-primary: #88c0d0;
--color-secondary: #81a1c1;
--color-accent: #ebcb8b;
--color-text: #eceff4;
--color-muted: #4c566a;
--color-warning: #ebcb8b;
--color-error: #bf616a;
--color-success: #a3be8c;
--color-layer0: transparent;
--color-layer1: #3b4252;
--color-layer2: #242933;
--color-layer3: #1a1c23;
}
/* System Theme */
@media (prefers-color-scheme: dark) {
[data-theme="system"] {
--color-background: transparent;
--color-surface: #1e1e2e;
--color-primary: #89b4fa;
--color-secondary: #cba6f7;
--color-accent: #f9e2af;
--color-text: #cdd6f4;
--color-muted: #7f849c;
--color-warning: #fab387;
--color-error: #f38ba8;
--color-success: #a6e3a1;
--color-layer0: transparent;
--color-layer1: #181825;
--color-layer2: #11111b;
--color-layer3: #0a0a0f;
}
}
@media (prefers-color-scheme: light) {
[data-theme="system"] {
--color-background: transparent;
--color-surface: #282828;
--color-primary: #fabd2f;
--color-secondary: #83a598;
--color-accent: #fe8019;
--color-text: #ebdbb2;
--color-muted: #928374;
--color-warning: #fabd2f;
--color-error: #fb4934;
--color-success: #b8bb26;
--color-layer0: transparent;
--color-layer1: #32302a;
--color-layer2: #1d2021;
--color-layer3: #0d0c0c;
}
}

View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#181825",
"surface": "#1e1e2e",
"primary": "#89b4fa",
"secondary": "#cba6f7",
"accent": "#f9e2af",
"text": "#cdd6f4",
"muted": "#7f849c",
"warning": "#fab387",
"error": "#f38ba8",
"success": "#a6e3a1",
"layer0": "#181825",
"layer1": "#181825",
"layer2": "#11111b",
"layer3": "#0a0a0f"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

73
src/themes/gruvbox.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#282828",
"surface": "#282828",
"primary": "#fabd2f",
"secondary": "#83a598",
"accent": "#fe8019",
"text": "#ebdbb2",
"muted": "#928374",
"warning": "#fabd2f",
"error": "#fb4934",
"success": "#b8bb26",
"layer0": "#282828",
"layer1": "#32302a",
"layer2": "#1d2021",
"layer3": "#0d0c0c"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

73
src/themes/nord.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#2e3440",
"surface": "#2e3440",
"primary": "#88c0d0",
"secondary": "#81a1c1",
"accent": "#ebcb8b",
"text": "#eceff4",
"muted": "#4c566a",
"warning": "#ebcb8b",
"error": "#bf616a",
"success": "#a3be8c",
"layer0": "#2e3440",
"layer1": "#3b4252",
"layer2": "#242933",
"layer3": "#1a1c23"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

58
src/themes/schema.json Normal file
View File

@@ -0,0 +1,58 @@
{
"defs": {},
"theme": {
"primary": "#000000",
"secondary": "#000000",
"accent": "#000000",
"error": "#000000",
"warning": "#000000",
"success": "#000000",
"info": "#000000",
"text": "#000000",
"textMuted": "#000000",
"selectedListItemText": "#000000",
"background": "#000000",
"backgroundPanel": "#000000",
"backgroundElement": "#000000",
"backgroundMenu": "#000000",
"border": "#000000",
"borderActive": "#000000",
"borderSubtle": "#000000",
"diffAdded": "#000000",
"diffRemoved": "#000000",
"diffContext": "#000000",
"diffHunkHeader": "#000000",
"diffHighlightAdded": "#000000",
"diffHighlightRemoved": "#000000",
"diffAddedBg": "#000000",
"diffRemovedBg": "#000000",
"diffContextBg": "#000000",
"diffLineNumber": "#000000",
"diffAddedLineNumberBg": "#000000",
"diffRemovedLineNumberBg": "#000000",
"markdownText": "#000000",
"markdownHeading": "#000000",
"markdownLink": "#000000",
"markdownLinkText": "#000000",
"markdownCode": "#000000",
"markdownBlockQuote": "#000000",
"markdownEmph": "#000000",
"markdownStrong": "#000000",
"markdownHorizontalRule": "#000000",
"markdownListItem": "#000000",
"markdownListEnumeration": "#000000",
"markdownImage": "#000000",
"markdownImageText": "#000000",
"markdownCodeBlock": "#000000",
"syntaxComment": "#000000",
"syntaxKeyword": "#000000",
"syntaxFunction": "#000000",
"syntaxVariable": "#000000",
"syntaxString": "#000000",
"syntaxNumber": "#000000",
"syntaxType": "#000000",
"syntaxOperator": "#000000",
"syntaxPunctuation": "#000000",
"thinkingOpacity": 0.6
}
}

73
src/themes/tokyo.json Normal file
View File

@@ -0,0 +1,73 @@
{
"defs": {
"background": "#0f0f15",
"surface": "#1a1b26",
"primary": "#7aa2f7",
"secondary": "#bb9af7",
"accent": "#e0af68",
"text": "#c0caf5",
"muted": "#565f89",
"warning": "#e0af68",
"error": "#f7768e",
"success": "#9ece6a",
"layer0": "#0f0f15",
"layer1": "#16161e",
"layer2": "#0f0f15",
"layer3": "#08080b"
},
"theme": {
"primary": "primary",
"secondary": "secondary",
"accent": "accent",
"error": "error",
"warning": "warning",
"success": "success",
"info": "secondary",
"text": "text",
"textMuted": "muted",
"selectedListItemText": "background",
"background": "background",
"backgroundPanel": "surface",
"backgroundElement": "layer1",
"backgroundMenu": "layer1",
"border": "muted",
"borderActive": "primary",
"borderSubtle": "muted",
"diffAdded": "success",
"diffRemoved": "error",
"diffContext": "muted",
"diffHunkHeader": "muted",
"diffHighlightAdded": "success",
"diffHighlightRemoved": "error",
"diffAddedBg": "layer2",
"diffRemovedBg": "layer3",
"diffContextBg": "layer1",
"diffLineNumber": "muted",
"diffAddedLineNumberBg": "layer2",
"diffRemovedLineNumberBg": "layer3",
"markdownText": "text",
"markdownHeading": "accent",
"markdownLink": "primary",
"markdownLinkText": "secondary",
"markdownCode": "success",
"markdownBlockQuote": "warning",
"markdownEmph": "warning",
"markdownStrong": "accent",
"markdownHorizontalRule": "muted",
"markdownListItem": "primary",
"markdownListEnumeration": "secondary",
"markdownImage": "primary",
"markdownImageText": "secondary",
"markdownCodeBlock": "text",
"syntaxComment": "muted",
"syntaxKeyword": "accent",
"syntaxFunction": "primary",
"syntaxVariable": "secondary",
"syntaxString": "success",
"syntaxNumber": "warning",
"syntaxType": "accent",
"syntaxOperator": "secondary",
"syntaxPunctuation": "text",
"thinkingOpacity": 0.6
}
}

65
src/types/auth.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Authentication types for PodTUI
* Authentication is optional and disabled by default
*/
/** User profile information */
export interface User {
id: string
email: string
name: string
createdAt: Date
lastLoginAt?: Date
syncEnabled: boolean
}
/** Authentication state */
export interface AuthState {
user: User | null
isAuthenticated: boolean
isLoading: boolean
error: AuthError | null
}
/** Authentication error */
export interface AuthError {
code: AuthErrorCode
message: string
}
/** Error codes for authentication */
export enum AuthErrorCode {
INVALID_CREDENTIALS = "INVALID_CREDENTIALS",
INVALID_CODE = "INVALID_CODE",
CODE_EXPIRED = "CODE_EXPIRED",
NETWORK_ERROR = "NETWORK_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
/** Login credentials */
export interface LoginCredentials {
email: string
password: string
}
/** Code validation request */
export interface CodeValidationRequest {
code: string
}
/** OAuth provider types */
export enum OAuthProvider {
GOOGLE = "google",
APPLE = "apple",
}
/** OAuth provider configuration */
export interface OAuthProviderConfig {
id: OAuthProvider
name: string
enabled: boolean
description: string
}
/** Auth screen types for navigation */
export type AuthScreen = "login" | "code" | "oauth" | "profile"

192
src/types/desktop-theme.ts Normal file
View File

@@ -0,0 +1,192 @@
import type {
DesktopTheme,
ThemeColors,
ThemeDefinition,
ThemeName,
ThemeToken,
ThemeVariant,
} from "../types/settings"
import type { ColorValue } from "./theme-schema"
// Base theme colors
export const BASE_THEME_COLORS: ThemeColors = {
background: "transparent",
surface: "#1b1f27",
primary: "#6fa8ff",
secondary: "#a9b1d6",
accent: "#f6c177",
text: "#e6edf3",
textPrimary: "#e6edf3",
textSecondary: "#a9b1d6",
textTertiary: "#7d8590",
textSelectedPrimary: "#1b1f27",
textSelectedSecondary: "#e6edf3",
textSelectedTertiary: "#a9b1d6",
muted: "#7d8590",
warning: "#f0b429",
error: "#f47067",
success: "#3fb950",
_hasSelectedListItemText: true,
thinkingOpacity: 0.5,
}
// Base layer backgrounds
export const BASE_LAYER_BACKGROUND: ThemeColors["layerBackgrounds"] = {
layer0: "transparent",
layer1: "#1e222e",
layer2: "#161b22",
layer3: "#0d1117",
}
// Theme tokens
export const BASE_THEME_TOKENS: ThemeToken = {
"background": "transparent",
"surface": "#1b1f27",
"primary": "#6fa8ff",
"secondary": "#a9b1d6",
"accent": "#f6c177",
"text": "#e6edf3",
"muted": "#7d8590",
"warning": "#f0b429",
"error": "#f47067",
"success": "#3fb950",
"layer0": "transparent",
"layer1": "#1e222e",
"layer2": "#161b22",
"layer3": "#0d1117",
}
// Desktop theme structure
export const THEMES_DESKTOP: DesktopTheme = {
name: "PodTUI",
variants: [
{
name: "catppuccin",
colors: {
background: "transparent",
surface: "#1e1e2e",
primary: "#89b4fa",
secondary: "#cba6f7",
accent: "#f9e2af",
text: "#cdd6f4",
textPrimary: "#cdd6f4",
textSecondary: "#cba6f7",
textTertiary: "#7f849c",
textSelectedPrimary: "#1e1e2e",
textSelectedSecondary: "#cdd6f4",
textSelectedTertiary: "#cba6f7",
muted: "#7f849c",
warning: "#fab387",
error: "#f38ba8",
success: "#a6e3a1",
layerBackgrounds: {
layer0: "transparent",
layer1: "#181825",
layer2: "#11111b",
layer3: "#0a0a0f",
},
},
},
{
name: "gruvbox",
colors: {
background: "transparent",
surface: "#282828",
primary: "#fabd2f",
secondary: "#83a598",
accent: "#fe8019",
text: "#ebdbb2",
textPrimary: "#ebdbb2",
textSecondary: "#83a598",
textTertiary: "#928374",
textSelectedPrimary: "#282828",
textSelectedSecondary: "#ebdbb2",
textSelectedTertiary: "#83a598",
muted: "#928374",
warning: "#fabd2f",
error: "#fb4934",
success: "#b8bb26",
layerBackgrounds: {
layer0: "transparent",
layer1: "#32302a",
layer2: "#1d2021",
layer3: "#0d0c0c",
},
},
},
{
name: "tokyo",
colors: {
background: "transparent",
surface: "#1a1b26",
primary: "#7aa2f7",
secondary: "#bb9af7",
accent: "#e0af68",
text: "#c0caf5",
textPrimary: "#c0caf5",
textSecondary: "#bb9af7",
textTertiary: "#565f89",
textSelectedPrimary: "#1a1b26",
textSelectedSecondary: "#c0caf5",
textSelectedTertiary: "#bb9af7",
muted: "#565f89",
warning: "#e0af68",
error: "#f7768e",
success: "#9ece6a",
layerBackgrounds: {
layer0: "transparent",
layer1: "#16161e",
layer2: "#0f0f15",
layer3: "#08080b",
},
},
},
{
name: "nord",
colors: {
background: "transparent",
surface: "#2e3440",
primary: "#88c0d0",
secondary: "#81a1c1",
accent: "#ebcb8b",
text: "#eceff4",
textPrimary: "#eceff4",
textSecondary: "#81a1c1",
textTertiary: "#4c566a",
textSelectedPrimary: "#2e3440",
textSelectedSecondary: "#eceff4",
textSelectedTertiary: "#81a1c1",
muted: "#4c566a",
warning: "#ebcb8b",
error: "#bf616a",
success: "#a3be8c",
layerBackgrounds: {
layer0: "transparent",
layer1: "#3b4252",
layer2: "#242933",
layer3: "#1a1c23",
},
},
},
],
defaultVariant: "catppuccin",
tokens: BASE_THEME_TOKENS,
}
// Helper function to get theme by name
export function getThemeByName(name: ThemeName): ThemeVariant | undefined {
return THEMES_DESKTOP.variants.find((variant) => variant.name === name)
}
// Helper function to get default theme
export function getDefaultTheme(): ThemeVariant {
return THEMES_DESKTOP.variants.find(
(variant) => variant.name === THEMES_DESKTOP.defaultVariant
)!
}
export type ThemeJsonFile = ThemeDefinition
export function isColorReference(value: ColorValue): value is string {
return typeof value === "string" && !value.startsWith("#")
}

117
src/types/episode.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Episode type definitions for PodTUI
*/
/** Episode playback status */
export enum EpisodeStatus {
NOT_STARTED = "not_started",
PLAYING = "playing",
PAUSED = "paused",
COMPLETED = "completed",
}
/** Core episode information */
export interface Episode {
/** Unique identifier */
id: string
/** Parent podcast ID */
podcastId: string
/** Episode title */
title: string
/** Episode description/show notes */
description: string
/** Audio file URL */
audioUrl: string
/** Duration in seconds */
duration: number
/** Publication date */
pubDate: Date
/** Episode number (if available) */
episodeNumber?: number
/** Season number (if available) */
seasonNumber?: number
/** Episode type (full, trailer, bonus) */
episodeType?: EpisodeType
/** Whether episode is explicit */
explicit?: boolean
/** Episode image URL (if different from podcast) */
imageUrl?: string
/** File size in bytes */
fileSize?: number
/** MIME type */
mimeType?: string
}
/** Episode type enumeration */
export enum EpisodeType {
FULL = "full",
TRAILER = "trailer",
BONUS = "bonus",
}
/** Episode playback progress */
export interface Progress {
/** Episode ID */
episodeId: string
/** Current position in seconds */
position: number
/** Total duration in seconds */
duration: number
/** Last played timestamp */
timestamp: Date
/** Playback speed (1.0 = normal) */
playbackSpeed?: number
}
/** Episode with playback state */
export interface EpisodeWithProgress extends Episode {
/** Current playback status */
status: EpisodeStatus
/** Playback progress */
progress?: Progress
}
/** Episode list item for display */
export interface EpisodeListItem {
/** Episode data */
episode: Episode
/** Podcast title (for display in feeds) */
podcastTitle: string
/** Podcast cover URL */
podcastCoverUrl?: string
/** Current status */
status: EpisodeStatus
/** Progress percentage (0-100) */
progressPercent: number
}
/** Download status for an episode */
export enum DownloadStatus {
NONE = "none",
QUEUED = "queued",
DOWNLOADING = "downloading",
COMPLETED = "completed",
FAILED = "failed",
}
/** Metadata for a downloaded episode */
export interface DownloadedEpisode {
/** Episode ID */
episodeId: string
/** Feed ID the episode belongs to */
feedId: string
/** Current download status */
status: DownloadStatus
/** Download progress 0-100 */
progress: number
/** Absolute path to the downloaded file */
filePath: string | null
/** When the download completed */
downloadedAt: Date | null
/** Download speed in bytes/sec (while downloading) */
speed: number
/** File size in bytes */
fileSize: number
/** Error message if failed */
error: string | null
}

122
src/types/feed.ts Normal file
View File

@@ -0,0 +1,122 @@
/**
* Feed type definitions for PodTUI
*/
import type { Podcast } from "./podcast"
import type { Episode, EpisodeStatus } from "./episode"
/** Feed visibility */
export enum FeedVisibility {
PUBLIC = "public",
PRIVATE = "private",
}
/** Feed information */
export interface Feed {
/** Unique identifier */
id: string
/** Associated podcast */
podcast: Podcast
/** Episodes in this feed */
episodes: Episode[]
/** Whether feed is public or private */
visibility: FeedVisibility
/** Source ID that provided this feed */
sourceId: string
/** Last updated timestamp */
lastUpdated: Date
/** Custom feed name (user-defined) */
customName?: string
/** User notes about this feed */
notes?: string
/** Whether feed is pinned/favorited */
isPinned: boolean
/** Feed color for UI */
color?: string
/** Whether auto-download is enabled for this feed */
autoDownload?: boolean
/** Number of newest episodes to auto-download (0 = all new) */
autoDownloadCount?: number
}
/** Feed item for display in lists */
export interface FeedItem {
/** Episode data */
episode: Episode
/** Parent podcast */
podcast: Podcast
/** Feed ID */
feedId: string
/** Episode status */
status: EpisodeStatus
/** Progress percentage (0-100) */
progressPercent: number
/** Whether this item is new (unplayed) */
isNew: boolean
}
/** Feed filter options */
export interface FeedFilter {
/** Filter by visibility */
visibility?: FeedVisibility | "all"
/** Filter by source ID */
sourceId?: string
/** Filter by pinned status */
pinnedOnly?: boolean
/** Search query for filtering */
searchQuery?: string
/** Sort field */
sortBy?: FeedSortField
/** Sort direction */
sortDirection?: "asc" | "desc"
/** Show private feeds */
showPrivate?: boolean
}
/** Feed sort fields */
export enum FeedSortField {
/** Sort by last updated */
UPDATED = "updated",
/** Sort by title */
TITLE = "title",
/** Sort by episode count */
EPISODE_COUNT = "episodeCount",
/** Sort by most recent episode */
LATEST_EPISODE = "latestEpisode",
}
/** Feed list display options */
export interface FeedListOptions {
/** Show episode count */
showEpisodeCount: boolean
/** Show last updated */
showLastUpdated: boolean
/** Show source indicator */
showSource: boolean
/** Compact mode */
compact: boolean
}
/** Default feed list options */
export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = {
showEpisodeCount: true,
showLastUpdated: true,
showSource: false,
compact: false,
}
/** Feed statistics */
export interface FeedStats {
/** Total feed count */
totalFeeds: number
/** Public feed count */
publicFeeds: number
/** Private feed count */
privateFeeds: number
/** Total episode count across all feeds */
totalEpisodes: number
/** Unplayed episode count */
unplayedEpisodes: number
/** In-progress episode count */
inProgressEpisodes: number
}

42
src/types/podcast.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* Podcast type definitions for PodTUI
*/
/** Core podcast information */
export interface Podcast {
/** Unique identifier */
id: string
/** Podcast title */
title: string
/** Podcast description/summary */
description: string
/** Cover image URL */
coverUrl?: string
/** RSS feed URL */
feedUrl: string
/** Author/creator name */
author?: string
/** Podcast categories */
categories?: string[]
/** Language code (e.g., 'en', 'es') */
language?: string
/** Website URL */
websiteUrl?: string
/** Last updated timestamp */
lastUpdated: Date
/** Whether the podcast is currently subscribed */
isSubscribed: boolean
/** Callback to toggle feed visibility */
onToggleVisibility?: (feedId: string) => void
}
/** Podcast with episodes included */
export interface PodcastWithEpisodes extends Podcast {
/** List of episodes */
episodes: Episode[]
/** Total episode count */
totalEpisodes: number
}
/** Episode import - needed for PodcastWithEpisodes */
import type { Episode } from "./episode"

98
src/types/settings.ts Normal file
View File

@@ -0,0 +1,98 @@
import type { RGBA } from "@opentui/core";
import type { ColorValue, ThemeJson, Variant } from "./theme-schema";
export type ThemeName =
| "system"
| "catppuccin"
| "gruvbox"
| "tokyo"
| "nord"
| "custom";
export type LayerBackgrounds = {
layer0: ColorValue;
layer1: ColorValue;
layer2: ColorValue;
layer3: ColorValue;
};
export type ThemeColors = {
background: ColorValue;
surface: ColorValue;
primary: ColorValue;
secondary: ColorValue;
accent: ColorValue;
text: ColorValue;
textPrimary?: ColorValue;
textSecondary?: ColorValue;
textTertiary?: ColorValue;
textSelectedPrimary?: ColorValue;
textSelectedSecondary?: ColorValue;
textSelectedTertiary?: ColorValue;
muted: ColorValue;
warning: ColorValue;
error: ColorValue;
success: ColorValue;
layerBackgrounds?: LayerBackgrounds;
_hasSelectedListItemText?: boolean;
thinkingOpacity?: number;
selectedListItemText?: ColorValue;
};
export type ThemeVariant = {
name: string;
colors: ThemeColors;
};
export type ThemeToken = {
[key: string]: string;
};
export type ResolvedTheme = Record<string, RGBA> & {
layerBackgrounds: Record<string, RGBA>;
_hasSelectedListItemText: boolean;
thinkingOpacity: number;
};
export type DesktopTheme = {
name: string;
variants: ThemeVariant[];
defaultVariant: string;
tokens: ThemeToken;
};
export type VisualizerSettings = {
/** Number of frequency bars (8128, default: 32) */
bars: number;
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
sensitivity: number;
/** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction: number;
/** Low frequency cutoff in Hz (default: 50) */
lowCutOff: number;
/** High frequency cutoff in Hz (default: 10000) */
highCutOff: number;
};
export type AppSettings = {
theme: ThemeName;
fontSize: number;
playbackSpeed: number;
downloadPath: string;
visualizer: VisualizerSettings;
};
export type UserPreferences = {
showExplicit: boolean;
autoDownload: boolean;
};
export type AppState = {
settings: AppSettings;
preferences: UserPreferences;
customTheme: ThemeColors;
};
export type ThemeMode = "dark" | "light";
export type ThemeVariantValue = Variant;
export type ThemeDefinition = ThemeJson;

116
src/types/source.ts Normal file
View File

@@ -0,0 +1,116 @@
/**
* Podcast source type definitions for PodTUI
*/
/** Source type enumeration */
export enum SourceType {
/** RSS feed URL */
RSS = "rss",
/** API-based source (iTunes, Spotify, etc.) */
API = "api",
/** Custom/user-defined source */
CUSTOM = "custom",
}
/** Podcast source configuration */
export interface PodcastSource {
/** Unique identifier */
id: string
/** Source display name */
name: string
/** Source type */
type: SourceType
/** Base URL for the source */
baseUrl: string
/** API key (if required) */
apiKey?: string
/** Whether source is enabled */
enabled: boolean
/** Source icon/logo URL */
iconUrl?: string
/** Source description */
description?: string
/** Default country for source searches */
country?: string
/** Default language for search results */
language?: string
/** Include explicit results */
allowExplicit?: boolean
/** Rate limit (requests per minute) */
rateLimit?: number
/** Last successful fetch */
lastFetch?: Date
}
/** Search query configuration */
export interface SearchQuery {
/** Search query text */
query: string
/** Source IDs to search (empty = all enabled sources) */
sourceIds: string[]
/** Optional filters */
filters?: SearchFilters
}
/** Search filters */
export interface SearchFilters {
/** Filter by language */
language?: string
/** Filter by category */
category?: string
/** Filter by explicit content */
explicit?: boolean
/** Sort by field */
sortBy?: SearchSortField
/** Sort direction */
sortDirection?: "asc" | "desc"
/** Results limit */
limit?: number
/** Results offset for pagination */
offset?: number
}
/** Search sort fields */
export enum SearchSortField {
RELEVANCE = "relevance",
DATE = "date",
TITLE = "title",
POPULARITY = "popularity",
}
/** Search result */
export interface SearchResult {
/** Source that returned this result */
sourceId: string
/** Source display name */
sourceName?: string
/** Source type */
sourceType?: SourceType
/** Podcast data */
podcast: import("./podcast").Podcast
/** Relevance score (0-1) */
score?: number
}
/** Default podcast sources */
export const DEFAULT_SOURCES: PodcastSource[] = [
{
id: "itunes",
name: "Apple Podcasts",
type: SourceType.API,
baseUrl: "https://itunes.apple.com/search",
enabled: true,
description: "Search the Apple Podcasts directory",
country: "US",
language: "en_us",
allowExplicit: true,
},
{
id: "rss",
name: "RSS Feed",
type: SourceType.RSS,
baseUrl: "",
enabled: true,
description: "Add podcasts via RSS feed URL",
},
]

24
src/types/sync-json.ts Normal file
View File

@@ -0,0 +1,24 @@
export type SyncData = {
version: string
lastSyncedAt: string
feeds: {
id: string
title: string
url: string
isPrivate: boolean
}[]
sources: {
id: string
name: string
url: string
}[]
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

28
src/types/sync-xml.ts Normal file
View File

@@ -0,0 +1,28 @@
export type SyncDataXML = {
version: string
lastSyncedAt: string
feeds: {
feed: {
id: string
title: string
url: string
isPrivate: boolean
}[]
}
sources: {
source: {
id: string
name: string
url: string
}[]
}
settings: {
theme: string
playbackSpeed: number
downloadPath: string
}
preferences: {
showExplicit: boolean
autoDownload: boolean
}
}

32
src/types/theme-schema.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { RGBA } from "@opentui/core"
export type HexColor = `#${string}`
export type RefName = string
export type Variant = {
dark: HexColor | RefName
light: HexColor | RefName
}
export type ColorValue = HexColor | RefName | Variant | RGBA | number
export type ThemeJson = {
$schema?: string
defs?: Record<string, HexColor | RefName>
theme: Record<string, ColorValue> & {
selectedListItemText?: ColorValue
backgroundMenu?: ColorValue
thinkingOpacity?: number
}
}
export type ThemeColors = Record<string, RGBA> & {
_hasSelectedListItemText: boolean
thinkingOpacity: number
textPrimary?: ColorValue
textSecondary?: ColorValue
textTertiary?: ColorValue
textSelectedPrimary?: ColorValue
textSelectedSecondary?: ColorValue
textSelectedTertiary?: ColorValue
}

333
src/ui/command.tsx Normal file
View File

@@ -0,0 +1,333 @@
import {
createContext,
createMemo,
createSignal,
onCleanup,
useContext,
type Accessor,
type ParentProps,
For,
Show,
} from "solid-js";
import { useKeyboard, useTerminalDimensions } from "@opentui/solid";
import { KeybindsResolved, useKeybinds } from "../context/KeybindContext";
import { useDialog } from "./dialog";
import { useTheme } from "../context/ThemeContext";
import { TextAttributes } from "@opentui/core";
import { emit } from "../utils/event-bus";
import { SelectableBox, SelectableText } from "@/components/Selectable";
/**
* Command option for the command palette.
*/
export type CommandOption = {
/** Display title */
title: string;
/** Unique identifier */
value: string;
/** Description shown below title */
description?: string;
/** Category for grouping */
category?: string;
/** Keybind reference */
keybind?: keyof KeybindsResolved;
/** Whether this command is suggested */
suggested?: boolean;
/** Slash command configuration */
slash?: {
name: string;
aliases?: string[];
};
/** Whether to hide from command list */
hidden?: boolean;
/** Whether command is enabled */
enabled?: boolean;
/** Footer text (usually keybind display) */
footer?: string;
/** Handler when command is selected */
onSelect?: (dialog: ReturnType<typeof useDialog>) => void;
};
type CommandContext = ReturnType<typeof init>;
const ctx = createContext<CommandContext>();
function init() {
const [registrations, setRegistrations] = createSignal<
Accessor<CommandOption[]>[]
>([]);
const [suspendCount, setSuspendCount] = createSignal(0);
const dialog = useDialog();
const keybind = useKeybinds();
const entries = createMemo(() => {
const all = registrations().flatMap((x) => x());
return all.map((x) => ({
...x,
footer: x.keybind ? keybind.print(x.keybind) : undefined,
}));
});
const isEnabled = (option: CommandOption) => option.enabled !== false;
const isVisible = (option: CommandOption) =>
isEnabled(option) && !option.hidden;
const visibleOptions = createMemo(() =>
entries().filter((option) => isVisible(option)),
);
const suggestedOptions = createMemo(() =>
visibleOptions()
.filter((option) => option.suggested)
.map((option) => ({
...option,
value: `suggested:${option.value}`,
category: "Suggested",
})),
);
const suspended = () => suspendCount() > 0;
// Handle keybind shortcuts
useKeyboard((evt) => {
if (suspended()) return;
if (dialog.isOpen) return;
for (const option of entries()) {
if (!isEnabled(option)) continue;
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault();
option.onSelect?.(dialog);
emit("command.execute", { command: option.value });
return;
}
}
});
const result = {
/**
* Trigger a command by its value.
*/
trigger(name: string) {
for (const option of entries()) {
if (option.value === name) {
if (!isEnabled(option)) return;
option.onSelect?.(dialog);
emit("command.execute", { command: name });
return;
}
}
},
/**
* Get all slash commands.
*/
slashes() {
return visibleOptions().flatMap((option) => {
const slash = option.slash;
if (!slash) return [];
return {
display: "/" + slash.name,
description: option.description ?? option.title,
aliases: slash.aliases?.map((alias) => "/" + alias),
onSelect: () => result.trigger(option.value),
};
});
},
/**
* Enable/disable keybinds temporarily.
*/
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1));
},
suspended,
/**
* Show the command palette dialog.
*/
show() {
dialog.replace(() => (
<CommandDialog
options={visibleOptions()}
suggestedOptions={suggestedOptions()}
/>
));
},
/**
* Register commands. Returns cleanup function.
*/
register(cb: () => CommandOption[]) {
const results = createMemo(cb);
setRegistrations((arr) => [results, ...arr]);
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results));
});
},
/**
* Get all visible options.
*/
get options() {
return visibleOptions();
},
};
return result;
}
export function useCommandDialog() {
const value = useContext(ctx);
if (!value) {
throw new Error("useCommandDialog must be used within a CommandProvider");
}
return value;
}
export function CommandProvider(props: ParentProps) {
const value = init();
const dialog = useDialog();
const keybind = useKeybinds();
// Open command palette on ctrl+p or command_list keybind
useKeyboard((evt) => {
if (value.suspended()) return;
if (dialog.isOpen) return;
if (evt.defaultPrevented) return;
if (keybind.match("command_list", evt)) {
evt.preventDefault();
value.show();
return;
}
});
return <ctx.Provider value={value}>{props.children}</ctx.Provider>;
}
/**
* Command palette dialog component.
*/
function CommandDialog(props: {
options: CommandOption[];
suggestedOptions: CommandOption[];
}) {
const { theme } = useTheme();
const dialog = useDialog();
const dimensions = useTerminalDimensions();
const [filter, setFilter] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredOptions = createMemo(() => {
const query = filter().toLowerCase();
if (!query) {
return [...props.suggestedOptions, ...props.options];
}
return props.options.filter(
(option) =>
option.title.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query) ||
option.category?.toLowerCase().includes(query),
);
});
// Reset selection when filter changes
createMemo(() => {
filter();
setSelectedIndex(0);
});
useKeyboard((evt) => {
if (evt.name === "escape") {
dialog.clear();
evt.preventDefault();
return;
}
if (evt.name === "return" || evt.name === "enter") {
const option = filteredOptions()[selectedIndex()];
if (option) {
option.onSelect?.(dialog);
dialog.clear();
}
evt.preventDefault();
return;
}
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
setSelectedIndex((i) => Math.max(0, i - 1));
evt.preventDefault();
return;
}
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
setSelectedIndex((i) => Math.min(filteredOptions().length - 1, i + 1));
evt.preventDefault();
return;
}
// Handle text input
if (evt.name && evt.name.length === 1 && !evt.ctrl && !evt.meta) {
setFilter((f) => f + evt.name);
return;
}
if (evt.name === "backspace") {
setFilter((f) => f.slice(0, -1));
return;
}
});
const maxHeight = Math.floor(dimensions().height * 0.6);
return (
<box flexDirection="column" padding={1} borderColor={theme.border}>
{/* Search input */}
<box marginBottom={1}>
<text fg={theme.textMuted}>{"> "}</text>
<text fg={theme.text}>{filter() || "Type to search commands..."}</text>
</box>
{/* Command list */}
<box flexDirection="column" maxHeight={maxHeight} borderColor={theme.border}>
<For each={filteredOptions().slice(0, 10)}>
{(option, index) => (
<SelectableBox
selected={() => index() === selectedIndex()}
flexDirection="column"
padding={1}
onMouseDown={() => {
setSelectedIndex(index());
const selectedOption = filteredOptions()[index()];
if (selectedOption) {
selectedOption.onSelect?.(dialog);
dialog.clear();
}
}}
>
<box flexDirection="column" flexGrow={1}>
<SelectableText
selected={() => index() === selectedIndex()}
primary
>
{option.title}
</SelectableText>
<Show when={option.footer}>
<SelectableText
selected={() => index() === selectedIndex()}
tertiary
>
{option.footer}
</SelectableText>
</Show>
<Show when={option.description}>
<SelectableText
selected={() => index() === selectedIndex()}
tertiary
>
{option.description}
</SelectableText>
</Show>
</box>
</SelectableBox>
)}
</For>
<Show when={filteredOptions().length === 0}>
<text fg={theme.textMuted} style={{ padding: 1 }}>
No commands found
</text>
</Show>
</box>
</box>
);
}

225
src/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,225 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import { RGBA, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { Clipboard } from "../utils/clipboard"
import { useToast } from "./toast"
import { emit } from "../utils/event-bus"
export type DialogSize = "medium" | "large"
/**
* Dialog component that renders a modal overlay with content.
*/
export function Dialog(
props: ParentProps<{
size?: DialogSize
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const renderer = useRenderer()
return (
<box
onMouseUp={async () => {
if (renderer.getSelection()) return
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={Math.floor(dimensions().height / 4)}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
if (renderer.getSelection()) return
e.stopPropagation()
}}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
borderColor={theme.border}
>
{props.children}
</box>
</box>
)
}
type DialogStackItem = {
element: JSX.Element
onClose?: () => void
}
function init() {
const [store, setStore] = createStore({
stack: [] as DialogStackItem[],
size: "medium" as DialogSize,
})
const renderer = useRenderer()
let focus: Renderable | null = null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable): boolean {
for (const child of item.getChildren()) {
if (child === focus) return true
if (find(child)) return true
}
return false
}
const found = find(renderer.root)
if (!found) return
focus.focus()
}, 1)
}
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
evt.stopPropagation()
refocus()
emit("dialog.close", {})
}
})
return {
/**
* Clear all dialogs from the stack.
*/
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
emit("dialog.close", {})
},
/**
* Replace all dialogs with a new one.
*/
replace(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
const element = typeof input === "function" ? input() : input
setStore("size", "medium")
setStore("stack", [{ element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Push a new dialog onto the stack.
*/
push(input: JSX.Element | (() => JSX.Element), onClose?: () => void) {
if (store.stack.length === 0) {
focus = renderer.currentFocusedRenderable
focus?.blur()
}
const element = typeof input === "function" ? input() : input
setStore("stack", [...store.stack, { element, onClose }])
emit("dialog.open", { dialogId: "dialog" })
},
/**
* Pop the top dialog from the stack.
*/
pop() {
if (store.stack.length === 0) return
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
if (store.stack.length === 0) {
refocus()
}
emit("dialog.close", {})
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: DialogSize) {
setStore("size", size)
},
get isOpen() {
return store.stack.length > 0
},
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
/**
* DialogProvider wraps the application and provides dialog functionality.
* Also handles clipboard copy on text selection within dialogs.
*/
export function DialogProvider(props: ParentProps) {
const value = init()
const renderer = useRenderer()
const toast = useToast()
return (
<ctx.Provider value={value}>
{props.children}
<box
position="absolute"
onMouseUp={async () => {
const text = renderer.getSelection()?.getSelectedText()
if (text && text.length > 0) {
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
}}
>
<Show when={value.stack.length > 0}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
/**
* Hook to access the dialog context.
*/
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}

153
src/ui/toast.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { createContext, useContext, type ParentProps, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useTheme } from "../context/ThemeContext"
import { useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { emit } from "../utils/event-bus"
export type ToastVariant = "info" | "success" | "warning" | "error"
export type ToastOptions = {
title?: string
message: string
variant: ToastVariant
duration?: number
}
const DEFAULT_DURATION = 5000
/**
* Toast component that displays at the top-right of the screen.
* NOTE: This component must be rendered INSIDE ThemeProvider since it uses useTheme().
* The ToastProvider itself can be placed outside ThemeProvider if needed.
*/
export function Toast() {
const toast = useToast()
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const getVariantColor = (variant: ToastVariant) => {
switch (variant) {
case "success":
return theme.success
case "warning":
return theme.warning
case "error":
return theme.error
case "info":
default:
return theme.info
}
}
return (
<Show when={toast.currentToast}>
{(current) => (
<box
position="absolute"
justifyContent="center"
alignItems="flex-start"
top={2}
right={2}
maxWidth={Math.min(60, dimensions().width - 6)}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
backgroundColor={theme.backgroundPanel}
borderColor={getVariantColor(current().variant)}
border={["left", "right"]}
>
<box flexDirection="column">
<Show when={current().title}>
<text attributes={TextAttributes.BOLD} style={{ marginBottom: 1 }} fg={theme.text}>
{current().title}
</text>
</Show>
<text fg={theme.text} wrapMode="word" width="100%">
{current().message}
</text>
</box>
</box>
)}
</Show>
)
}
function init() {
const [store, setStore] = createStore({
currentToast: null as ToastOptions | null,
})
let timeoutHandle: NodeJS.Timeout | null = null
const toast = {
show(options: ToastOptions) {
const duration = options.duration ?? DEFAULT_DURATION
setStore("currentToast", {
title: options.title,
message: options.message,
variant: options.variant,
})
// Emit event for other listeners
emit("toast.show", options)
if (timeoutHandle) clearTimeout(timeoutHandle)
timeoutHandle = setTimeout(() => {
setStore("currentToast", null)
}, duration)
},
error: (err: unknown) => {
if (err instanceof Error) {
return toast.show({
variant: "error",
message: err.message,
})
}
toast.show({
variant: "error",
message: "An unknown error has occurred",
})
},
info: (message: string, title?: string) => {
toast.show({ variant: "info", message, title })
},
success: (message: string, title?: string) => {
toast.show({ variant: "success", message, title })
},
warning: (message: string, title?: string) => {
toast.show({ variant: "warning", message, title })
},
clear: () => {
if (timeoutHandle) clearTimeout(timeoutHandle)
setStore("currentToast", null)
},
get currentToast(): ToastOptions | null {
return store.currentToast
},
}
return toast
}
export type ToastContext = ReturnType<typeof init>
const ctx = createContext<ToastContext>()
/**
* ToastProvider provides toast functionality.
* NOTE: The Toast UI component is NOT rendered here - you must render <Toast />
* separately inside your component tree, after ThemeProvider.
*/
export function ToastProvider(props: ParentProps) {
const value = init()
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
}
export function useToast() {
const value = useContext(ctx)
if (!value) {
throw new Error("useToast must be used within a ToastProvider")
}
return value
}

41
src/utils/ansi-to-rgba.ts Normal file
View File

@@ -0,0 +1,41 @@
import { RGBA } from "@opentui/core"
export function ansiToRgba(code: number) {
if (code < 16) {
const ansi = [
"#000000",
"#800000",
"#008000",
"#808000",
"#000080",
"#800080",
"#008080",
"#c0c0c0",
"#808080",
"#ff0000",
"#00ff00",
"#ffff00",
"#0000ff",
"#ff00ff",
"#00ffff",
"#ffffff",
]
return RGBA.fromHex(ansi[code] ?? "#000000")
}
if (code < 232) {
const index = code - 16
const b = index % 6
const g = Math.floor(index / 6) % 6
const r = Math.floor(index / 36)
const value = (x: number) => (x === 0 ? 0 : x * 40 + 55)
return RGBA.fromInts(value(r), value(g), value(b))
}
if (code < 256) {
const gray = (code - 232) * 10 + 8
return RGBA.fromInts(gray, gray, gray)
}
return RGBA.fromInts(0, 0, 0)
}

View File

@@ -0,0 +1,158 @@
/**
* App state persistence via JSON file in XDG_CONFIG_HOME
*
* Reads and writes app settings, preferences, and custom theme to a JSON file
*/
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
import { backupConfigFile } from "./config-backup";
import type {
AppState,
AppSettings,
UserPreferences,
VisualizerSettings,
} from "../types/settings";
import { DEFAULT_THEME } from "../constants/themes";
const APP_STATE_FILE = "app-state.json";
const PROGRESS_FILE = "progress.json";
const AUDIO_NAV_FILE = "audio-nav.json";
// --- Defaults ---
const defaultVisualizerSettings: VisualizerSettings = {
bars: 32,
sensitivity: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
};
const defaultSettings: AppSettings = {
theme: "system",
fontSize: 14,
playbackSpeed: 1,
downloadPath: "",
visualizer: defaultVisualizerSettings,
};
const defaultPreferences: UserPreferences = {
showExplicit: false,
autoDownload: false,
};
const defaultState: AppState = {
settings: defaultSettings,
preferences: defaultPreferences,
customTheme: DEFAULT_THEME,
};
// --- App State ---
/** Load app state from JSON file */
export async function loadAppStateFromFile(): Promise<AppState> {
try {
const filePath = getConfigFilePath(APP_STATE_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return defaultState;
const raw = await file.json();
if (!raw || typeof raw !== "object") return defaultState;
const parsed = raw as Partial<AppState>;
return {
settings: { ...defaultSettings, ...parsed.settings },
preferences: { ...defaultPreferences, ...parsed.preferences },
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
};
} catch {
return defaultState;
}
}
/** Save app state to JSON file */
export async function saveAppStateToFile(state: AppState): Promise<void> {
try {
await ensureConfigDir();
await backupConfigFile(APP_STATE_FILE);
const filePath = getConfigFilePath(APP_STATE_FILE);
await Bun.write(filePath, JSON.stringify(state, null, 2));
} catch {
// Silently ignore write errors
}
}
interface ProgressEntry {
episodeId: string;
position: number;
duration: number;
timestamp: string | Date;
playbackSpeed?: number;
}
/** Load progress map from JSON file */
export async function loadProgressFromFile(): Promise<
Record<string, ProgressEntry>
> {
try {
const filePath = getConfigFilePath(PROGRESS_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return {};
const raw = await file.json();
if (!raw || typeof raw !== "object") return {};
return raw as Record<string, ProgressEntry>;
} catch {
return {};
}
}
/** Save progress map to JSON file */
export async function saveProgressToFile(
data: Record<string, unknown>,
): Promise<void> {
try {
await ensureConfigDir();
await backupConfigFile(PROGRESS_FILE);
const filePath = getConfigFilePath(PROGRESS_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch {
// Silently ignore write errors
}
}
interface AudioNavEntry {
source: string;
currentIndex: number;
podcastId?: string;
lastUpdated: string;
}
/** Load audio navigation state from JSON file */
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
try {
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
const file = Bun.file(filePath);
if (!(await file.exists())) return null;
const raw = await file.json();
if (!raw || typeof raw !== "object") return null;
return raw as T;
} catch {
return null;
}
}
/** Save audio navigation state to JSON file */
export async function saveAudioNavToFile<T>(
data: T,
): Promise<void> {
try {
await ensureConfigDir();
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
await Bun.write(filePath, JSON.stringify(data, null, 2));
} catch {
// Silently ignore write errors
}
}

852
src/utils/audio-player.ts Normal file
View File

@@ -0,0 +1,852 @@
/**
* Cross-platform audio playback engine for PodTUI.
*
* Backend priority:
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
* 2. ffplay — basic control via process signals
* 3. afplay — macOS built-in (no seek/speed, volume only)
* 4. system — open/xdg-open/start (fire-and-forget, no control)
*
* All backends implement the AudioBackend interface so the Player
* component doesn't need to care which one is active.
*/
import { platform } from "os"
import { existsSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
// ── Types ────────────────────────────────────────────────────────────
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none"
export interface AudioState {
playing: boolean
position: number
duration: number
volume: number
speed: number
backend: BackendName
error: string | null
}
export interface AudioBackend {
readonly name: BackendName
play(url: string, opts?: PlayOptions): Promise<void>
pause(): Promise<void>
resume(): Promise<void>
stop(): Promise<void>
seek(seconds: number): Promise<void>
setVolume(volume: number): Promise<void>
setSpeed(speed: number): Promise<void>
getPosition(): Promise<number>
getDuration(): Promise<number>
isPlaying(): boolean
dispose(): void
}
export interface PlayOptions {
startPosition?: number
volume?: number
speed?: number
}
// ── Utilities ────────────────────────────────────────────────────────
function which(cmd: string): string | null {
const resolved = Bun.which(cmd)
if (resolved) return resolved
if (platform() === "darwin") {
const candidates = [
`/opt/homebrew/bin/${cmd}`,
`/usr/local/bin/${cmd}`,
`/usr/bin/${cmd}`,
]
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
}
return null
}
function mpvSocketPath(): string {
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`)
}
// ── mpv Backend ──────────────────────────────────────────────────────
// Uses JSON IPC over a Unix socket for full bidirectional control.
class MpvBackend implements AudioBackend {
readonly name: BackendName = "mpv"
private proc: ReturnType<typeof Bun.spawn> | null = null
private socketPath = mpvSocketPath()
private _playing = false
private _position = 0
private _duration = 0
private _volume = 100
private _speed = 1
private pollTimer: ReturnType<typeof setInterval> | null = null
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop()
// Clean up stale socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs")
unlinkSync(this.socketPath)
}
} catch { /* ignore */ }
const args = [
"mpv",
"--no-video",
"--no-terminal",
"--really-quiet",
`--input-ipc-server=${this.socketPath}`,
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
`--speed=${opts?.speed ?? 1}`,
]
if (opts?.startPosition && opts.startPosition > 0) {
args.push(`--start=${opts.startPosition}`)
}
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)
// Start polling position
this.startPolling()
// Detect process exit
this.proc.exited.then(() => {
this._playing = false
this.stopPolling()
}).catch(() => {})
}
private async waitForSocket(timeoutMs: number): Promise<void> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (existsSync(this.socketPath)) return
await new Promise((r) => setTimeout(r, 50))
}
}
private async ipc(command: unknown[]): Promise<unknown> {
try {
const socket = await Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
// Response handling is done by reading below
},
error(_socket, err) {},
close() {},
open() {},
},
})
const payload = JSON.stringify({ command }) + "\n"
socket.write(payload)
// Read response with timeout
const response = await new Promise<string>((resolve) => {
let buf = ""
const reader = setInterval(() => {
// Check if we got a response already
if (buf.includes("\n")) {
clearInterval(reader)
resolve(buf)
}
}, 10)
setTimeout(() => {
clearInterval(reader)
resolve(buf)
}, 200)
})
socket.end()
if (response) {
try { return JSON.parse(response.split("\n")[0]) } catch { return null }
}
return null
} catch {
return null
}
}
/** Send a command over mpv's IPC and get the parsed response data. */
private async ipcCommand(command: unknown[]): Promise<unknown> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
})
const payload = JSON.stringify({ command }) + "\n"
conn.write(payload)
// Give mpv a moment to process, then read via a fresh connection
await new Promise((r) => setTimeout(r, 30))
conn.end()
return null
} catch {
return null
}
}
/** Send a fire-and-forget command (no response needed) */
private async send(command: unknown[]): Promise<void> {
try {
const conn = await Bun.connect({
unix: this.socketPath,
socket: {
data() {},
error() {},
close() {},
open() {},
},
})
conn.write(JSON.stringify({ command }) + "\n")
// Don't wait, just schedule a close
setTimeout(() => { try { conn.end() } catch {} }, 50)
} catch { /* ignore */ }
}
/** Get a property value from mpv via IPC */
private async getProperty(name: string): Promise<number> {
try {
return await new Promise<number>((resolve) => {
let result = 0
const timeout = setTimeout(() => resolve(result), 300)
Bun.connect({
unix: this.socketPath,
socket: {
data(_socket, data) {
try {
const text = Buffer.from(data).toString()
const parsed = JSON.parse(text.split("\n")[0])
if (parsed?.data !== undefined) {
result = Number(parsed.data) || 0
}
} catch { /* ignore parse errors */ }
clearTimeout(timeout)
resolve(result)
},
error() { clearTimeout(timeout); resolve(0) },
close() {},
open(socket) {
socket.write(JSON.stringify({ command: ["get_property", name] }) + "\n")
},
},
}).catch(() => { clearTimeout(timeout); resolve(0) })
})
} catch {
return 0
}
}
private startPolling(): void {
this.stopPolling()
this.pollTimer = setInterval(async () => {
if (!this._playing || !this.proc) return
this._position = await this.getProperty("time-pos")
if (this._duration <= 0) {
this._duration = await this.getProperty("duration")
}
}, 500)
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
async pause(): Promise<void> {
await this.send(["set_property", "pause", true])
this._playing = false
}
async resume(): Promise<void> {
await this.send(["set_property", "pause", false])
this._playing = true
}
async stop(): Promise<void> {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch { /* ignore */ }
this.proc = null
}
this._playing = false
this._position = 0
// Clean up socket
try {
if (existsSync(this.socketPath)) {
const { unlinkSync } = await import("fs")
unlinkSync(this.socketPath)
}
} catch { /* ignore */ }
}
async seek(seconds: number): Promise<void> {
await this.send(["set_property", "time-pos", seconds])
this._position = seconds
}
async setVolume(volume: number): Promise<void> {
const v = Math.round(volume * 100)
await this.send(["set_property", "volume", v])
this._volume = v
}
async setSpeed(speed: number): Promise<void> {
await this.send(["set_property", "speed", speed])
this._speed = speed
}
async getPosition(): Promise<number> {
return this._position
}
async getDuration(): Promise<number> {
if (this._duration <= 0) {
this._duration = await this.getProperty("duration")
}
return this._duration
}
isPlaying(): boolean {
return this._playing
}
dispose(): void {
this.stop()
}
}
// ── ffplay Backend ───────────────────────────────────────────────────
// ffplay has no IPC. We track duration from episode metadata and
// position via elapsed wall-clock time. Seek requires restarting.
class FfplayBackend implements AudioBackend {
readonly name: BackendName = "ffplay"
private proc: ReturnType<typeof Bun.spawn> | null = null
private _playing = false
private _paused = false
private _position = 0
private _duration = 0
private _volume = 100
private _speed = 1
private _url = ""
private startTime = 0
private pollTimer: ReturnType<typeof setInterval> | null = null
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop()
this._url = url
this._volume = Math.round((opts?.volume ?? 1) * 100)
this._speed = opts?.speed ?? 1
this._position = opts?.startPosition ?? 0
this.spawnProcess()
}
private spawnProcess(): void {
const args = [
"ffplay",
"-nodisp",
"-autoexit",
"-loglevel", "quiet",
"-volume", String(this._volume),
]
if (this._position > 0) {
args.push("-ss", String(this._position))
}
if (this._speed !== 1) {
args.push("-af", `atempo=${this._speed}`)
}
args.push("-i", this._url)
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
})
this._playing = true
this._paused = false
this.startTime = Date.now()
this.startPolling()
this.proc.exited.then(() => {
this._playing = false
this.stopPolling()
}).catch(() => {})
}
private startPolling(): void {
this.stopPolling()
this.pollTimer = setInterval(() => {
if (!this._playing) return
const elapsed = (Date.now() - this.startTime) / 1000 * this._speed
this._position = this._position + elapsed
this.startTime = Date.now()
}, 500)
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
async pause(): Promise<void> {
if (this.proc) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
try { if (pid) process.kill(pid, "SIGSTOP") } catch {}
this._paused = true
}
this._playing = false
this.stopPolling()
}
async resume(): Promise<void> {
if (!this._url) return
if (this.proc && this._paused) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
try { if (pid) process.kill(pid, "SIGCONT") } catch {}
this._paused = false
this._playing = true
this.startTime = Date.now()
this.startPolling()
return
}
this.spawnProcess()
}
async stop(): Promise<void> {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this._playing = false
this._paused = false
this._position = 0
this._url = ""
}
async seek(seconds: number): Promise<void> {
this._position = seconds
if (this._playing && this._url) {
// Restart at new position
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async setVolume(volume: number): Promise<void> {
this._volume = Math.round(volume * 100)
// ffplay has no runtime IPC; volume will apply on next play/resume.
// Restart the process to apply immediately if currently playing.
if (this._url && (this._playing || this._paused)) {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async setSpeed(speed: number): Promise<void> {
this._speed = speed
if (this._url && (this._playing || this._paused)) {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async getPosition(): Promise<number> {
return this._position
}
async getDuration(): Promise<number> {
return this._duration
}
isPlaying(): boolean {
return this._playing
}
dispose(): void {
this.stop()
}
}
// ── afplay Backend (macOS) ───────────────────────────────────────────
// Built-in on macOS. Supports volume and rate but no seek or position.
class AfplayBackend implements AudioBackend {
readonly name: BackendName = "afplay"
private proc: ReturnType<typeof Bun.spawn> | null = null
private _playing = false
private _paused = false
private _position = 0
private _duration = 0
private _volume = 1
private _speed = 1
private _url = ""
private startTime = 0
private pollTimer: ReturnType<typeof setInterval> | null = null
async play(url: string, opts?: PlayOptions): Promise<void> {
await this.stop()
this._url = url
this._volume = opts?.volume ?? 1
this._speed = opts?.speed ?? 1
this._position = opts?.startPosition ?? 0
this.spawnProcess()
}
private spawnProcess(): void {
// afplay supports --volume (0-1) and --rate
const args = [
"afplay",
"--volume", String(this._volume),
"--rate", String(this._speed),
]
if (this._position > 0) {
args.push("--time", String(this._duration > 0 ? this._duration - this._position : 0))
}
args.push(this._url)
this.proc = Bun.spawn(args, {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
})
this._playing = true
this._paused = false
this.startTime = Date.now()
this.startPolling()
this.proc.exited.then(() => {
this._playing = false
this.stopPolling()
}).catch(() => {})
}
private startPolling(): void {
this.stopPolling()
this.pollTimer = setInterval(() => {
if (!this._playing) return
const elapsed = (Date.now() - this.startTime) / 1000 * this._speed
this._position = this._position + elapsed
this.startTime = Date.now()
}, 500)
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
async pause(): Promise<void> {
if (this.proc) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
try { if (pid) process.kill(pid, "SIGSTOP") } catch {}
this._paused = true
}
this._playing = false
this.stopPolling()
}
async resume(): Promise<void> {
if (!this._url) return
if (this.proc && this._paused) {
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
try { if (pid) process.kill(pid, "SIGCONT") } catch {}
this._paused = false
this._playing = true
this.startTime = Date.now()
this.startPolling()
return
}
this.spawnProcess()
}
async stop(): Promise<void> {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this._playing = false
this._paused = false
this._position = 0
this._url = ""
}
async seek(seconds: number): Promise<void> {
this._position = seconds
if (this._playing && this._url) {
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async setVolume(volume: number): Promise<void> {
this._volume = volume
// Restart the process with new volume to apply immediately
if (this._url && (this._playing || this._paused)) {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async setSpeed(speed: number): Promise<void> {
this._speed = speed
// Restart the process with new rate to apply immediately
if (this._url && (this._playing || this._paused)) {
this.stopPolling()
if (this.proc) {
try { this.proc.kill() } catch {}
this.proc = null
}
this.spawnProcess()
const pid = (this.proc as unknown as { pid?: number } | null)?.pid
if (this._paused && pid) {
try { process.kill(pid, "SIGSTOP") } catch {}
this._playing = false
}
}
}
async getPosition(): Promise<number> {
return this._position
}
async getDuration(): Promise<number> {
return this._duration
}
isPlaying(): boolean {
return this._playing
}
dispose(): void {
this.stop()
}
}
// ── System Backend (open/xdg-open) ───────────────────────────────────
// Fire-and-forget. Opens the URL in the default handler. No control.
class SystemBackend implements AudioBackend {
readonly name: BackendName = "system"
private _playing = false
async play(url: string): Promise<void> {
const os = platform()
const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"
Bun.spawn([cmd, url], {
stdout: "ignore",
stderr: "ignore",
stdin: "ignore",
})
this._playing = true
}
async pause(): Promise<void> { this._playing = false }
async resume(): Promise<void> { this._playing = true }
async stop(): Promise<void> { this._playing = false }
async seek(): Promise<void> {}
async setVolume(): Promise<void> {}
async setSpeed(): Promise<void> {}
async getPosition(): Promise<number> { return 0 }
async getDuration(): Promise<number> { return 0 }
isPlaying(): boolean { return this._playing }
dispose(): void { this._playing = false }
}
// ── No-op Backend ────────────────────────────────────────────────────
class NoopBackend implements AudioBackend {
readonly name: BackendName = "none"
async play(): Promise<void> {}
async pause(): Promise<void> {}
async resume(): Promise<void> {}
async stop(): Promise<void> {}
async seek(): Promise<void> {}
async setVolume(): Promise<void> {}
async setSpeed(): Promise<void> {}
async getPosition(): Promise<number> { return 0 }
async getDuration(): Promise<number> { return 0 }
isPlaying(): boolean { return false }
dispose(): void {}
}
// ── Detection & Factory ──────────────────────────────────────────────
export interface DetectedPlayer {
name: BackendName
path: string | null
capabilities: {
seek: boolean
volume: boolean
speed: boolean
positionTracking: boolean
}
}
/** Detect all available audio players on this system. */
export function detectPlayers(): DetectedPlayer[] {
const players: DetectedPlayer[] = []
const mpvPath = which("mpv")
if (mpvPath) {
players.push({
name: "mpv",
path: mpvPath,
capabilities: { seek: true, volume: true, speed: true, positionTracking: true },
})
}
const ffplayPath = which("ffplay")
if (ffplayPath) {
players.push({
name: "ffplay",
path: ffplayPath,
capabilities: { seek: true, volume: true, speed: false, positionTracking: false },
})
}
const os = platform()
if (os === "darwin") {
const afplayPath = which("afplay")
if (afplayPath) {
players.push({
name: "afplay",
path: afplayPath,
capabilities: { seek: true, volume: true, speed: true, positionTracking: false },
})
}
}
// System open is always available as fallback
const openCmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"
if (which(openCmd)) {
players.push({
name: "system",
path: which(openCmd),
capabilities: { seek: false, volume: false, speed: false, positionTracking: false },
})
}
return players
}
/** Create the best available audio backend. */
export function createAudioBackend(preferred?: BackendName): AudioBackend {
if (preferred) {
const backend = createBackendByName(preferred)
if (backend) return backend
}
// Auto-detect in priority order
const players = detectPlayers()
if (players.length === 0) return new NoopBackend()
return createBackendByName(players[0].name) ?? new NoopBackend()
}
function createBackendByName(name: BackendName): AudioBackend | null {
switch (name) {
case "mpv": return which("mpv") ? new MpvBackend() : null
case "ffplay": return which("ffplay") ? new FfplayBackend() : null
case "afplay": return platform() === "darwin" && which("afplay") ? new AfplayBackend() : null
case "system": return new SystemBackend()
case "none": return new NoopBackend()
}
}

View File

@@ -0,0 +1,251 @@
/**
* Real-time audio stream reader for visualization.
*
* Spawns a separate ffmpeg process that decodes the same audio URL
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
* and provides them to the caller on demand.
*
* This is independent from the actual playback backend — it's a
* read-only "tap" on the audio for FFT analysis purposes.
*/
/** PCM output format constants */
const SAMPLE_RATE = 44100
const CHANNELS = 1
const BYTES_PER_SAMPLE = 2 // s16le
/** How many samples to buffer (~1 second) */
const RING_BUFFER_SAMPLES = SAMPLE_RATE
export interface AudioStreamReaderOptions {
/** Audio URL or file path to decode */
url: string
/** Sample rate (default: 44100) */
sampleRate?: number
}
/**
* Monotonically increasing generation counter.
* Each start() increments this; the read loop checks it to know
* if it's been superseded and should bail out.
*/
let globalGeneration = 0
export class AudioStreamReader {
private proc: ReturnType<typeof Bun.spawn> | null = null
private ringBuffer: Float64Array
private writePos = 0
private totalSamplesWritten = 0
private _running = false
private generation = 0
readonly url: string
private sampleRate: number
constructor(options: AudioStreamReaderOptions) {
this.url = options.url
this.sampleRate = options.sampleRate ?? SAMPLE_RATE
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES)
}
/** Whether the reader is actively reading samples. */
get running(): boolean {
return this._running
}
/** Total number of samples written since start(). */
get samplesWritten(): number {
return this.totalSamplesWritten
}
/**
* Start the ffmpeg decode process and begin reading PCM data.
*
* If already running, the previous process is killed first.
* Uses a generation counter to guarantee that only one read loop
* is ever active — stale loops from killed processes bail out
* immediately.
*
* @param startPosition Seek position in seconds (default: 0).
* @param speed Playback speed multiplier (default: 1). Applies ffmpeg
* atempo filter so visualization stays in sync with audio.
*/
start(startPosition = 0, speed = 1): void {
// Always kill the previous process first — no early return on _running
this.killProcess()
if (!Bun.which("ffmpeg")) {
throw new Error("ffmpeg not found — required for audio visualization")
}
// Increment generation so any lingering read loop from a previous
// start() will see a mismatch and exit.
this.generation = ++globalGeneration
const args = [
"ffmpeg",
"-loglevel", "quiet",
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "5",
]
// Seek before input for network efficiency
if (startPosition > 0) {
args.push("-ss", String(startPosition))
}
args.push("-i", this.url)
// Apply speed via atempo filter if not 1x.
// ffmpeg atempo only supports 0.5100.0; chain multiple for extremes.
if (speed !== 1 && speed > 0) {
args.push("-af", buildAtempoChain(speed))
}
args.push(
"-ac", String(CHANNELS),
"-ar", String(this.sampleRate),
"-f", "s16le",
"-acodec", "pcm_s16le",
"-",
)
this.proc = Bun.spawn(args, {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
})
this._running = true
this.writePos = 0
this.totalSamplesWritten = 0
// Capture generation for this run
const myGeneration = this.generation
// Start async reading loop
this.readLoop(myGeneration)
// Detect process exit
this.proc.exited.then(() => {
// Only clear _running if this is still the current generation
if (this.generation === myGeneration) {
this._running = false
}
}).catch(() => {
if (this.generation === myGeneration) {
this._running = false
}
})
}
/**
* Read available samples into the provided buffer.
* Returns the number of samples actually copied.
*
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
* @returns Number of samples written to `out`.
*/
read(out: Float64Array): number {
const available = Math.min(out.length, this.totalSamplesWritten, this.ringBuffer.length)
if (available <= 0) return 0
// Read the most recent `available` samples from the ring buffer
const readStart = (this.writePos - available + this.ringBuffer.length) % this.ringBuffer.length
if (readStart + available <= this.ringBuffer.length) {
out.set(this.ringBuffer.subarray(readStart, readStart + available))
} else {
const firstChunk = this.ringBuffer.length - readStart
out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length))
out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk)
}
return available
}
/**
* Stop the ffmpeg process and clean up.
* Safe to call multiple times. Guarantees the read loop exits.
*/
stop(): void {
// Bump generation to invalidate any running read loop
this.generation = ++globalGeneration
this._running = false
this.killProcess()
this.writePos = 0
this.totalSamplesWritten = 0
}
/**
* Restart the reader at a new position and/or speed.
*/
restart(startPosition = 0, speed = 1): void {
this.start(startPosition, speed)
}
/** Kill the ffmpeg process without touching generation/state. */
private killProcess(): void {
if (this.proc) {
try { this.proc.kill() } catch { /* ignore */ }
this.proc = null
}
}
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
private async readLoop(myGeneration: number): Promise<void> {
const stdout = this.proc?.stdout
if (!stdout || typeof stdout === "number") return
const reader = (stdout as ReadableStream<Uint8Array>).getReader()
try {
while (this.generation === myGeneration) {
const { done, value } = await reader.read()
if (done || this.generation !== myGeneration) break
if (!value || value.byteLength === 0) continue
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE)
if (sampleCount === 0) continue
const int16View = new Int16Array(
value.buffer,
value.byteOffset,
sampleCount,
)
for (let i = 0; i < sampleCount; i++) {
this.ringBuffer[this.writePos] = int16View[i]
this.writePos = (this.writePos + 1) % this.ringBuffer.length
this.totalSamplesWritten++
}
}
} catch {
// Stream ended or process killed — expected during stop()
} finally {
try { reader.releaseLock() } catch { /* ignore */ }
}
}
}
/**
* Build an ffmpeg atempo filter chain for a given speed.
* atempo only accepts values in [0.5, 100.0], so we chain
* multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5).
*/
function buildAtempoChain(speed: number): string {
const parts: string[] = []
let remaining = Math.max(0.25, Math.min(4, speed))
while (remaining > 100) {
parts.push("atempo=100.0")
remaining /= 100
}
while (remaining < 0.5) {
parts.push("atempo=0.5")
remaining /= 0.5
}
parts.push(`atempo=${remaining}`)
return parts.join(",")
}

103
src/utils/audio-waveform.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Audio waveform analysis for PodTUI
*
* Extracts amplitude data from audio files using ffmpeg (when available)
* Results are cache in-memory keyed by audio URL.
*/
/** Number of amplitude data points to generate */
const DEFAULT_RESOLUTION = 128;
/** In-memory cache: audioUrl -> amplitude data */
const waveformCache = new Map<string, number[]>();
/**
* Try to extract real waveform data from an audio URL using ffmpeg.
* Returns null if ffmpeg is not available or the extraction fails.
*/
async function extractWithFfmpeg(
audioUrl: string,
resolution: number,
): Promise<number[] | null> {
try {
if (!Bun.which("ffmpeg")) return null;
// Use ffmpeg to output raw PCM samples, then downsample to `resolution` points.
// -t 300: read at most 5 minutes (enough data to fill the waveform)
const proc = Bun.spawn(
[
"ffmpeg",
"-i",
audioUrl,
"-t",
"300",
"-ac",
"1", // mono
"-ar",
"8000", // low sample rate to keep data small
"-f",
"s16le", // raw signed 16-bit PCM
"-v",
"quiet",
"-",
],
{ stdout: "pipe", stderr: "ignore" },
);
const output = await new Response(proc.stdout).arrayBuffer();
await proc.exited;
if (output.byteLength === 0) return null;
const samples = new Int16Array(output);
if (samples.length === 0) return null;
// Downsample to `resolution` buckets by taking the max absolute amplitude
// in each bucket.
const bucketSize = Math.max(1, Math.floor(samples.length / resolution));
const data: number[] = [];
for (let i = 0; i < resolution; i++) {
const start = i * bucketSize;
const end = Math.min(start + bucketSize, samples.length);
let maxAbs = 0;
for (let j = start; j < end; j++) {
const abs = Math.abs(samples[j]);
if (abs > maxAbs) maxAbs = abs;
}
// Normalise to 0-1
data.push(Number((maxAbs / 32768).toFixed(3)));
}
return data;
} catch {
return null;
}
}
/**
* Get waveform data for an audio URL.
*
* Returns cached data if available, otherwise attempts ffmpeg extraction
*/
export async function getWaveformData(
audioUrl: string,
resolution: number = DEFAULT_RESOLUTION,
): Promise<number[]> {
const cacheKey = `${audioUrl}:${resolution}`;
const cached = waveformCache.get(cacheKey);
if (cached) return cached;
const real = await extractWithFfmpeg(audioUrl, resolution);
if (real) {
waveformCache.set(cacheKey, real);
return real;
} else {
console.error("generation failure");
return [];
}
}
export function clearWaveformCache(): void {
waveformCache.clear();
}

57
src/utils/cache.ts Normal file
View File

@@ -0,0 +1,57 @@
type CacheEntry<T> = {
value: T
timestamp: number
}
const CACHE_KEY = "podtui_cache"
const DEFAULT_TTL = 1000 * 60 * 60
const loadCache = (): Record<string, CacheEntry<unknown>> => {
if (typeof localStorage === "undefined") return {}
try {
const raw = localStorage.getItem(CACHE_KEY)
return raw ? (JSON.parse(raw) as Record<string, CacheEntry<unknown>>) : {}
} catch {
return {}
}
}
const saveCache = (cache: Record<string, CacheEntry<unknown>>) => {
if (typeof localStorage === "undefined") return
try {
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
} catch {
// ignore
}
}
const cache = loadCache()
export const cacheValue = <T,>(key: string, value: T) => {
cache[key] = { value, timestamp: Date.now() }
saveCache(cache)
}
export const getCachedValue = <T,>(key: string, ttl = DEFAULT_TTL): T | null => {
const entry = cache[key] as CacheEntry<T> | undefined
if (!entry) return null
if (Date.now() - entry.timestamp > ttl) {
delete cache[key]
saveCache(cache)
return null
}
return entry.value
}
export const invalidateCache = (prefix?: string) => {
if (!prefix) {
Object.keys(cache).forEach((key) => delete cache[key])
saveCache(cache)
return
}
Object.keys(cache)
.filter((key) => key.startsWith(prefix))
.forEach((key) => delete cache[key])
saveCache(cache)
}

230
src/utils/cavacore.ts Normal file
View File

@@ -0,0 +1,230 @@
/**
* TypeScript FFI bindings for libcavacore.
*
* Wraps cava's frequency-analysis engine (cavacore) via Bun's dlopen.
* The precompiled shared library ships in src/native/ (dev) and dist/ (prod)
* with fftw3 statically linked — zero native dependencies for end users.
*
* Usage:
* ```ts
* const cava = loadCavaCore()
* if (cava) {
* cava.init({ bars: 32, sampleRate: 44100 })
* const freqs = cava.execute(pcmSamples)
* cava.destroy()
* }
* ```
*/
import { dlopen, FFIType, ptr } from "bun:ffi"
import { existsSync } from "fs"
import { join, dirname } from "path"
// ── Types ────────────────────────────────────────────────────────────
export interface CavaCoreConfig {
/** Number of frequency bars (default: 32) */
bars?: number
/** Audio sample rate in Hz (default: 44100) */
sampleRate?: number
/** Number of audio channels (default: 1 = mono) */
channels?: number
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
autosens?: number
/** Noise reduction factor 0.01.0 (default: 0.77) */
noiseReduction?: number
/** Low frequency cutoff in Hz (default: 50) */
lowCutOff?: number
/** High frequency cutoff in Hz (default: 10000) */
highCutOff?: number
}
const DEFAULTS: Required<CavaCoreConfig> = {
bars: 32,
sampleRate: 44100,
channels: 1,
autosens: 1,
noiseReduction: 0.77,
lowCutOff: 50,
highCutOff: 10000,
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type CavaLib = { symbols: Record<string, (...args: any[]) => any>; close(): void }
// ── Library resolution ───────────────────────────────────────────────
function findLibrary(): string | null {
const platform = process.platform
const libName = platform === "darwin"
? "libcavacore.dylib"
: platform === "win32"
? "cavacore.dll"
: "libcavacore.so"
// Candidate paths, in priority order:
// 1. src/native/ (development)
// 2. Same directory as the running executable (dist bundle)
// 3. dist/ relative to cwd
const candidates = [
join(import.meta.dir, "..", "native", libName),
join(dirname(process.execPath), libName),
join(process.cwd(), "dist", libName),
]
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
return null
}
// ── CavaCore class ───────────────────────────────────────────────────
export class CavaCore {
private lib: CavaLib
private plan: ReturnType<CavaLib["symbols"]["cava_init"]> | null = null
private inputBuffer: Float64Array | null = null
private outputBuffer: Float64Array | null = null
private _bars = 0
private _channels = 1
private _destroyed = false
/** Use loadCavaCore() instead of constructing directly. */
constructor(lib: CavaLib) {
this.lib = lib
}
/** Number of frequency bars configured. */
get bars(): number {
return this._bars
}
/** Whether this instance has been initialized (and not yet destroyed). */
get isReady(): boolean {
return this.plan !== null && !this._destroyed
}
/**
* Initialize the cavacore engine with the given configuration.
* Must be called before execute(). Can be called again after destroy()
* to reinitialize with different parameters.
*/
init(config: CavaCoreConfig = {}): void {
if (this.plan) {
this.destroy()
}
const cfg = { ...DEFAULTS, ...config }
this._bars = cfg.bars
this._channels = cfg.channels
this.plan = this.lib.symbols.cava_init(
cfg.bars,
cfg.sampleRate,
cfg.channels,
cfg.autosens,
cfg.noiseReduction,
cfg.lowCutOff,
cfg.highCutOff,
)
if (!this.plan) {
throw new Error("cava_init returned null — initialization failed")
}
// Pre-allocate output buffer (bars * channels)
this.outputBuffer = new Float64Array(cfg.bars * cfg.channels)
this._destroyed = false
}
/**
* Feed PCM samples into cavacore and get frequency bar values back.
*
* @param samples - Float64Array of PCM samples (scaled ~±32768).
* The array length determines the number of samples processed.
* @returns Float64Array of bar values (0.01.0 range, length = bars * channels).
* Returns the same buffer reference each call (overwritten in place).
*/
execute(samples: Float64Array): Float64Array {
if (!this.plan || !this.outputBuffer) {
throw new Error("CavaCore not initialized — call init() first")
}
// Reuse input buffer if same size, otherwise allocate new
if (!this.inputBuffer || this.inputBuffer.length !== samples.length) {
this.inputBuffer = new Float64Array(samples.length)
}
this.inputBuffer.set(samples)
this.lib.symbols.cava_execute(
ptr(this.inputBuffer),
samples.length,
ptr(this.outputBuffer),
this.plan,
)
return this.outputBuffer
}
/**
* Release all native resources. Safe to call multiple times.
* After calling destroy(), init() can be called again to reuse the instance.
*/
destroy(): void {
if (this.plan && !this._destroyed) {
this.lib.symbols.cava_destroy(this.plan)
this.plan = null
this._destroyed = true
}
this.inputBuffer = null
this.outputBuffer = null
}
}
// ── Factory ──────────────────────────────────────────────────────────
/**
* Attempt to load the cavacore shared library and return a CavaCore instance.
* Returns null if the library cannot be found — callers should fall back
* to the static waveform display.
*/
export function loadCavaCore(): CavaCore | null {
try {
const libPath = findLibrary()
if (!libPath) return null
const lib = dlopen(libPath, {
cava_init: {
args: [
FFIType.i32, // bars
FFIType.u32, // rate
FFIType.i32, // channels
FFIType.i32, // autosens
FFIType.double, // noise_reduction
FFIType.i32, // low_cut_off
FFIType.i32, // high_cut_off
],
returns: FFIType.ptr,
},
cava_execute: {
args: [
FFIType.ptr, // cava_in (double*)
FFIType.i32, // samples
FFIType.ptr, // cava_out (double*)
FFIType.ptr, // plan
],
returns: FFIType.void,
},
cava_destroy: {
args: [FFIType.ptr], // plan
returns: FFIType.void,
},
})
return new CavaCore(lib as CavaLib)
} catch {
// Library load failed — missing dylib, wrong arch, etc.
return null
}
}

221
src/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,221 @@
import { $ } from "bun"
import { platform, release } from "os"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
/**
* Lazy initialization for clipboard copy method.
* Detects the best clipboard method for the current platform.
*/
function createLazy<T>(factory: () => T): () => T {
let value: T | undefined
return () => {
if (value === undefined) {
value = factory()
}
return value
}
}
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
/**
* Read content from the clipboard.
* Supports text and image (PNG) content on macOS, Windows, and Linux.
*/
export async function read(): Promise<Content | undefined> {
const os = platform()
// macOS: Try to read PNG image first
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "podtui-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
if (buffer.byteLength > 0) {
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
}
} catch {
// Ignore errors, fall through to text
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}
// Windows/WSL: Try to read PNG image
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
// Linux: Try Wayland or X11
if (os === "linux") {
// Try Wayland first
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
// Try X11
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
// Fall back to reading text
try {
const text = await readText()
if (text) {
return { data: text, mime: "text/plain" }
}
} catch {
// Ignore errors
}
return undefined
}
/**
* Read text from the clipboard.
*/
export async function readText(): Promise<string | undefined> {
const os = platform()
if (os === "darwin") {
const result = await $`pbpaste`.nothrow().text()
return result || undefined
}
if (os === "linux") {
// Try Wayland first
if (process.env["WAYLAND_DISPLAY"]) {
const result = await $`wl-paste`.nothrow().text()
if (result) return result
}
// Try X11
const result = await $`xclip -selection clipboard -o`.nothrow().text()
return result || undefined
}
if (os === "win32" || release().includes("WSL")) {
const result = await $`powershell.exe -NonInteractive -NoProfile -command "Get-Clipboard"`.nothrow().text()
return result?.trim() || undefined
}
return undefined
}
const getCopyMethod = createLazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
}
if (os === "win32") {
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
// Fallback: No native clipboard support
return async (_text: string) => {
console.warn("No clipboard support available on this platform")
}
})
/**
* Copy text to the clipboard.
* Uses OSC 52 for SSH/tmux support and native clipboard for local.
*/
export async function copy(text: string): Promise<void> {
// Always try OSC 52 first for SSH/tmux support
writeOsc52(text)
// Then use native clipboard
await getCopyMethod()(text)
}
}

Some files were not shown because too many files have changed in this diff Show More