Compare commits
73 Commits
f08afb2ed1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c4938c54 | |||
| 256f112512 | |||
| 8196ac8e31 | |||
| f003377f0d | |||
| 1618588a30 | |||
| c9a370a424 | |||
| b45e7bf538 | |||
| 1e6618211a | |||
| 1a5efceebd | |||
| 0c16353e2e | |||
| 8d350d9eb5 | |||
| cc09786592 | |||
| cedf099910 | |||
| d1e1dd28b4 | |||
| 1c65c85d02 | |||
| 8e0f90f449 | |||
| 91fcaa9b9e | |||
| 0bbb327b29 | |||
| 276732d2a9 | |||
| 72000b362d | |||
| 9a2b790897 | |||
| 2dfc96321b | |||
| 3d5bc84550 | |||
| f707594d0c | |||
| a405474f11 | |||
| ce022dc447 | |||
| 6053d4d02c | |||
| 64a2ba2751 | |||
| bcf248f7dd | |||
| 5bd393c9cd | |||
| 627fb65547 | |||
| 73aa211229 | |||
| 7eb49ac1c7 | |||
| 19a1f1a43b | |||
| 2e323d283f | |||
| 46f9135776 | |||
| db74e20571 | |||
| 70f50eec2a | |||
| 1cee931913 | |||
| bfea6816ef | |||
| 75f1f7d6af | |||
| 1e3b794b8e | |||
| 1293d30225 | |||
| 920042ee2a | |||
| e1dc242b1d | |||
| 8d6b19582c | |||
| 63ded34a6b | |||
| 0e4f47323f | |||
| 42a1ddf458 | |||
| 168e6d5a61 | |||
| 6b00871c32 | |||
| e0fa76fb32 | |||
| f3344fbed2 | |||
| 03e69d04dc | |||
| 91de49be0d | |||
| 3d156403c7 | |||
| e239b33042 | |||
| 9fa52d71ca | |||
| ea9ab4d3f9 | |||
| 6950deaa88 | |||
| 4579659784 | |||
| c26150221a | |||
| 39a4f88496 | |||
| 624a6ba022 | |||
| cdabf2c3e0 | |||
| b8549777ba | |||
| 9b1a3585e6 | |||
| 4cee352641 | |||
| 72b2870f64 | |||
| f7df578461 | |||
| bd4747679d | |||
| d5ce8452e4 | |||
| 7b5c256e07 |
11
.eslintrc.cjs
Normal file
11
.eslintrc.cjs
Normal 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
32
.gitignore
vendored
@@ -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
97
AGENTS.md
Normal 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
15
README.md
Normal 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
62
build.ts
Normal 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")
|
||||||
1
bunfig.toml
Normal file
1
bunfig.toml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
preload = ["@opentui/solid/preload"]
|
||||||
39
package.json
39
package.json
@@ -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
79
scripts/build-cavacore.sh
Executable 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
194
src/App.tsx
Normal 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
73
src/api/client.ts
Normal 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
147
src/api/rss-parser.ts
Normal 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(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/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
94
src/api/source-handler.ts
Normal 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)]
|
||||||
|
}
|
||||||
180
src/components/CodeValidation.tsx
Normal file
180
src/components/CodeValidation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/LoadingIndicator.tsx
Normal file
24
src/components/LoadingIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/components/Navigation.tsx
Normal file
28
src/components/Navigation.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
src/components/Selectable.tsx
Normal file
81
src/components/Selectable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
28
src/components/ShortcutHelp.tsx
Normal file
28
src/components/ShortcutHelp.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/TabNavigation.tsx
Normal file
55
src/components/TabNavigation.tsx
Normal 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
75
src/config/auth.ts
Normal 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
20
src/config/keybind.jsonc
Normal 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
6
src/config/shortcuts.ts
Normal 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
|
||||||
12
src/constants/sync-formats.ts
Normal file
12
src/constants/sync-formats.ts
Normal 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
25
src/constants/themes.ts
Normal 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,
|
||||||
|
};
|
||||||
136
src/context/KeybindContext.tsx
Normal file
136
src/context/KeybindContext.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
73
src/context/NavigationContext.tsx
Normal file
73
src/context/NavigationContext.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
308
src/context/ThemeContext.tsx
Normal file
308
src/context/ThemeContext.tsx
Normal 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
53
src/context/helper.tsx
Normal 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
528
src/hooks/useAudio.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/hooks/useCachedData.ts
Normal file
34
src/hooks/useCachedData.ts
Normal 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 }
|
||||||
|
}
|
||||||
98
src/hooks/useMultimediaKeys.ts
Normal file
98
src/hooks/useMultimediaKeys.ts
Normal 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
225
src/index.tsx
Normal 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
BIN
src/native/libcavacore.dylib
Executable file
Binary file not shown.
27
src/opentui-jsx.d.ts
vendored
Normal file
27
src/opentui-jsx.d.ts
vendored
Normal 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 {}
|
||||||
185
src/pages/Discover/DiscoverPage.tsx
Normal file
185
src/pages/Discover/DiscoverPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
src/pages/Discover/PodcastCard.tsx
Normal file
85
src/pages/Discover/PodcastCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
src/pages/Feed/FeedDetail.tsx
Normal file
194
src/pages/Feed/FeedDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
src/pages/Feed/FeedFilter.tsx
Normal file
207
src/pages/Feed/FeedFilter.tsx
Normal 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
154
src/pages/Feed/FeedItem.tsx
Normal 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
198
src/pages/Feed/FeedList.tsx
Normal 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
195
src/pages/Feed/FeedPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
325
src/pages/MyShows/MyShowsPage.tsx
Normal file
325
src/pages/MyShows/MyShowsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/pages/Player/PlaybackControls.tsx
Normal file
64
src/pages/Player/PlaybackControls.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
src/pages/Player/PlayerPage.tsx
Normal file
112
src/pages/Player/PlayerPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/pages/Player/RealtimeWaveform.tsx
Normal file
256
src/pages/Player/RealtimeWaveform.tsx
Normal 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.0–1.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>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
src/pages/Search/ResultCard.tsx
Normal file
95
src/pages/Search/ResultCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/pages/Search/ResultDetail.tsx
Normal file
75
src/pages/Search/ResultDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/pages/Search/SearchHistory.tsx
Normal file
89
src/pages/Search/SearchHistory.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
src/pages/Search/SearchPage.tsx
Normal file
210
src/pages/Search/SearchPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/pages/Search/SearchResults.tsx
Normal file
80
src/pages/Search/SearchResults.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/pages/Search/SourceBadge.tsx
Normal file
43
src/pages/Search/SourceBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/pages/Settings/ExportDialog.tsx
Normal file
38
src/pages/Settings/ExportDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/pages/Settings/FilePicker.tsx
Normal file
24
src/pages/Settings/FilePicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/Settings/ImportDialog.tsx
Normal file
23
src/pages/Settings/ImportDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
src/pages/Settings/LoginScreen.tsx
Normal file
178
src/pages/Settings/LoginScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/pages/Settings/OAuthPlaceholder.tsx
Normal file
123
src/pages/Settings/OAuthPlaceholder.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
src/pages/Settings/PreferencesPanel.tsx
Normal file
159
src/pages/Settings/PreferencesPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
119
src/pages/Settings/SettingsPage.tsx
Normal file
119
src/pages/Settings/SettingsPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
src/pages/Settings/SourceManager.tsx
Normal file
317
src/pages/Settings/SourceManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/pages/Settings/SyncError.tsx
Normal file
18
src/pages/Settings/SyncError.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/pages/Settings/SyncPanel.tsx
Normal file
32
src/pages/Settings/SyncPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
src/pages/Settings/SyncProfile.tsx
Normal file
157
src/pages/Settings/SyncProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
src/pages/Settings/SyncProgress.tsx
Normal file
28
src/pages/Settings/SyncProgress.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
52
src/pages/Settings/SyncStatus.tsx
Normal file
52
src/pages/Settings/SyncStatus.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
164
src/pages/Settings/VisualizerSettings.tsx
Normal file
164
src/pages/Settings/VisualizerSettings.tsx
Normal 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
130
src/stores/app.ts
Normal 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
126
src/stores/audio-nav.ts
Normal 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
244
src/stores/auth.ts
Normal 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
215
src/stores/discover.ts
Normal 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
360
src/stores/download.ts
Normal 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
495
src/stores/feed.ts
Normal 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
166
src/stores/progress.ts
Normal 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
187
src/stores/search.ts
Normal 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
138
src/styles/theme.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/themes/catppuccin.json
Normal file
73
src/themes/catppuccin.json
Normal 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
73
src/themes/gruvbox.json
Normal 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
73
src/themes/nord.json
Normal 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
58
src/themes/schema.json
Normal 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
73
src/themes/tokyo.json
Normal 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
65
src/types/auth.ts
Normal 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
192
src/types/desktop-theme.ts
Normal 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
117
src/types/episode.ts
Normal 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
122
src/types/feed.ts
Normal 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
42
src/types/podcast.ts
Normal 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
98
src/types/settings.ts
Normal 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 (8–128, default: 32) */
|
||||||
|
bars: number;
|
||||||
|
/** Automatic sensitivity: 1 = enabled, 0 = disabled (default: 1) */
|
||||||
|
sensitivity: number;
|
||||||
|
/** Noise reduction factor 0.0–1.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
116
src/types/source.ts
Normal 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
24
src/types/sync-json.ts
Normal 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
28
src/types/sync-xml.ts
Normal 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
32
src/types/theme-schema.ts
Normal 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
333
src/ui/command.tsx
Normal 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
225
src/ui/dialog.tsx
Normal 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
153
src/ui/toast.tsx
Normal 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
41
src/utils/ansi-to-rgba.ts
Normal 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)
|
||||||
|
}
|
||||||
158
src/utils/app-persistence.ts
Normal file
158
src/utils/app-persistence.ts
Normal 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
852
src/utils/audio-player.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/utils/audio-stream-reader.ts
Normal file
251
src/utils/audio-stream-reader.ts
Normal 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.5–100.0; chain multiple for extremes.
|
||||||
|
if (speed !== 1 && speed > 0) {
|
||||||
|
args.push("-af", buildAtempoChain(speed))
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
"-ac", String(CHANNELS),
|
||||||
|
"-ar", String(this.sampleRate),
|
||||||
|
"-f", "s16le",
|
||||||
|
"-acodec", "pcm_s16le",
|
||||||
|
"-",
|
||||||
|
)
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
this._running = true
|
||||||
|
this.writePos = 0
|
||||||
|
this.totalSamplesWritten = 0
|
||||||
|
|
||||||
|
// 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
103
src/utils/audio-waveform.ts
Normal 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
57
src/utils/cache.ts
Normal 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
230
src/utils/cavacore.ts
Normal 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.0–1.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.0–1.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
221
src/utils/clipboard.ts
Normal 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
Reference in New Issue
Block a user