file ordering

This commit is contained in:
2026-02-06 14:55:42 -05:00
parent 1293d30225
commit 1e3b794b8e
101 changed files with 1662 additions and 4224 deletions

View File

@@ -1,30 +1,29 @@
import { createSignal, createMemo, ErrorBoundary } from "solid-js";
import { useSelectionHandler } from "@opentui/solid";
import { Layout } from "./components/Layout";
import { Navigation } from "./components/Navigation";
import { TabNavigation } from "./components/TabNavigation";
import { FeedPage } from "./components/FeedPage";
import { MyShowsPage } from "./components/MyShowsPage";
import { LoginScreen } from "./components/LoginScreen";
import { CodeValidation } from "./components/CodeValidation";
import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
import { SyncProfile } from "./components/SyncProfile";
import { SearchPage } from "./components/SearchPage";
import { DiscoverPage } from "./components/DiscoverPage";
import { Player } from "./components/Player";
import { SettingsScreen } from "./components/SettingsScreen";
import { useAuthStore } from "./stores/auth";
import { useFeedStore } from "./stores/feed";
import { useAppStore } from "./stores/app";
import { useAudio } from "./hooks/useAudio";
import { useMultimediaKeys } from "./hooks/useMultimediaKeys";
import { FeedVisibility } from "./types/feed";
import { useAppKeyboard } from "./hooks/useAppKeyboard";
import { Clipboard } from "./utils/clipboard";
import { emit } from "./utils/event-bus";
import type { TabId } from "./components/Tab";
import type { AuthScreen } from "./types/auth";
import type { Episode } from "./types/episode";
import { FeedPage } from "@/tabs/Feed/FeedPage";
import { MyShowsPage } from "@/tabs/MyShows/MyShowsPage";
import { LoginScreen } from "@/tabs/Settings/LoginScreen";
import { CodeValidation } from "@/components/CodeValidation";
import { OAuthPlaceholder } from "@/tabs/Settings/OAuthPlaceholder";
import { SyncProfile } from "@/tabs/Settings/SyncProfile";
import { SearchPage } from "@/tabs/Search/SearchPage";
import { DiscoverPage } from "@/tabs/Discover/DiscoverPage";
import { Player } from "@/tabs/Player/Player";
import { SettingsScreen } from "@/tabs/Settings/SettingsScreen";
import { useAuthStore } from "@/stores/auth";
import { useFeedStore } from "@/stores/feed";
import { useAppStore } from "@/stores/app";
import { useAudio } from "@/hooks/useAudio";
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
import { FeedVisibility } from "@/types/feed";
import { useAppKeyboard } from "@/hooks/useAppKeyboard";
import { Clipboard } from "@/utils/clipboard";
import { emit } from "@/utils/event-bus";
import type { TabId } from "@/components/Tab";
import type { AuthScreen } from "@/types/auth";
import type { Episode } from "@/types/episode";
export function App() {
const [activeTab, setActiveTab] = createSignal<TabId>("feed");

View File

@@ -1,42 +0,0 @@
import type { JSX } from "solid-js"
type BoxLayoutProps = {
children?: JSX.Element
flexDirection?: "row" | "column" | "row-reverse" | "column-reverse"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
gap?: number
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
margin?: number
border?: boolean
title?: string
}
export function BoxLayout(props: BoxLayoutProps) {
return (
<box
style={{
flexDirection: props.flexDirection,
justifyContent: props.justifyContent,
alignItems: props.alignItems,
gap: props.gap,
width: props.width,
height: props.height,
padding: props.padding,
margin: props.margin,
}}
border={props.border}
title={props.title}
>
{props.children}
</box>
)
}

View File

@@ -3,97 +3,99 @@
* 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 { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { AUTH_CONFIG } from "@/config/auth";
interface CodeValidationProps {
focused?: boolean
onBack?: () => void
focused?: boolean;
onBack?: () => void;
}
type FocusField = "code" | "submit" | "back"
type FocusField = "code" | "submit" | "back";
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore()
const [code, setCode] = createSignal("")
const [focusField, setFocusField] = createSignal<FocusField>("code")
const [codeError, setCodeError] = createSignal<string | null>(null)
const auth = useAuthStore();
const [code, setCode] = createSignal("");
const [focusField, setFocusField] = createSignal<FocusField>("code");
const [codeError, setCodeError] = createSignal<string | null>(null);
const fields: FocusField[] = ["code", "submit", "back"]
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, "")
const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Limit to max length
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength)
setCode(limited)
const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength);
setCode(limited);
// Clear error when typing
if (codeError()) {
setCodeError(null)
setCodeError(null);
}
}
};
const validateCode = (value: string): boolean => {
if (!value) {
setCodeError("Code is required")
return false
setCodeError("Code is required");
return false;
}
if (value.length !== AUTH_CONFIG.codeValidation.codeLength) {
setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`)
return false
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("Code must contain only letters and numbers");
return false;
}
setCodeError(null)
return true
}
setCodeError(null);
return true;
};
const handleSubmit = async () => {
if (!validateCode(code())) {
return
return;
}
const success = await auth.validateCode(code())
const success = await auth.validateCode(code());
if (!success && auth.error) {
setCodeError(auth.error.message)
setCodeError(auth.error.message);
}
}
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "submit") {
handleSubmit()
handleSubmit();
} else if (focusField() === "back" && props.onBack) {
props.onBack()
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
props.onBack();
}
}
};
const codeProgress = () => {
const len = code().length
const max = AUTH_CONFIG.codeValidation.codeLength
return `${len}/${max}`
}
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(" ")
}
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}>
@@ -103,7 +105,9 @@ export function CodeValidation(props: CodeValidationProps) {
<box height={1} />
<text fg="gray">Enter your 8-character sync code to link your account.</text>
<text fg="gray">
Enter your 8-character sync code to link your account.
</text>
<text fg="gray">You can get this code from the web portal.</text>
<box height={1} />
@@ -115,7 +119,13 @@ export function CodeValidation(props: CodeValidationProps) {
</text>
<box border padding={1}>
<text fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}>
<text
fg={
code().length === AUTH_CONFIG.codeValidation.codeLength
? "green"
: "yellow"
}
>
{codeDisplay()}
</text>
</box>
@@ -129,9 +139,7 @@ export function CodeValidation(props: CodeValidationProps) {
width={30}
/>
{codeError() && (
<text fg="red">{codeError()}</text>
)}
{codeError() && <text fg="red">{codeError()}</text>}
</box>
<box height={1} />
@@ -160,13 +168,11 @@ export function CodeValidation(props: CodeValidationProps) {
</box>
{/* Auth error message */}
{auth.error && (
<text fg="red">{auth.error.message}</text>
)}
{auth.error && <text fg="red">{auth.error.message}</text>}
<box height={1} />
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box>
)
);
}

View File

@@ -1,35 +0,0 @@
import type { JSX } from "solid-js"
type ColumnProps = {
children?: JSX.Element
gap?: number
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
}
export function Column(props: ColumnProps) {
return (
<box
style={{
flexDirection: "column",
gap: props.gap,
alignItems: props.alignItems,
justifyContent: props.justifyContent,
width: props.width,
height: props.height,
padding: props.padding,
}}
>
{props.children}
</box>
)
}

View File

@@ -1,17 +0,0 @@
type FileInfoProps = {
path: string
format: string
size: string
modifiedAt: string
}
export function FileInfo(props: FileInfoProps) {
return (
<box border title="File Info" style={{ padding: 1, flexDirection: "column" }}>
<text>Path: {props.path}</text>
<text>Format: {props.format}</text>
<text>Size: {props.size}</text>
<text>Modified: {props.modifiedAt}</text>
</box>
)
}

View File

@@ -1,17 +0,0 @@
import type { JSX } from "solid-js"
import type { TabId } from "./Tab"
/**
* @deprecated Use useAppKeyboard hook directly instead.
* This component is kept for backwards compatibility.
*/
type KeyboardHandlerProps = {
children?: JSX.Element
onTabSelect?: (tab: TabId) => void
}
export function KeyboardHandler(props: KeyboardHandlerProps) {
// Keyboard handling has been moved to useAppKeyboard hook
// This component is now just a passthrough
return <>{props.children}</>
}

View File

@@ -1,30 +0,0 @@
import { useTheme } from "../context/ThemeContext"
export function LayerIndicator({ layerDepth }: { layerDepth: number }) {
const { theme } = useTheme()
const getLayerIndicator = () => {
const indicators = []
for (let i = 0; i < 4; i++) {
const isActive = i <= layerDepth
const color = isActive ? theme.accent : theme.textMuted
const size = isActive ? "●" : "○"
indicators.push(
<text fg={color} marginRight={1}>
{size}
</text>
)
}
return indicators
}
return (
<box flexDirection="row" alignItems="center">
<text fg={theme.textMuted} marginRight={1}>Depth:</text>
{getLayerIndicator()}
<text fg={theme.textMuted} marginLeft={1}>
{layerDepth}
</text>
</box>
)
}

View File

@@ -1,64 +1,63 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import { Show, For, createMemo } from "solid-js"
import { useTheme } from "../context/ThemeContext"
import type { JSX } from "solid-js";
import type { RGBA } from "@opentui/core";
import { Show, For } from "solid-js";
import { useTheme } from "@/context/ThemeContext";
type PanelConfig = {
/** Panel content */
content: JSX.Element
content: JSX.Element;
/** Panel title shown in header */
title?: string
title?: string;
/** Fixed width (leave undefined for flex) */
width?: number
width?: number;
/** Whether this panel is currently focused */
focused?: boolean
}
focused?: boolean;
};
type LayoutProps = {
/** Top tab bar */
header?: JSX.Element
header?: JSX.Element;
/** Bottom status bar */
footer?: JSX.Element
footer?: JSX.Element;
/** Panels to display left-to-right like a file explorer */
panels: PanelConfig[]
panels: PanelConfig[];
/** Index of the currently active/focused panel */
activePanelIndex?: number
}
activePanelIndex?: number;
};
export function Layout(props: LayoutProps) {
const context = useTheme()
const panelBg = (index: number): RGBA => {
const backgrounds = context.theme.layerBackgrounds
const backgrounds = theme.layerBackgrounds;
const layers = [
backgrounds?.layer0 ?? context.theme.background,
backgrounds?.layer1 ?? context.theme.backgroundPanel,
backgrounds?.layer2 ?? context.theme.backgroundElement,
backgrounds?.layer3 ?? context.theme.backgroundMenu,
]
return layers[Math.min(index, layers.length - 1)]
}
backgrounds?.layer0 ?? theme.background,
backgrounds?.layer1 ?? theme.backgroundPanel,
backgrounds?.layer2 ?? theme.backgroundElement,
backgrounds?.layer3 ?? theme.backgroundMenu,
];
return layers[Math.min(index, layers.length - 1)];
};
const borderColor = (index: number): RGBA | string => {
const isActive = index === (props.activePanelIndex ?? 0)
const isActive = index === (props.activePanelIndex ?? 0);
return isActive
? (context.theme.accent ?? context.theme.primary)
: (context.theme.border ?? context.theme.textMuted)
}
? (theme.accent ?? theme.primary)
: (theme.border ?? theme.textMuted);
};
const { theme } = useTheme();
return (
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={context.theme.background}
backgroundColor={theme.background}
>
{/* Header - tab bar */}
<Show when={props.header}>
<box
style={{
height: 3,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
backgroundColor: theme.surface ?? theme.backgroundPanel,
}}
>
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
@@ -68,16 +67,13 @@ export function Layout(props: LayoutProps) {
</Show>
{/* Main content: side-by-side panels */}
<box
flexDirection="row"
style={{ flexGrow: 1 }}
>
<box flexDirection="row" style={{ flexGrow: 1 }}>
<For each={props.panels}>
{(panel, index) => (
<box
flexDirection="column"
border
borderColor={borderColor(index())}
borderColor={theme.border}
backgroundColor={panelBg(index())}
style={{
flexGrow: panel.width ? 0 : 1,
@@ -92,13 +88,18 @@ export function Layout(props: LayoutProps) {
height: 1,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: index() === (props.activePanelIndex ?? 0)
? (context.theme.accent ?? context.theme.primary)
: (context.theme.surface ?? context.theme.backgroundPanel),
backgroundColor:
index() === (props.activePanelIndex ?? 0)
? (theme.accent ?? theme.primary)
: (theme.surface ?? theme.backgroundPanel),
}}
>
<text
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined}
fg={
index() === (props.activePanelIndex ?? 0)
? "black"
: undefined
}
>
<strong>{panel.title}</strong>
</text>
@@ -124,14 +125,12 @@ export function Layout(props: LayoutProps) {
<box
style={{
height: 2,
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
backgroundColor: theme.surface ?? theme.backgroundPanel,
}}
>
<box style={{ padding: 1 }}>
{props.footer}
</box>
<box style={{ padding: 1 }}>{props.footer}</box>
</box>
</Show>
</box>
)
);
}

View File

@@ -1,97 +0,0 @@
/**
* MergedWaveform — unified progress bar + waveform display
*
* Shows waveform bars coloured to indicate played vs unplayed portions.
* The played section doubles as the progress indicator, replacing the
* separate progress bar. Click-to-seek is supported.
*/
import { createSignal, createEffect, onCleanup } from "solid-js";
import { getWaveformData } from "../utils/audio-waveform";
type MergedWaveformProps = {
/** Audio URL — used to generate or retrieve waveform data */
audioUrl: string;
/** Current playback position in seconds */
position: number;
/** Total duration in seconds */
duration: number;
/** Whether audio is currently playing */
isPlaying: boolean;
/** Number of data points / columns */
resolution?: number;
/** Callback when user clicks to seek */
onSeek?: (seconds: number) => void;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
export function MergedWaveform(props: MergedWaveformProps) {
const resolution = () => props.resolution ?? 64;
// Waveform data — start with sync/cached, kick off async extraction
const [data, setData] = createSignal<number[]>();
// When the audioUrl changes, attempt async extraction for real data
createEffect(() => {
const url = props.audioUrl;
const res = resolution();
if (!url) return;
let cancelled = false;
getWaveformData(url, res).then((result) => {
if (!cancelled) setData(result);
});
onCleanup(() => {
cancelled = true;
});
});
const playedRatio = () =>
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
const renderLine = () => {
const d = data();
if (!d) {
console.error("no data recieved");
return;
}
const played = Math.floor(d.length * playedRatio());
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590";
const futureColor = "#3b4252";
const playedChars = d
.slice(0, played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("");
const futureChars = d
.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 d = data();
const ratio = !d || d.length === 0 ? 0 : event.x / d.length;
const next = Math.max(
0,
Math.min(props.duration, Math.round(props.duration * ratio)),
);
props.onSeek?.(next);
};
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
);
}

View File

@@ -1,154 +0,0 @@
import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls"
import { MergedWaveform } from "./MergedWaveform"
import { RealtimeWaveform, isCavacoreAvailable } from "./RealtimeWaveform"
import { useAudio } from "../hooks/useAudio"
import { useAppStore } from "../stores/app"
import type { Episode } from "../types/episode"
type PlayerProps = {
focused: boolean
episode?: Episode | null
onExit?: () => void
}
const SAMPLE_EPISODE: Episode = {
id: "sample-ep",
podcastId: "sample-podcast",
title: "A Tour of the Productive Mind",
description: "A short guided session on building creative focus.",
audioUrl: "",
duration: 2780,
pubDate: new Date(),
}
export function Player(props: PlayerProps) {
const audio = useAudio()
// The episode to display — prefer a passed-in episode, then the
// currently-playing episode, then fall back to the sample.
const episode = () => props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE
const dur = () => audio.duration() || episode().duration || 1
useKeyboard((key: { name: string }) => {
if (!props.focused) return
if (key.name === "space") {
if (audio.currentEpisode()) {
audio.togglePlayback()
} else {
// Nothing loaded yet — start playing the displayed episode
const ep = episode()
if (ep.audioUrl) {
audio.play(ep)
}
}
return
}
if (key.name === "escape") {
props.onExit?.()
return
}
if (key.name === "left") {
audio.seekRelative(-10)
}
if (key.name === "right") {
audio.seekRelative(10)
}
if (key.name === "up") {
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))))
}
if (key.name === "down") {
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))))
}
if (key.name === "s") {
const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2))
audio.setSpeed(next)
}
})
const progressPercent = () => {
const d = dur()
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}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
</text>
</box>
{audio.error() && (
<text fg="red">{audio.error()}</text>
)}
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{episode().title}</strong>
</text>
<text fg="gray">{episode().description}</text>
{isCavacoreAvailable() ? (
<RealtimeWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
speed={audio.speed()}
onSeek={(next: number) => audio.seek(next)}
visualizerConfig={(() => {
const viz = useAppStore().state().settings.visualizer
return {
bars: viz.bars,
noiseReduction: viz.noiseReduction,
lowCutOff: viz.lowCutOff,
highCutOff: viz.highCutOff,
}
})()}
/>
) : (
<MergedWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)}
/>
)}
</box>
<PlaybackControls
isPlaying={audio.isPlaying()}
volume={audio.volume()}
speed={audio.speed()}
backendName={audio.backendName()}
hasAudioUrl={!!episode().audioUrl}
onToggle={() => {
if (audio.currentEpisode()) {
audio.togglePlayback()
} else {
const ep = episode()
if (ep.audioUrl) audio.play(ep)
}
}}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(dur())}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<text fg="gray">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
</box>
)
}

View File

@@ -1,19 +0,0 @@
import { createMemo, type JSX } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
type ResponsiveContainerProps = {
children?: (size: "small" | "medium" | "large") => JSX.Element
}
export function ResponsiveContainer(props: ResponsiveContainerProps) {
const dimensions = useTerminalDimensions()
const size = createMemo<"small" | "medium" | "large">(() => {
const width = dimensions().width
if (width < 60) return "small"
if (width < 100) return "medium"
return "large"
})
return <>{props.children?.(size())}</>
}

View File

@@ -1,75 +0,0 @@
import { Show } from "solid-js"
import { format } from "date-fns"
import type { SearchResult } from "../types/source"
import { SourceBadge } from "./SourceBadge"
type ResultDetailProps = {
result?: SearchResult
onSubscribe?: (result: SearchResult) => void
}
export function ResultDetail(props: ResultDetailProps) {
return (
<box flexDirection="column" border padding={1} gap={1} height="100%">
<Show
when={props.result}
fallback={
<text fg="gray">Select a result to see details.</text>
}
>
{(result) => (
<>
<text fg="white">
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg="gray">by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg="gray">{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
<text fg="gray">
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="cyan">[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg="green">Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
)
}

View File

@@ -1,35 +0,0 @@
import type { JSX } from "solid-js"
type RowProps = {
children?: JSX.Element
gap?: number
alignItems?: "flex-start" | "flex-end" | "center" | "stretch" | "baseline"
justifyContent?:
| "flex-start"
| "flex-end"
| "center"
| "space-between"
| "space-around"
| "space-evenly"
width?: number | "auto" | `${number}%`
height?: number | "auto" | `${number}%`
padding?: number
}
export function Row(props: RowProps) {
return (
<box
style={{
flexDirection: "row",
gap: props.gap,
alignItems: props.alignItems,
justifyContent: props.justifyContent,
width: props.width,
height: props.height,
padding: props.padding,
}}
>
{props.children}
</box>
)
}

View File

@@ -1,4 +1,4 @@
import { shortcuts } from "../config/shortcuts"
import { shortcuts } from "@/config/shortcuts";
export function ShortcutHelp() {
return (
@@ -22,5 +22,5 @@ export function ShortcutHelp() {
</box>
</box>
</box>
)
);
}

View File

@@ -1,34 +0,0 @@
import { SourceType } from "../types/source"
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 "cyan"
if (sourceType === SourceType.RSS) return "green"
if (sourceType === SourceType.CUSTOM) return "yellow"
return "gray"
}
export function SourceBadge(props: SourceBadgeProps) {
const label = () => props.sourceName || props.sourceId
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg="gray">{label()}</text>
</box>
)
}

View File

@@ -1,4 +1,4 @@
import { useTheme } from "../context/ThemeContext";
import { useTheme } from "@/context/ThemeContext";
export type TabId =
| "feed"

View File

@@ -1,19 +1,43 @@
import { Tab, type TabId } from "./Tab"
import { Tab, type TabId } from "./Tab";
type TabNavigationProps = {
activeTab: TabId
onTabSelect: (tab: TabId) => void
}
activeTab: TabId;
onTabSelect: (tab: TabId) => void;
};
export function TabNavigation(props: TabNavigationProps) {
return (
<box style={{ flexDirection: "row", gap: 1 }}>
<Tab tab={{ id: "feed", label: "Feed" }} active={props.activeTab === "feed"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "shows", label: "My Shows" }} active={props.activeTab === "shows"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "player", label: "Player" }} active={props.activeTab === "player"} onSelect={props.onTabSelect} />
<Tab tab={{ id: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />
<Tab
tab={{ id: "feed", label: "Feed" }}
active={props.activeTab === "feed"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "shows", label: "My Shows" }}
active={props.activeTab === "shows"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "discover", label: "Discover" }}
active={props.activeTab === "discover"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "search", label: "Search" }}
active={props.activeTab === "search"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "player", label: "Player" }}
active={props.activeTab === "player"}
onSelect={props.onTabSelect}
/>
<Tab
tab={{ id: "settings", label: "Settings" }}
active={props.activeTab === "settings"}
onSelect={props.onTabSelect}
/>
</box>
)
);
}

View File

@@ -1,52 +0,0 @@
type WaveformProps = {
data: number[]
position: number
duration: number
isPlaying: boolean
onSeek?: (next: number) => void
}
const bars = [".", "-", "~", "=", "#"]
export function Waveform(props: WaveformProps) {
const playedRatio = () => (props.duration === 0 ? 0 : props.position / props.duration)
const renderLine = () => {
const playedCount = Math.floor(props.data.length * playedRatio())
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
const futureColor = "#3b4252"
const played = props.data
.map((value, index) =>
index <= playedCount
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
: ""
)
.join("")
const upcoming = props.data
.map((value, index) =>
index > playedCount
? bars[Math.min(bars.length - 1, Math.floor(value * bars.length))]
: ""
)
.join("")
return (
<box flexDirection="row" gap={0}>
<text fg={playedColor}>{played || " "}</text>
<text fg={futureColor}>{upcoming || " "}</text>
</box>
)
}
const handleClick = (event: { x: number }) => {
const ratio = props.data.length === 0 ? 0 : event.x / props.data.length
const next = Math.max(0, Math.min(props.duration, Math.round(props.duration * ratio)))
props.onSeek?.(next)
}
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
)
}

View File

@@ -1,36 +1,39 @@
// Hack: Force TERM to tmux-256color when running in tmux to enable
// correct palette detection in @opentui/core
if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
process.env.TERM = "tmux-256color"
}
//if (process.env.TMUX && !process.env.TERM?.includes("tmux")) {
//process.env.TERM = "tmux-256color"
//}
import { render, useRenderer } from "@opentui/solid"
import { App } from "./App"
import { ThemeProvider } from "./context/ThemeContext"
import { ToastProvider, Toast } from "./ui/toast"
import { KeybindProvider } from "./context/KeybindContext"
import { DialogProvider } from "./ui/dialog"
import { CommandProvider } from "./ui/command"
import { render, useRenderer } from "@opentui/solid";
import { App } from "./App";
import { ThemeProvider } from "./context/ThemeContext";
import { ToastProvider, Toast } from "./ui/toast";
import { KeybindProvider } from "./context/KeybindContext";
import { DialogProvider } from "./ui/dialog";
import { CommandProvider } from "./ui/command";
function RendererSetup(props: { children: unknown }) {
const renderer = useRenderer()
renderer.disableStdoutInterception()
return props.children
const renderer = useRenderer();
renderer.disableStdoutInterception();
return props.children;
}
render(() => (
<RendererSetup>
<ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<App />
<Toast />
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</ThemeProvider>
</ToastProvider>
</RendererSetup>
), { useThread: false })
render(
() => (
<RendererSetup>
<ToastProvider>
<ThemeProvider mode="dark">
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<App />
<Toast />
</CommandProvider>
</DialogProvider>
</KeybindProvider>
</ThemeProvider>
</ToastProvider>
</RendererSetup>
),
{ useThread: false },
);

View File

@@ -2,22 +2,22 @@
* CategoryFilter component - Horizontal category filter tabs
*/
import { For } from "solid-js"
import type { DiscoverCategory } from "../stores/discover"
import { For } from "solid-js";
import type { DiscoverCategory } from "@/stores/discover";
type CategoryFilterProps = {
categories: DiscoverCategory[]
selectedCategory: string
focused: boolean
onSelect?: (categoryId: string) => void
}
categories: DiscoverCategory[];
selectedCategory: string;
focused: boolean;
onSelect?: (categoryId: string) => void;
};
export function CategoryFilter(props: CategoryFilterProps) {
return (
<box flexDirection="row" gap={1} flexWrap="wrap">
<For each={props.categories}>
{(category) => {
const isSelected = () => props.selectedCategory === category.id
const isSelected = () => props.selectedCategory === category.id;
return (
<box
@@ -32,9 +32,9 @@ export function CategoryFilter(props: CategoryFilterProps) {
{category.icon} {category.name}
</text>
</box>
)
);
}}
</For>
</box>
)
);
}

View File

@@ -2,150 +2,158 @@
* DiscoverPage component - Main discover/browse interface for PodTUI
*/
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useDiscoverStore, DISCOVER_CATEGORIES } from "../stores/discover"
import { CategoryFilter } from "./CategoryFilter"
import { TrendingShows } from "./TrendingShows"
import { createSignal } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useDiscoverStore, DISCOVER_CATEGORIES } from "@/stores/discover";
import { CategoryFilter } from "./CategoryFilter";
import { TrendingShows } from "./TrendingShows";
type DiscoverPageProps = {
focused: boolean
onExit?: () => void
}
focused: boolean;
onExit?: () => void;
};
type FocusArea = "categories" | "shows"
type FocusArea = "categories" | "shows";
export function DiscoverPage(props: DiscoverPageProps) {
const discoverStore = useDiscoverStore()
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows")
const [showIndex, setShowIndex] = createSignal(0)
const [categoryIndex, setCategoryIndex] = createSignal(0)
const discoverStore = useDiscoverStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("shows");
const [showIndex, setShowIndex] = createSignal(0);
const [categoryIndex, setCategoryIndex] = createSignal(0);
// Keyboard navigation
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const area = focusArea()
const area = focusArea();
// Tab switches focus between categories and shows
if (key.name === "tab") {
if (key.shift) {
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
} else {
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
setFocusArea((a) => (a === "categories" ? "shows" : "categories"));
}
return
return;
}
if ((key.name === "return" || key.name === "enter") && area === "categories") {
setFocusArea("shows")
return
if (
(key.name === "return" || key.name === "enter") &&
area === "categories"
) {
setFocusArea("shows");
return;
}
// Category navigation
if (area === "categories") {
if (key.name === "left" || key.name === "h") {
const nextIndex = Math.max(0, categoryIndex() - 1)
setCategoryIndex(nextIndex)
const cat = DISCOVER_CATEGORIES[nextIndex]
if (cat) discoverStore.setSelectedCategory(cat.id)
setShowIndex(0)
return
const nextIndex = Math.max(0, categoryIndex() - 1);
setCategoryIndex(nextIndex);
const cat = DISCOVER_CATEGORIES[nextIndex];
if (cat) discoverStore.setSelectedCategory(cat.id);
setShowIndex(0);
return;
}
if (key.name === "right" || key.name === "l") {
const nextIndex = Math.min(DISCOVER_CATEGORIES.length - 1, categoryIndex() + 1)
setCategoryIndex(nextIndex)
const cat = DISCOVER_CATEGORIES[nextIndex]
if (cat) discoverStore.setSelectedCategory(cat.id)
setShowIndex(0)
return
const nextIndex = Math.min(
DISCOVER_CATEGORIES.length - 1,
categoryIndex() + 1,
);
setCategoryIndex(nextIndex);
const cat = DISCOVER_CATEGORIES[nextIndex];
if (cat) discoverStore.setSelectedCategory(cat.id);
setShowIndex(0);
return;
}
if (key.name === "return" || key.name === "enter") {
// Select category and move to shows
setFocusArea("shows")
return
setFocusArea("shows");
return;
}
if (key.name === "down" || key.name === "j") {
setFocusArea("shows")
return
setFocusArea("shows");
return;
}
}
// Shows navigation
if (area === "shows") {
const shows = discoverStore.filteredPodcasts()
const shows = discoverStore.filteredPodcasts();
if (key.name === "down" || key.name === "j") {
if (shows.length === 0) return
setShowIndex((i) => Math.min(i + 1, shows.length - 1))
return
if (shows.length === 0) return;
setShowIndex((i) => Math.min(i + 1, shows.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
if (shows.length === 0) {
setFocusArea("categories")
return
setFocusArea("categories");
return;
}
const newIndex = showIndex() - 1
const newIndex = showIndex() - 1;
if (newIndex < 0) {
setFocusArea("categories")
setFocusArea("categories");
} else {
setShowIndex(newIndex)
setShowIndex(newIndex);
}
return
return;
}
if (key.name === "return" || key.name === "enter") {
// Subscribe/unsubscribe
const podcast = shows[showIndex()]
const podcast = shows[showIndex()];
if (podcast) {
discoverStore.toggleSubscription(podcast.id)
discoverStore.toggleSubscription(podcast.id);
}
return
return;
}
}
if (key.name === "escape") {
if (area === "shows") {
setFocusArea("categories")
key.stopPropagation()
setFocusArea("categories");
key.stopPropagation();
} else {
props.onExit?.()
props.onExit?.();
}
return
return;
}
// Refresh with 'r'
if (key.name === "r") {
discoverStore.refresh()
return
discoverStore.refresh();
return;
}
})
});
const handleCategorySelect = (categoryId: string) => {
discoverStore.setSelectedCategory(categoryId)
const index = DISCOVER_CATEGORIES.findIndex((c) => c.id === categoryId)
if (index >= 0) setCategoryIndex(index)
setShowIndex(0)
}
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)
setFocusArea("shows")
}
setShowIndex(index);
setFocusArea("shows");
};
const handleSubscribe = (podcast: { id: string }) => {
discoverStore.toggleSubscription(podcast.id)
}
discoverStore.toggleSubscription(podcast.id);
};
return (
<box flexDirection="column" height="100%" gap={1}>
{/* Header */}
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<text>
<strong>Discover Podcasts</strong>
</text>
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<text>
<strong>Discover Podcasts</strong>
</text>
<box flexDirection="row" gap={2}>
<text fg="gray">
{discoverStore.filteredPodcasts().length} shows
</text>
<text fg="gray">{discoverStore.filteredPodcasts().length} shows</text>
<box onMouseDown={() => discoverStore.refresh()}>
<text fg="cyan">[R] Refresh</text>
</box>
@@ -169,15 +177,14 @@ export function DiscoverPage(props: DiscoverPageProps) {
{/* Trending Shows */}
<box flexDirection="column" flexGrow={1} border>
<box padding={1}>
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
Trending in {
DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory()
)?.name ?? "All"
}
</text>
</box>
<box padding={1}>
<text fg={focusArea() === "shows" ? "cyan" : "gray"}>
Trending in{" "}
{DISCOVER_CATEGORIES.find(
(c) => c.id === discoverStore.selectedCategory(),
)?.name ?? "All"}
</text>
</box>
<TrendingShows
podcasts={discoverStore.filteredPodcasts()}
selectedIndex={showIndex()}
@@ -197,5 +204,5 @@ export function DiscoverPage(props: DiscoverPageProps) {
<text fg="gray">[R] Refresh</text>
</box>
</box>
)
);
}

View File

@@ -2,21 +2,21 @@
* PodcastCard component - Reusable card for displaying podcast info
*/
import { Show, For } from "solid-js"
import type { Podcast } from "../types/podcast"
import { Show, For } from "solid-js";
import type { Podcast } from "@/types/podcast";
type PodcastCardProps = {
podcast: Podcast
selected: boolean
compact?: boolean
onSelect?: () => void
onSubscribe?: () => void
}
podcast: Podcast;
selected: boolean;
compact?: boolean;
onSelect?: () => void;
onSubscribe?: () => void;
};
export function PodcastCard(props: PodcastCardProps) {
const handleSubscribeClick = () => {
props.onSubscribe?.()
}
props.onSubscribe?.();
};
return (
<box
@@ -51,7 +51,11 @@ export function PodcastCard(props: PodcastCardProps) {
</Show>
{/* Categories and Subscribe Button */}
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
<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)}>
@@ -69,5 +73,5 @@ export function PodcastCard(props: PodcastCardProps) {
</Show>
</box>
</box>
)
);
}

View File

@@ -2,18 +2,18 @@
* TrendingShows component - Grid/list of trending podcasts
*/
import { For, Show } from "solid-js"
import type { Podcast } from "../types/podcast"
import { PodcastCard } from "./PodcastCard"
import { For, Show } from "solid-js";
import type { Podcast } from "@/types/podcast";
import { PodcastCard } from "./PodcastCard";
type TrendingShowsProps = {
podcasts: Podcast[]
selectedIndex: number
focused: boolean
isLoading: boolean
onSelect?: (index: number) => void
onSubscribe?: (podcast: Podcast) => void
}
podcasts: Podcast[];
selectedIndex: number;
focused: boolean;
isLoading: boolean;
onSelect?: (index: number) => void;
onSubscribe?: (podcast: Podcast) => void;
};
export function TrendingShows(props: TrendingShowsProps) {
return (
@@ -47,5 +47,5 @@ export function TrendingShows(props: TrendingShowsProps) {
</scrollbox>
</Show>
</box>
)
);
}

View File

@@ -3,80 +3,80 @@
* 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 { 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";
interface FeedDetailProps {
feed: Feed
focused?: boolean
onBack?: () => void
onPlayEpisode?: (episode: Episode) => void
feed: Feed;
focused?: boolean;
onBack?: () => void;
onPlayEpisode?: (episode: Episode) => void;
}
export function FeedDetail(props: FeedDetailProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [showInfo, setShowInfo] = createSignal(true)
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()
)
}
(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)
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
if (hrs > 0) {
return `${hrs}h ${mins % 60}m`
return `${hrs}h ${mins % 60}m`;
}
return `${mins}m`
}
return `${mins}m`;
};
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
return format(date, "MMM d, yyyy");
};
const handleKeyPress = (key: { name: string }) => {
const eps = episodes()
const eps = episodes();
if (key.name === "escape" && props.onBack) {
props.onBack()
return
props.onBack();
return;
}
if (key.name === "i") {
setShowInfo((v) => !v)
return
setShowInfo((v) => !v);
return;
}
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1))
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1))
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "return" || key.name === "enter") {
const episode = eps[selectedIndex()]
const episode = eps[selectedIndex()];
if (episode && props.onPlayEpisode) {
props.onPlayEpisode(episode)
props.onPlayEpisode(episode);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0)
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(eps.length - 1)
setSelectedIndex(eps.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10))
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10))
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10));
}
}
};
useKeyboard((key) => {
if (!props.focused) return
handleKeyPress(key)
})
if (!props.focused) return;
handleKeyPress(key);
});
return (
<box flexDirection="column" gap={1}>
@@ -120,9 +120,7 @@ export function FeedDetail(props: FeedDetailProps) {
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
</text>
{props.feed.isPinned && (
<text fg="yellow">[Pinned]</text>
)}
{props.feed.isPinned && <text fg="yellow">[Pinned]</text>}
</box>
</box>
</Show>
@@ -145,9 +143,9 @@ export function FeedDetail(props: FeedDetailProps) {
padding={1}
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
onMouseDown={() => {
setSelectedIndex(index())
setSelectedIndex(index());
if (props.onPlayEpisode) {
props.onPlayEpisode(episode)
props.onPlayEpisode(episode);
}
}}
>
@@ -174,5 +172,5 @@ export function FeedDetail(props: FeedDetailProps) {
j/k to navigate, Enter to play, i to toggle info, Esc to go back
</text>
</box>
)
);
}

View File

@@ -3,54 +3,56 @@
* 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 { createSignal } from "solid-js";
import { FeedVisibility, FeedSortField } from "@/types/feed";
import type { FeedFilter } from "@/types/feed";
interface FeedFilterProps {
filter: FeedFilter
focused?: boolean
onFilterChange: (filter: FeedFilter) => void
filter: FeedFilter;
focused?: boolean;
onFilterChange: (filter: FeedFilter) => void;
}
type FilterField = "visibility" | "sort" | "pinned" | "search"
type FilterField = "visibility" | "sort" | "pinned" | "search";
export function FeedFilterComponent(props: FeedFilterProps) {
const [focusField, setFocusField] = createSignal<FilterField>("visibility")
const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "")
const [focusField, setFocusField] = createSignal<FilterField>("visibility");
const [searchValue, setSearchValue] = createSignal(
props.filter.searchQuery || "",
);
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"]
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "visibility") {
cycleVisibility()
cycleVisibility();
} else if (focusField() === "sort") {
cycleSort()
cycleSort();
} else if (focusField() === "pinned") {
togglePinned()
togglePinned();
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned()
togglePinned();
}
}
}
};
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 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[] = [
@@ -58,52 +60,54 @@ export function FeedFilterComponent(props: FeedFilterProps) {
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 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 handleSearchInput = (value: string) => {
setSearchValue(value)
props.onFilterChange({ ...props.filter, searchQuery: value })
}
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 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 "green"
if (vis === "private") return "yellow"
return "white"
}
const vis = props.filter.visibility;
if (vis === "public") return "green";
if (vis === "private") return "yellow";
return "white";
};
const sortLabel = () => {
const sort = props.filter.sortBy
const sort = props.filter.sortBy;
switch (sort) {
case "title":
return "Title"
return "Title";
case "episodeCount":
return "Episodes"
return "Episodes";
case "latestEpisode":
return "Latest"
return "Latest";
case "updated":
default:
return "Updated"
return "Updated";
}
}
};
return (
<box flexDirection="column" border padding={1} gap={1}>
@@ -119,7 +123,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>
Show:
</text>
<text fg={visibilityColor()}>{visibilityLabel()}</text>
</box>
</box>
@@ -143,7 +149,9 @@ export function FeedFilterComponent(props: FeedFilterProps) {
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
>
<box flexDirection="row" gap={1}>
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>
Pinned:
</text>
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</text>
@@ -165,5 +173,5 @@ export function FeedFilterComponent(props: FeedFilterProps) {
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
</box>
)
);
}

View File

@@ -3,39 +3,39 @@
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "../types/feed"
import { format } from "date-fns"
import type { Feed, FeedVisibility } from "@/types/feed";
import { format } from "date-fns";
interface FeedItemProps {
feed: Feed
isSelected: boolean
showEpisodeCount?: boolean
showLastUpdated?: boolean
compact?: boolean
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")
}
return format(date, "MMM d");
};
const episodeCount = () => props.feed.episodes.length
const episodeCount = () => props.feed.episodes.length;
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length
}
return props.feed.episodes.length;
};
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]"
}
return props.feed.visibility === "public" ? "[P]" : "[*]";
};
const visibilityColor = () => {
return props.feed.visibility === "public" ? "green" : "yellow"
}
return props.feed.visibility === "public" ? "green" : "yellow";
};
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " "
}
return props.feed.isPinned ? "*" : " ";
};
if (props.compact) {
// Compact single-line view
@@ -54,11 +54,9 @@ export function FeedItem(props: FeedItemProps) {
<text fg={props.isSelected ? "white" : undefined}>
{props.feed.customName || props.feed.podcast.title}
</text>
{props.showEpisodeCount && (
<text fg="gray">({episodeCount()})</text>
)}
{props.showEpisodeCount && <text fg="gray">({episodeCount()})</text>}
</box>
)
);
}
// Full view with details
@@ -105,5 +103,5 @@ export function FeedItem(props: FeedItemProps) {
</box>
)}
</box>
)
);
}

View File

@@ -3,87 +3,87 @@
* 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 { 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";
interface FeedListProps {
focused?: boolean
compact?: boolean
showEpisodeCount?: boolean
showLastUpdated?: boolean
onSelectFeed?: (feed: Feed) => void
onOpenFeed?: (feed: Feed) => void
onFocusChange?: (focused: boolean) => void
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 feedStore = useFeedStore()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const filteredFeeds = () => feedStore.getFilteredFeeds()
const filteredFeeds = () => feedStore.getFilteredFeeds();
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false)
return
props.onFocusChange?.(false);
return;
}
const feeds = filteredFeeds()
const feeds = filteredFeeds();
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1))
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1))
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1));
} else if (key.name === "return" || key.name === "enter") {
const feed = feeds[selectedIndex()]
const feed = feeds[selectedIndex()];
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed)
props.onOpenFeed(feed);
}
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0)
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1)
setSelectedIndex(feeds.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5))
setSelectedIndex((i) => Math.max(0, i - 5));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5));
} else if (key.name === "p") {
// Toggle pin on selected feed
const feed = feeds[selectedIndex()]
const feed = feeds[selectedIndex()];
if (feed) {
feedStore.togglePinned(feed.id)
feedStore.togglePinned(feed.id);
}
} else if (key.name === "f") {
// Cycle visibility filter
cycleVisibilityFilter()
cycleVisibilityFilter();
} else if (key.name === "s") {
// Cycle sort
cycleSortField()
cycleSortField();
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()]
const selectedFeed = feeds[selectedIndex()];
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed)
props.onSelectFeed(selectedFeed);
}
}
};
useKeyboard((key) => {
if (!props.focused) return
handleKeyPress(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 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[] = [
@@ -91,45 +91,49 @@ export function FeedList(props: FeedListProps) {
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 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 vis = feedStore.filter().visibility;
if (vis === "all") return "All";
if (vis === "public") return "Public";
return "Private";
};
const sortLabel = () => {
const sort = feedStore.filter().sortBy
const sort = feedStore.filter().sortBy;
switch (sort) {
case "title": return "Title"
case "episodeCount": return "Episodes"
case "latestEpisode": return "Latest"
default: return "Updated"
case "title":
return "Title";
case "episodeCount":
return "Episodes";
case "latestEpisode":
return "Latest";
default:
return "Updated";
}
}
};
const handleFeedClick = (feed: Feed, index: number) => {
setSelectedIndex(index)
setSelectedIndex(index);
if (props.onSelectFeed) {
props.onSelectFeed(feed)
props.onSelectFeed(feed);
}
}
};
const handleFeedDoubleClick = (feed: Feed) => {
if (props.onOpenFeed) {
props.onOpenFeed(feed)
props.onOpenFeed(feed);
}
}
};
return (
<box flexDirection="column" gap={1}>
<box flexDirection="column" gap={1}>
{/* Header with filter controls */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
<text>
@@ -137,18 +141,10 @@ export function FeedList(props: FeedListProps) {
</text>
<text fg="gray">({filteredFeeds().length} feeds)</text>
<box flexDirection="row" gap={1}>
<box
border
padding={0}
onMouseDown={cycleVisibilityFilter}
>
<box border padding={0} onMouseDown={cycleVisibilityFilter}>
<text fg="cyan">[f] {visibilityLabel()}</text>
</box>
<box
border
padding={0}
onMouseDown={cycleSortField}
>
<box border padding={0} onMouseDown={cycleSortField}>
<text fg="cyan">[s] {sortLabel()}</text>
</box>
</box>
@@ -189,5 +185,5 @@ export function FeedList(props: FeedListProps) {
</text>
</box>
</box>
)
);
}

View File

@@ -3,69 +3,69 @@
* Reverse chronological order, like an inbox/timeline
*/
import { createSignal, For, Show } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useFeedStore } from "../stores/feed"
import { format } from "date-fns"
import type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
import { createSignal, For, Show } from "solid-js";
import { useKeyboard } from "@opentui/solid";
import { useFeedStore } from "@/stores/feed";
import { format } from "date-fns";
import type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
type FeedPageProps = {
focused: boolean
onPlayEpisode?: (episode: Episode, feed: Feed) => void
onExit?: () => void
}
focused: boolean;
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
onExit?: () => void;
};
export function FeedPage(props: FeedPageProps) {
const feedStore = useFeedStore()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
const feedStore = useFeedStore();
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false);
const allEpisodes = () => feedStore.getAllEpisodesChronological()
const allEpisodes = () => feedStore.getAllEpisodesChronological();
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
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`
}
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 handleRefresh = async () => {
setIsRefreshing(true)
await feedStore.refreshAllFeeds()
setIsRefreshing(false)
}
setIsRefreshing(true);
await feedStore.refreshAllFeeds();
setIsRefreshing(false);
};
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const episodes = allEpisodes()
const episodes = allEpisodes();
if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1))
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1));
} else if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1))
setSelectedIndex((i) => Math.max(0, i - 1));
} else if (key.name === "return" || key.name === "enter") {
const item = episodes[selectedIndex()]
if (item) props.onPlayEpisode?.(item.episode, item.feed)
const item = episodes[selectedIndex()];
if (item) props.onPlayEpisode?.(item.episode, item.feed);
} else if (key.name === "home" || key.name === "g") {
setSelectedIndex(0)
setSelectedIndex(0);
} else if (key.name === "end") {
setSelectedIndex(episodes.length - 1)
setSelectedIndex(episodes.length - 1);
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 10))
setSelectedIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10))
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10));
} else if (key.name === "r") {
handleRefresh()
handleRefresh();
} else if (key.name === "escape") {
props.onExit?.()
props.onExit?.();
}
})
});
return (
<box flexDirection="column" height="100%">
@@ -95,7 +95,9 @@ export function FeedPage(props: FeedPageProps) {
paddingRight={1}
paddingTop={0}
paddingBottom={0}
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
backgroundColor={
index() === selectedIndex() ? "#333" : undefined
}
onMouseDown={() => setSelectedIndex(index())}
>
<box flexDirection="row" gap={1}>
@@ -117,5 +119,5 @@ export function FeedPage(props: FeedPageProps) {
</scrollbox>
</Show>
</box>
)
);
}

View File

@@ -4,208 +4,218 @@
* Right panel: episodes for the selected show
*/
import { createSignal, For, Show, createMemo, createEffect } 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 type { Episode } from "../types/episode"
import type { Feed } from "../types/feed"
import { createSignal, For, Show, createMemo, createEffect } 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 type { Episode } from "@/types/episode";
import type { Feed } from "@/types/feed";
type MyShowsPageProps = {
focused: boolean
onPlayEpisode?: (episode: Episode, feed: Feed) => void
onExit?: () => void
}
focused: boolean;
onPlayEpisode?: (episode: Episode, feed: Feed) => void;
onExit?: () => void;
};
type FocusPane = "shows" | "episodes"
type FocusPane = "shows" | "episodes";
export function MyShowsPage(props: MyShowsPageProps) {
const feedStore = useFeedStore()
const downloadStore = useDownloadStore()
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
const [showIndex, setShowIndex] = createSignal(0)
const [episodeIndex, setEpisodeIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
const feedStore = useFeedStore();
const downloadStore = useDownloadStore();
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows");
const [showIndex, setShowIndex] = createSignal(0);
const [episodeIndex, setEpisodeIndex] = createSignal(0);
const [isRefreshing, setIsRefreshing] = createSignal(false);
/** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5
const LOAD_MORE_THRESHOLD = 5;
const shows = () => feedStore.getFilteredFeeds()
const shows = () => feedStore.getFilteredFeeds();
const selectedShow = createMemo(() => {
const s = shows()
const idx = showIndex()
return idx < s.length ? s[idx] : undefined
})
const s = shows();
const idx = showIndex();
return idx < s.length ? s[idx] : undefined;
});
const episodes = createMemo(() => {
const show = selectedShow()
if (!show) return []
const show = selectedShow();
if (!show) return [];
return [...show.episodes].sort(
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
)
})
(a, b) => b.pubDate.getTime() - a.pubDate.getTime(),
);
});
// Detect when user navigates near the bottom and load more episodes
createEffect(() => {
const idx = episodeIndex()
const eps = episodes()
const show = selectedShow()
if (!show || eps.length === 0) return
const idx = episodeIndex();
const eps = episodes();
const show = selectedShow();
if (!show || eps.length === 0) return;
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) {
feedStore.loadMoreEpisodes(show.id)
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD;
if (
nearBottom &&
feedStore.hasMoreEpisodes(show.id) &&
!feedStore.isLoadingMore()
) {
feedStore.loadMoreEpisodes(show.id);
}
})
});
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
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`
}
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)
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "[Q]"
return "[Q]";
case DownloadStatus.DOWNLOADING: {
const pct = downloadStore.getDownloadProgress(episodeId)
return `[${pct}%]`
const pct = downloadStore.getDownloadProgress(episodeId);
return `[${pct}%]`;
}
case DownloadStatus.COMPLETED:
return "[DL]"
return "[DL]";
case DownloadStatus.FAILED:
return "[ERR]"
return "[ERR]";
default:
return ""
return "";
}
}
};
/** Get download status color */
const downloadColor = (episodeId: string): string => {
const status = downloadStore.getDownloadStatus(episodeId)
const status = downloadStore.getDownloadStatus(episodeId);
switch (status) {
case DownloadStatus.QUEUED:
return "yellow"
return "yellow";
case DownloadStatus.DOWNLOADING:
return "cyan"
return "cyan";
case DownloadStatus.COMPLETED:
return "green"
return "green";
case DownloadStatus.FAILED:
return "red"
return "red";
default:
return "gray"
return "gray";
}
}
};
const handleRefresh = async () => {
const show = selectedShow()
if (!show) return
setIsRefreshing(true)
await feedStore.refreshFeed(show.id)
setIsRefreshing(false)
}
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)
}
const show = selectedShow();
if (!show) return;
feedStore.removeFeed(show.id);
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
};
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const pane = focusPane()
const pane = focusPane();
// Navigate between panes
if (key.name === "right" || key.name === "l") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
}
return
return;
}
if (key.name === "left" || key.name === "h") {
if (pane === "episodes") {
setFocusPane("shows")
setFocusPane("shows");
}
return
return;
}
if (key.name === "tab") {
if (pane === "shows" && selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
} else {
setFocusPane("shows")
setFocusPane("shows");
}
return
return;
}
if (pane === "shows") {
const s = shows()
const s = shows();
if (key.name === "down" || key.name === "j") {
setShowIndex((i) => Math.min(s.length - 1, i + 1))
setEpisodeIndex(0)
setShowIndex((i) => Math.min(s.length - 1, i + 1));
setEpisodeIndex(0);
} else if (key.name === "up" || key.name === "k") {
setShowIndex((i) => Math.max(0, i - 1))
setEpisodeIndex(0)
setShowIndex((i) => Math.max(0, i - 1));
setEpisodeIndex(0);
} else if (key.name === "return" || key.name === "enter") {
if (selectedShow()) {
setFocusPane("episodes")
setEpisodeIndex(0)
setFocusPane("episodes");
setEpisodeIndex(0);
}
} else if (key.name === "d") {
handleUnsubscribe()
handleUnsubscribe();
} else if (key.name === "r") {
handleRefresh()
handleRefresh();
} else if (key.name === "escape") {
props.onExit?.()
props.onExit?.();
}
} else if (pane === "episodes") {
const eps = episodes()
const eps = episodes();
if (key.name === "down" || key.name === "j") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1))
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1));
} else if (key.name === "up" || key.name === "k") {
setEpisodeIndex((i) => Math.max(0, i - 1))
setEpisodeIndex((i) => Math.max(0, i - 1));
} else if (key.name === "return" || key.name === "enter") {
const ep = eps[episodeIndex()]
const show = selectedShow()
if (ep && show) props.onPlayEpisode?.(ep, show)
const ep = eps[episodeIndex()];
const show = selectedShow();
if (ep && show) props.onPlayEpisode?.(ep, show);
} else if (key.name === "d") {
const ep = eps[episodeIndex()]
const show = selectedShow()
const ep = eps[episodeIndex()];
const show = selectedShow();
if (ep && show) {
const status = downloadStore.getDownloadStatus(ep.id)
if (status === DownloadStatus.NONE || status === DownloadStatus.FAILED) {
downloadStore.startDownload(ep, show.id)
} else if (status === DownloadStatus.DOWNLOADING || status === DownloadStatus.QUEUED) {
downloadStore.cancelDownload(ep.id)
const status = downloadStore.getDownloadStatus(ep.id);
if (
status === DownloadStatus.NONE ||
status === DownloadStatus.FAILED
) {
downloadStore.startDownload(ep, show.id);
} else if (
status === DownloadStatus.DOWNLOADING ||
status === DownloadStatus.QUEUED
) {
downloadStore.cancelDownload(ep.id);
}
}
} else if (key.name === "pageup") {
setEpisodeIndex((i) => Math.max(0, i - 10))
setEpisodeIndex((i) => Math.max(0, i - 10));
} else if (key.name === "pagedown") {
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10))
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10));
} else if (key.name === "r") {
handleRefresh()
handleRefresh();
} else if (key.name === "escape") {
setFocusPane("shows")
key.stopPropagation()
setFocusPane("shows");
key.stopPropagation();
}
}
})
});
return {
showsPanel: () => (
@@ -223,7 +233,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}>
<scrollbox
height="100%"
focused={props.focused && focusPane() === "shows"}
>
<For each={shows()}>
{(feed, index) => (
<box
@@ -233,8 +246,8 @@ export function MyShowsPage(props: MyShowsPageProps) {
paddingRight={1}
backgroundColor={index() === showIndex() ? "#333" : undefined}
onMouseDown={() => {
setShowIndex(index())
setEpisodeIndex(0)
setShowIndex(index());
setEpisodeIndex(0);
}}
>
<text fg={index() === showIndex() ? "cyan" : "gray"}>
@@ -270,7 +283,10 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box>
}
>
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}>
<scrollbox
height="100%"
focused={props.focused && focusPane() === "episodes"}
>
<For each={episodes()}>
{(episode, index) => (
<box
@@ -278,15 +294,21 @@ export function MyShowsPage(props: MyShowsPageProps) {
gap={0}
paddingLeft={1}
paddingRight={1}
backgroundColor={index() === episodeIndex() ? "#333" : undefined}
backgroundColor={
index() === episodeIndex() ? "#333" : undefined
}
onMouseDown={() => setEpisodeIndex(index())}
>
<box flexDirection="row" gap={1}>
<text fg={index() === episodeIndex() ? "cyan" : "gray"}>
{index() === episodeIndex() ? ">" : " "}
</text>
<text fg={index() === episodeIndex() ? "white" : undefined}>
{episode.episodeNumber ? `#${episode.episodeNumber} ` : ""}
<text
fg={index() === episodeIndex() ? "white" : undefined}
>
{episode.episodeNumber
? `#${episode.episodeNumber} `
: ""}
{episode.title}
</text>
</box>
@@ -294,7 +316,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="gray">{formatDate(episode.pubDate)}</text>
<text fg="gray">{formatDuration(episode.duration)}</text>
<Show when={downloadLabel(episode.id)}>
<text fg={downloadColor(episode.id)}>{downloadLabel(episode.id)}</text>
<text fg={downloadColor(episode.id)}>
{downloadLabel(episode.id)}
</text>
</Show>
</box>
</box>
@@ -305,7 +329,13 @@ export function MyShowsPage(props: MyShowsPageProps) {
<text fg="yellow">Loading more episodes...</text>
</box>
</Show>
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
<Show
when={
!feedStore.isLoadingMore() &&
selectedShow() &&
feedStore.hasMoreEpisodes(selectedShow()!.id)
}
>
<box paddingLeft={2} paddingTop={1}>
<text fg="gray">Scroll down for more episodes</text>
</box>
@@ -318,5 +348,5 @@ export function MyShowsPage(props: MyShowsPageProps) {
focusPane,
selectedShow,
}
};
}

147
src/tabs/Player/Player.tsx Normal file
View File

@@ -0,0 +1,147 @@
import { useKeyboard } from "@opentui/solid";
import { PlaybackControls } from "./PlaybackControls";
import { RealtimeWaveform } from "./RealtimeWaveform";
import { useAudio } from "@/hooks/useAudio";
import { useAppStore } from "@/stores/app";
import type { Episode } from "@/types/episode";
type PlayerProps = {
focused: boolean;
episode?: Episode | null;
onExit?: () => void;
};
const SAMPLE_EPISODE: Episode = {
id: "sample-ep",
podcastId: "sample-podcast",
title: "A Tour of the Productive Mind",
description: "A short guided session on building creative focus.",
audioUrl: "",
duration: 2780,
pubDate: new Date(),
};
export function Player(props: PlayerProps) {
const audio = useAudio();
// The episode to display — prefer a passed-in episode, then the
// currently-playing episode, then fall back to the sample.
const episode = () =>
props.episode ?? audio.currentEpisode() ?? SAMPLE_EPISODE;
const dur = () => audio.duration() || episode().duration || 1;
useKeyboard((key: { name: string }) => {
if (!props.focused) return;
if (key.name === "space") {
if (audio.currentEpisode()) {
audio.togglePlayback();
} else {
// Nothing loaded yet — start playing the displayed episode
const ep = episode();
if (ep.audioUrl) {
audio.play(ep);
}
}
return;
}
if (key.name === "escape") {
props.onExit?.();
return;
}
if (key.name === "left") {
audio.seekRelative(-10);
}
if (key.name === "right") {
audio.seekRelative(10);
}
if (key.name === "up") {
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))));
}
if (key.name === "down") {
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))));
}
if (key.name === "s") {
const next =
audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2));
audio.setSpeed(next);
}
});
const progressPercent = () => {
const d = dur();
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}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())} (
{progressPercent()}%)
</text>
</box>
{audio.error() && <text fg="red">{audio.error()}</text>}
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{episode().title}</strong>
</text>
<text fg="gray">{episode().description}</text>
<RealtimeWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
speed={audio.speed()}
onSeek={(next: number) => audio.seek(next)}
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={!!episode().audioUrl}
onToggle={() => {
if (audio.currentEpisode()) {
audio.togglePlayback();
} else {
const ep = episode();
if (ep.audioUrl) audio.play(ep);
}
}}
onPrev={() => audio.seek(0)}
onNext={() => audio.seek(dur())}
onSpeedChange={(s: number) => audio.setSpeed(s)}
onVolumeChange={(v: number) => audio.setVolume(v)}
/>
<text fg="gray">
Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc
back
</text>
</box>
);
}

View File

@@ -1,86 +1,97 @@
/**
* RealtimeWaveform live audio frequency visualization using cavacore.
*
* Replaces MergedWaveform during playback. Spawns an independent ffmpeg
* 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.
*
* Falls back gracefully if cavacore is unavailable (loadCavaCore returns null).
* Same prop interface as MergedWaveform for drop-in replacement.
*/
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 { createSignal, createEffect, onCleanup, on, untrack } from "solid-js";
import {
loadCavaCore,
type CavaCore,
type CavaCoreConfig,
} from "@/utils/cavacore";
import { AudioStreamReader } from "@/utils/audio-stream-reader";
// ── Types ────────────────────────────────────────────────────────────
export type RealtimeWaveformProps = {
/** Audio URL — used to start the ffmpeg decode stream */
audioUrl: string
audioUrl: string;
/** Current playback position in seconds */
position: number
position: number;
/** Total duration in seconds */
duration: number
duration: number;
/** Whether audio is currently playing */
isPlaying: boolean
isPlaying: boolean;
/** Playback speed multiplier (default: 1) */
speed?: number
speed?: number;
/** Number of frequency bars / columns */
resolution?: number
resolution?: number;
/** Callback when user clicks to seek */
onSeek?: (seconds: number) => void
onSeek?: (seconds: number) => void;
/** Visualizer configuration overrides */
visualizerConfig?: Partial<CavaCoreConfig>
}
visualizerConfig?: Partial<CavaCoreConfig>;
};
/** Unicode lower block elements: space (silence) through full block (max) */
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]
const BARS = [
" ",
"\u2581",
"\u2582",
"\u2583",
"\u2584",
"\u2585",
"\u2586",
"\u2587",
"\u2588",
];
/** Target frame interval in ms (~30 fps) */
const FRAME_INTERVAL = 33
const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512
const SAMPLES_PER_FRAME = 512;
// ── Component ────────────────────────────────────────────────────────
export function RealtimeWaveform(props: RealtimeWaveformProps) {
const resolution = () => props.resolution ?? 32
const resolution = () => props.resolution ?? 32;
// Frequency bar values (0.01.0 per bar)
const [barData, setBarData] = createSignal<number[]>([])
const [barData, setBarData] = createSignal<number[]>([]);
// Track whether cavacore is available
const [available, setAvailable] = createSignal(false)
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
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
if (cava) return true;
cava = loadCavaCore()
cava = loadCavaCore();
if (!cava) {
setAvailable(false)
return false
setAvailable(false);
return false;
}
setAvailable(true)
return true
}
setAvailable(true);
return true;
};
// ── Start/stop the visualization pipeline ──────────────────────────
const startVisualization = (url: string, position: number, speed: number) => {
stopVisualization()
stopVisualization();
if (!url || !initCava() || !cava) return
if (!url || !initCava() || !cava) return;
// Initialize cavacore with current resolution + any overrides
const config: CavaCoreConfig = {
@@ -88,56 +99,57 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
sampleRate: 44100,
channels: 1,
...props.visualizerConfig,
}
cava.init(config)
};
cava.init(config);
// Pre-allocate sample read buffer
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME)
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 })
if (reader) reader.stop();
reader = new AudioStreamReader({ url });
}
reader.start(position, speed)
reader.start(position, speed);
// Start render loop
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
}
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
const stopVisualization = () => {
if (frameTimer) {
clearInterval(frameTimer)
frameTimer = null
clearInterval(frameTimer);
frameTimer = null;
}
if (reader) {
reader.stop()
reader.stop();
// Don't null reader — we reuse it across start/stop cycles
}
if (cava?.isReady) {
cava.destroy()
cava.destroy();
}
sampleBuffer = null
}
sampleBuffer = null;
};
// ── Render loop (called at ~30fps) ─────────────────────────────────
const renderFrame = () => {
if (!cava?.isReady || !reader?.running || !sampleBuffer) return
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
// Read available PCM samples from the stream
const count = reader.read(sampleBuffer)
if (count === 0) return
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)
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))
}
setBarData(Array.from(output));
};
// ── Single unified effect: respond to all prop changes ─────────────
//
@@ -159,14 +171,14 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
],
([playing, url, speed]) => {
if (playing && url) {
const pos = untrack(() => props.position)
startVisualization(url, pos, speed)
const pos = untrack(() => props.position);
startVisualization(url, pos, speed);
} else {
stopVisualization()
stopVisualization();
}
},
),
)
);
// ── Seek detection: lightweight effect for position jumps ──────────
//
@@ -175,107 +187,94 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
// 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
let lastSyncPosition = 0;
createEffect(
on(
() => props.position,
(pos) => {
if (!props.isPlaying || !reader?.running) {
lastSyncPosition = pos
return
lastSyncPosition = pos;
return;
}
const delta = Math.abs(pos - lastSyncPosition)
lastSyncPosition = pos
const delta = Math.abs(pos - lastSyncPosition);
lastSyncPosition = pos;
if (delta > 2) {
const speed = props.speed ?? 1
reader.restart(pos, speed)
const speed = props.speed ?? 1;
reader.restart(pos, speed);
}
},
),
)
);
// Cleanup on unmount
onCleanup(() => {
stopVisualization()
stopVisualization();
if (reader) {
reader.stop()
reader = null
reader.stop();
reader = null;
}
// Don't null cava itself — it can be reused. But do destroy its plan.
if (cava?.isReady) {
cava.destroy()
cava.destroy();
}
})
});
// ── Rendering ──────────────────────────────────────────────────────
const playedRatio = () =>
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration)
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration);
const renderLine = () => {
const bars = barData()
const numBars = resolution()
const bars = barData();
const numBars = resolution();
// If no data yet, show empty placeholder
if (bars.length === 0) {
const placeholder = ".".repeat(numBars)
const placeholder = ".".repeat(numBars);
return (
<box flexDirection="row" gap={0}>
<text fg="#3b4252">{placeholder}</text>
</box>
)
);
}
const played = Math.floor(numBars * playedRatio())
const playedColor = props.isPlaying ? "#6fa8ff" : "#7d8590"
const futureColor = "#3b4252"
const played = Math.floor(numBars * playedRatio());
const playedColor = props.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("")
.join("");
const futureChars = bars
.slice(played)
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
.join("")
.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 = resolution()
const ratio = numBars === 0 ? 0 : event.x / numBars
const numBars = resolution();
const ratio = numBars === 0 ? 0 : event.x / numBars;
const next = Math.max(
0,
Math.min(props.duration, Math.round(props.duration * ratio)),
)
props.onSeek?.(next)
}
);
props.onSeek?.(next);
};
return (
<box border padding={1} onMouseDown={handleClick}>
{renderLine()}
</box>
)
}
/**
* Check if cavacore is available on this system.
* Useful for deciding whether to show RealtimeWaveform or MergedWaveform.
*/
let _cavacoreAvailable: boolean | null = null
export function isCavacoreAvailable(): boolean {
if (_cavacoreAvailable === null) {
const cava = loadCavaCore()
_cavacoreAvailable = cava !== null
}
return _cavacoreAvailable
);
}

View File

@@ -1,16 +1,16 @@
import { Show } from "solid-js"
import type { SearchResult } from "../types/source"
import { SourceBadge } from "./SourceBadge"
import { Show } from "solid-js";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultCardProps = {
result: SearchResult
selected: boolean
onSelect: () => void
onSubscribe?: () => void
}
result: SearchResult;
selected: boolean;
onSelect: () => void;
onSubscribe?: () => void;
};
export function ResultCard(props: ResultCardProps) {
const podcast = () => props.result.podcast
const podcast = () => props.result.podcast;
return (
<box
@@ -21,7 +21,11 @@ export function ResultCard(props: ResultCardProps) {
backgroundColor={props.selected ? "#222" : undefined}
onMouseDown={props.onSelect}
>
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={props.selected ? "cyan" : "white"}>
<strong>{podcast().title}</strong>
@@ -67,13 +71,13 @@ export function ResultCard(props: ResultCardProps) {
paddingRight={1}
width={18}
onMouseDown={(event) => {
event.stopPropagation?.()
props.onSubscribe?.()
event.stopPropagation?.();
props.onSubscribe?.();
}}
>
<text fg="cyan">[+] Add to Feeds</text>
</box>
</Show>
</box>
)
);
}

View File

@@ -0,0 +1,73 @@
import { Show } from "solid-js";
import { format } from "date-fns";
import type { SearchResult } from "@/types/source";
import { SourceBadge } from "./SourceBadge";
type ResultDetailProps = {
result?: SearchResult;
onSubscribe?: (result: SearchResult) => void;
};
export function ResultDetail(props: ResultDetailProps) {
return (
<box flexDirection="column" border padding={1} gap={1} height="100%">
<Show
when={props.result}
fallback={<text fg="gray">Select a result to see details.</text>}
>
{(result) => (
<>
<text fg="white">
<strong>{result().podcast.title}</strong>
</text>
<SourceBadge
sourceId={result().sourceId}
sourceName={result().sourceName}
sourceType={result().sourceType}
/>
<Show when={result().podcast.author}>
<text fg="gray">by {result().podcast.author}</text>
</Show>
<Show when={result().podcast.description}>
<text fg="gray">{result().podcast.description}</text>
</Show>
<Show when={(result().podcast.categories ?? []).length > 0}>
<box flexDirection="row" gap={1}>
{(result().podcast.categories ?? []).map((category) => (
<text fg="yellow">[{category}]</text>
))}
</box>
</Show>
<text fg="gray">Feed: {result().podcast.feedUrl}</text>
<text fg="gray">
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="cyan">[+] Add to Feeds</text>
</box>
</Show>
<Show when={result().podcast.isSubscribed}>
<text fg="green">Already subscribed</text>
</Show>
</>
)}
</Show>
</box>
);
}

View File

@@ -2,171 +2,171 @@
* SearchPage component - Main search interface for PodTUI
*/
import { createSignal, createEffect, Show } 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 { createSignal, createEffect, Show } 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";
type SearchPageProps = {
focused: boolean
onSubscribe?: (result: SearchResult) => void
onInputFocusChange?: (focused: boolean) => void
onExit?: () => void
}
focused: boolean;
onSubscribe?: (result: SearchResult) => void;
onInputFocusChange?: (focused: boolean) => void;
onExit?: () => void;
};
type FocusArea = "input" | "results" | "history"
type FocusArea = "input" | "results" | "history";
export function SearchPage(props: SearchPageProps) {
const searchStore = useSearchStore()
const [focusArea, setFocusArea] = createSignal<FocusArea>("input")
const [inputValue, setInputValue] = createSignal("")
const [resultIndex, setResultIndex] = createSignal(0)
const [historyIndex, setHistoryIndex] = createSignal(0)
const searchStore = useSearchStore();
const [focusArea, setFocusArea] = createSignal<FocusArea>("input");
const [inputValue, setInputValue] = createSignal("");
const [resultIndex, setResultIndex] = createSignal(0);
const [historyIndex, setHistoryIndex] = createSignal(0);
// Keep parent informed about input focus state
createEffect(() => {
const isInputFocused = props.focused && focusArea() === "input"
props.onInputFocusChange?.(isInputFocused)
})
const isInputFocused = props.focused && focusArea() === "input";
props.onInputFocusChange?.(isInputFocused);
});
const handleSearch = async () => {
const query = inputValue().trim()
const query = inputValue().trim();
if (query) {
await searchStore.search(query)
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results")
setResultIndex(0)
setFocusArea("results");
setResultIndex(0);
}
}
}
};
const handleHistorySelect = async (query: string) => {
setInputValue(query)
await searchStore.search(query)
setInputValue(query);
await searchStore.search(query);
if (searchStore.results().length > 0) {
setFocusArea("results")
setResultIndex(0)
setFocusArea("results");
setResultIndex(0);
}
}
};
const handleResultSelect = (result: SearchResult) => {
props.onSubscribe?.(result)
searchStore.markSubscribed(result.podcast.id)
}
props.onSubscribe?.(result);
searchStore.markSubscribed(result.podcast.id);
};
// Keyboard navigation
useKeyboard((key) => {
if (!props.focused) return
if (!props.focused) return;
const area = focusArea()
const area = focusArea();
// Enter to search from input
if ((key.name === "return" || key.name === "enter") && area === "input") {
handleSearch()
return
handleSearch();
return;
}
// Tab to cycle focus areas
if (key.name === "tab" && !key.shift) {
if (area === "input") {
if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
} else if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
}
} else if (area === "results") {
if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
} else {
setFocusArea("input")
setFocusArea("input");
}
} else {
setFocusArea("input")
setFocusArea("input");
}
return
return;
}
if (key.name === "tab" && key.shift) {
if (area === "input") {
if (searchStore.history().length > 0) {
setFocusArea("history")
setFocusArea("history");
} else if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
}
} else if (area === "history") {
if (searchStore.results().length > 0) {
setFocusArea("results")
setFocusArea("results");
} else {
setFocusArea("input")
setFocusArea("input");
}
} else {
setFocusArea("input")
setFocusArea("input");
}
return
return;
}
// Up/Down for results and history
if (area === "results") {
const results = searchStore.results()
const results = searchStore.results();
if (key.name === "down" || key.name === "j") {
setResultIndex((i) => Math.min(i + 1, results.length - 1))
return
setResultIndex((i) => Math.min(i + 1, results.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setResultIndex((i) => Math.max(i - 1, 0))
return
setResultIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const result = results[resultIndex()]
if (result) handleResultSelect(result)
return
const result = results[resultIndex()];
if (result) handleResultSelect(result);
return;
}
}
if (area === "history") {
const history = searchStore.history()
const history = searchStore.history();
if (key.name === "down" || key.name === "j") {
setHistoryIndex((i) => Math.min(i + 1, history.length - 1))
return
setHistoryIndex((i) => Math.min(i + 1, history.length - 1));
return;
}
if (key.name === "up" || key.name === "k") {
setHistoryIndex((i) => Math.max(i - 1, 0))
return
setHistoryIndex((i) => Math.max(i - 1, 0));
return;
}
if (key.name === "return" || key.name === "enter") {
const query = history[historyIndex()]
if (query) handleHistorySelect(query)
return
const query = history[historyIndex()];
if (query) handleHistorySelect(query);
return;
}
}
// Escape goes back to input or up one level
if (key.name === "escape") {
if (area === "input") {
props.onExit?.()
props.onExit?.();
} else {
setFocusArea("input")
key.stopPropagation()
setFocusArea("input");
key.stopPropagation();
}
return
return;
}
// "/" focuses search input
if (key.name === "/" && area !== "input") {
setFocusArea("input")
return
setFocusArea("input");
return;
}
})
});
return (
<box flexDirection="column" height="100%" gap={1}>
{/* Search Header */}
<box flexDirection="column" gap={1}>
<text>
<strong>Search Podcasts</strong>
</text>
<text>
<strong>Search Podcasts</strong>
</text>
{/* Search Input */}
<box flexDirection="row" gap={1} alignItems="center">
@@ -174,7 +174,7 @@ export function SearchPage(props: SearchPageProps) {
<input
value={inputValue()}
onInput={(value) => {
setInputValue(value)
setInputValue(value);
}}
placeholder="Enter podcast name, topic, or author..."
focused={props.focused && focusArea() === "input"}
@@ -234,17 +234,17 @@ export function SearchPage(props: SearchPageProps) {
</box>
{/* History Sidebar */}
<box width={30} border>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={focusArea() === "history"}
<box width={30} border>
<box padding={1} flexDirection="column">
<box paddingBottom={1}>
<text fg={focusArea() === "history" ? "cyan" : "gray"}>
History
</text>
</box>
<SearchHistory
history={searchStore.history()}
selectedIndex={historyIndex()}
focused={focusArea() === "history"}
onSelect={handleHistorySelect}
onRemove={searchStore.removeFromHistory}
onClear={searchStore.clearHistory}
@@ -262,5 +262,5 @@ export function SearchPage(props: SearchPageProps) {
<text fg="gray">[Esc] Up</text>
</box>
</box>
)
);
}

View File

@@ -2,32 +2,35 @@
* 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"
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
}
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)
}
props.onChange?.(index);
};
return (
<Show when={!props.isSearching} fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}>
<Show
when={!props.isSearching}
fallback={
<box padding={1}>
<text fg="yellow">Searching...</text>
</box>
}
>
<Show
when={!props.error}
fallback={
@@ -40,7 +43,9 @@ export function SearchResults(props: SearchResultsProps) {
when={props.results.length > 0}
fallback={
<box padding={1}>
<text fg="gray">No results found. Try a different search term.</text>
<text fg="gray">
No results found. Try a different search term.
</text>
</box>
}
>
@@ -71,5 +76,5 @@ export function SearchResults(props: SearchResultsProps) {
</Show>
</Show>
</Show>
)
);
}

View File

@@ -0,0 +1,34 @@
import { SourceType } from "@/types/source";
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 "cyan";
if (sourceType === SourceType.RSS) return "green";
if (sourceType === SourceType.CUSTOM) return "yellow";
return "gray";
};
export function SourceBadge(props: SourceBadgeProps) {
const label = () => props.sourceName || props.sourceId;
return (
<box flexDirection="row" gap={1} padding={0}>
<text fg={typeColor(props.sourceType)}>
[{typeLabel(props.sourceType)}]
</text>
<text fg="gray">{label()}</text>
</box>
);
}

View File

@@ -1,12 +1,12 @@
import { detectFormat } from "../utils/file-detector"
import { detectFormat } from "@/utils/file-detector";
type FilePickerProps = {
value: string
onChange: (value: string) => void
}
value: string;
onChange: (value: string) => void;
};
export function FilePicker(props: FilePickerProps) {
const format = detectFormat(props.value)
const format = detectFormat(props.value);
return (
<box style={{ flexDirection: "column", gap: 1 }}>
@@ -18,5 +18,5 @@ export function FilePicker(props: FilePickerProps) {
/>
<text>Format: {format}</text>
</box>
)
);
}

View File

@@ -3,84 +3,84 @@
* 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"
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
focused?: boolean;
onNavigateToCode?: () => void;
onNavigateToOAuth?: () => void;
}
type FocusField = "email" | "password" | "submit" | "code" | "oauth"
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 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 fields: FocusField[] = ["email", "password", "submit", "code", "oauth"];
const validateEmail = (value: string): boolean => {
if (!value) {
setEmailError("Email is required")
return false
setEmailError("Email is required");
return false;
}
if (!AUTH_CONFIG.email.pattern.test(value)) {
setEmailError("Invalid email format")
return false
setEmailError("Invalid email format");
return false;
}
setEmailError(null)
return true
}
setEmailError(null);
return true;
};
const validatePassword = (value: string): boolean => {
if (!value) {
setPasswordError("Password is required")
return false
setPasswordError("Password is required");
return false;
}
if (value.length < AUTH_CONFIG.password.minLength) {
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`)
return false
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`);
return false;
}
setPasswordError(null)
return true
}
setPasswordError(null);
return true;
};
const handleSubmit = async () => {
const isEmailValid = validateEmail(email())
const isPasswordValid = validatePassword(password())
const isEmailValid = validateEmail(email());
const isPasswordValid = validatePassword(password());
if (!isEmailValid || !isPasswordValid) {
return
return;
}
await auth.login({ email: email(), password: password() })
}
await auth.login({ email: email(), password: password() });
};
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "submit") {
handleSubmit()
handleSubmit();
} else if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode()
props.onNavigateToCode();
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
props.onNavigateToOAuth()
props.onNavigateToOAuth();
}
}
}
};
return (
<box flexDirection="column" border padding={2} gap={1}>
@@ -92,7 +92,9 @@ export function LoginScreen(props: LoginScreenProps) {
{/* Email field */}
<box flexDirection="column" gap={0}>
<text fg={focusField() === "email" ? theme.primary : undefined}>Email:</text>
<text fg={focusField() === "email" ? theme.primary : undefined}>
Email:
</text>
<input
value={email()}
onInput={setEmail}
@@ -100,9 +102,7 @@ export function LoginScreen(props: LoginScreenProps) {
focused={props.focused && focusField() === "email"}
width={30}
/>
{emailError() && (
<text fg={theme.error}>{emailError()}</text>
)}
{emailError() && <text fg={theme.error}>{emailError()}</text>}
</box>
{/* Password field */}
@@ -117,9 +117,7 @@ export function LoginScreen(props: LoginScreenProps) {
focused={props.focused && focusField() === "password"}
width={30}
/>
{passwordError() && (
<text fg={theme.error}>{passwordError()}</text>
)}
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
</box>
<box height={1} />
@@ -129,7 +127,9 @@ export function LoginScreen(props: LoginScreenProps) {
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? theme.primary : undefined}
backgroundColor={
focusField() === "submit" ? theme.primary : undefined
}
>
<text fg={focusField() === "submit" ? theme.text : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
@@ -138,9 +138,7 @@ export function LoginScreen(props: LoginScreenProps) {
</box>
{/* Auth error message */}
{auth.error && (
<text fg={theme.error}>{auth.error.message}</text>
)}
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
<box height={1} />
@@ -173,5 +171,5 @@ export function LoginScreen(props: LoginScreenProps) {
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
</box>
)
);
}

View File

@@ -3,39 +3,39 @@
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js"
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
import { createSignal } from "solid-js";
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "@/config/auth";
interface OAuthPlaceholderProps {
focused?: boolean
onBack?: () => void
onNavigateToCode?: () => void
focused?: boolean;
onBack?: () => void;
onNavigateToCode?: () => void;
}
type FocusField = "code" | "back"
type FocusField = "code" | "back";
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const [focusField, setFocusField] = createSignal<FocusField>("code")
const [focusField, setFocusField] = createSignal<FocusField>("code");
const fields: FocusField[] = ["code", "back"]
const fields: FocusField[] = ["code", "back"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode()
props.onNavigateToCode();
} else if (focusField() === "back" && props.onBack) {
props.onBack()
props.onBack();
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
props.onBack();
}
}
};
return (
<box flexDirection="column" border padding={2} gap={1}>
@@ -121,5 +121,5 @@ export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
</box>
)
);
}

View File

@@ -1,10 +1,10 @@
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"
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"
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto";
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ value: "system", label: "System" },
@@ -13,68 +13,77 @@ const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
{ 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 appStore = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("theme");
const settings = () => appStore.state().settings
const preferences = () => appStore.state().preferences
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 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
: (idx + 1) % fields.length;
setFocusField(fields[next]);
return;
}
if (key.name === "left" || key.name === "h") {
stepValue(-1)
stepValue(-1);
}
if (key.name === "right" || key.name === "l") {
stepValue(1)
stepValue(1);
}
if (key.name === "space" || key.name === "return" || key.name === "enter") {
toggleValue()
toggleValue();
}
}
};
const stepValue = (delta: number) => {
const field = focusField()
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
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
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 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()
const field = focusField();
if (field === "explicit") {
appStore.updatePreferences({ showExplicit: !preferences().showExplicit })
appStore.updatePreferences({ showExplicit: !preferences().showExplicit });
}
if (field === "auto") {
appStore.updatePreferences({ autoDownload: !preferences().autoDownload })
appStore.updatePreferences({ autoDownload: !preferences().autoDownload });
}
}
};
useKeyboard(handleKey)
useKeyboard(handleKey);
return (
<box flexDirection="column" gap={1}>
@@ -82,15 +91,21 @@ export function PreferencesPanel() {
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>Theme:</text>
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>
Theme:
</text>
<box border padding={0}>
<text fg={theme.text}>{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
<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>
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>
Font Size:
</text>
<box border padding={0}>
<text fg={theme.text}>{settings().fontSize}px</text>
</box>
@@ -98,7 +113,9 @@ export function PreferencesPanel() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>Playback:</text>
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>
Playback:
</text>
<box border padding={0}>
<text fg={theme.text}>{settings().playbackSpeed}x</text>
</box>
@@ -106,9 +123,15 @@ export function PreferencesPanel() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "explicit" ? theme.primary : theme.textMuted}>Show Explicit:</text>
<text
fg={focusField() === "explicit" ? theme.primary : theme.textMuted}
>
Show Explicit:
</text>
<box border padding={0}>
<text fg={preferences().showExplicit ? theme.success : theme.textMuted}>
<text
fg={preferences().showExplicit ? theme.success : theme.textMuted}
>
{preferences().showExplicit ? "On" : "Off"}
</text>
</box>
@@ -116,9 +139,13 @@ export function PreferencesPanel() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>Auto Download:</text>
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>
Auto Download:
</text>
<box border padding={0}>
<text fg={preferences().autoDownload ? theme.success : theme.textMuted}>
<text
fg={preferences().autoDownload ? theme.success : theme.textMuted}
>
{preferences().autoDownload ? "On" : "Off"}
</text>
</box>
@@ -128,5 +155,5 @@ export function PreferencesPanel() {
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
)
);
}

View File

@@ -1,19 +1,19 @@
import { createSignal, For } 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 { createSignal, For } 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";
type SettingsScreenProps = {
accountLabel: string
accountStatus: "signed-in" | "signed-out"
onOpenAccount?: () => void
onExit?: () => void
}
accountLabel: string;
accountStatus: "signed-in" | "signed-out";
onOpenAccount?: () => void;
onExit?: () => void;
};
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account";
const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "sync", label: "Sync" },
@@ -21,41 +21,47 @@ const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "preferences", label: "Preferences" },
{ id: "visualizer", label: "Visualizer" },
{ id: "account", label: "Account" },
]
];
export function SettingsScreen(props: SettingsScreenProps) {
const { theme } = useTheme()
const [activeSection, setActiveSection] = createSignal<SectionId>("sync")
const { theme } = useTheme();
const [activeSection, setActiveSection] = createSignal<SectionId>("sync");
useKeyboard((key) => {
if (key.name === "escape") {
props.onExit?.()
return
props.onExit?.();
return;
}
if (key.name === "tab") {
const idx = SECTIONS.findIndex((s) => s.id === activeSection())
const idx = SECTIONS.findIndex((s) => s.id === activeSection());
const next = key.shift
? (idx - 1 + SECTIONS.length) % SECTIONS.length
: (idx + 1) % SECTIONS.length
setActiveSection(SECTIONS[next].id)
return
: (idx + 1) % SECTIONS.length;
setActiveSection(SECTIONS[next].id);
return;
}
if (key.name === "1") setActiveSection("sync")
if (key.name === "2") setActiveSection("sources")
if (key.name === "3") setActiveSection("preferences")
if (key.name === "4") setActiveSection("visualizer")
if (key.name === "5") setActiveSection("account")
})
if (key.name === "1") setActiveSection("sync");
if (key.name === "2") setActiveSection("sources");
if (key.name === "3") setActiveSection("preferences");
if (key.name === "4") setActiveSection("visualizer");
if (key.name === "5") setActiveSection("account");
});
return (
<box flexDirection="column" gap={1} height="100%">
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<box
flexDirection="row"
justifyContent="space-between"
alignItems="center"
>
<text>
<strong>Settings</strong>
</text>
<text fg={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text>
<text fg={theme.textMuted}>
[Tab] Switch section | 1-5 jump | Esc up
</text>
</box>
<box flexDirection="row" gap={1}>
@@ -64,10 +70,16 @@ export function SettingsScreen(props: SettingsScreenProps) {
<box
border
padding={0}
backgroundColor={activeSection() === section.id ? theme.primary : undefined}
backgroundColor={
activeSection() === section.id ? theme.primary : undefined
}
onMouseDown={() => setActiveSection(section.id)}
>
<text fg={activeSection() === section.id ? theme.text : theme.textMuted}>
<text
fg={
activeSection() === section.id ? theme.text : theme.textMuted
}
>
[{index() + 1}] {section.label}
</text>
</box>
@@ -85,7 +97,13 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text fg={theme.textMuted}>Account</text>
<box flexDirection="row" gap={2} alignItems="center">
<text fg={theme.textMuted}>Status:</text>
<text fg={props.accountStatus === "signed-in" ? theme.success : theme.warning}>
<text
fg={
props.accountStatus === "signed-in"
? theme.success
: theme.warning
}
>
{props.accountLabel}
</text>
</box>
@@ -98,5 +116,5 @@ export function SettingsScreen(props: SettingsScreenProps) {
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
</box>
)
);
}

View File

@@ -3,39 +3,39 @@
* 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 { 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";
interface SourceManagerProps {
focused?: boolean
onClose?: () => void
focused?: boolean;
onClose?: () => void;
}
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
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 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 sources = () => feedStore.sources();
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "escape") {
if (focusArea() !== "list") {
setFocusArea("list")
setError(null)
setFocusArea("list");
setError(null);
} else if (props.onClose) {
props.onClose()
props.onClose();
}
return
return;
}
if (key.name === "tab") {
@@ -46,82 +46,100 @@ export function SourceManager(props: SourceManagerProps) {
"explicit",
"add",
"url",
]
const idx = areas.indexOf(focusArea())
];
const idx = areas.indexOf(focusArea());
const nextIdx = key.shift
? (idx - 1 + areas.length) % areas.length
: (idx + 1) % areas.length
setFocusArea(areas[nextIdx])
return
: (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))
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 === "enter" || key.name === "space") {
const source = sources()[selectedIndex()]
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1));
} else if (
key.name === "return" ||
key.name === "enter" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source) {
feedStore.toggleSource(source.id)
feedStore.toggleSource(source.id);
}
} else if (key.name === "d" || key.name === "delete") {
const source = sources()[selectedIndex()]
const source = sources()[selectedIndex()];
if (source) {
const removed = feedStore.removeSource(source.id)
const removed = feedStore.removeSource(source.id);
if (!removed) {
setError("Cannot remove default sources")
setError("Cannot remove default sources");
}
}
} else if (key.name === "a") {
setFocusArea("add")
setFocusArea("add");
}
}
if (focusArea() === "country") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
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 })
const next = source.country === "US" ? "GB" : "US";
feedStore.updateSource(source.id, { country: next });
}
}
}
if (focusArea() === "explicit") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
if (
key.name === "enter" ||
key.name === "return" ||
key.name === "space"
) {
const source = sources()[selectedIndex()];
if (source && source.type === SourceType.API) {
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit })
feedStore.updateSource(source.id, {
allowExplicit: !source.allowExplicit,
});
}
}
}
if (focusArea() === "language") {
if (key.name === "enter" || key.name === "return" || key.name === "space") {
const source = sources()[selectedIndex()]
if (
key.name === "enter" ||
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 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`
const url = newSourceUrl().trim();
const name = newSourceName().trim() || `Custom Source`;
if (!url) {
setError("URL is required")
return
setError("URL is required");
return;
}
try {
new URL(url)
new URL(url);
} catch {
setError("Invalid URL format")
return
setError("Invalid URL format");
return;
}
feedStore.addSource({
@@ -130,25 +148,25 @@ export function SourceManager(props: SourceManagerProps) {
baseUrl: url,
enabled: true,
description: `Custom RSS feed: ${url}`,
})
});
setNewSourceUrl("")
setNewSourceName("")
setFocusArea("list")
setError(null)
}
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 "[?]"
}
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"
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 padding={1} gap={1}>
@@ -161,11 +179,13 @@ export function SourceManager(props: SourceManagerProps) {
</box>
</box>
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
{/* Source list */}
<box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>Sources:</text>
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>
Sources:
</text>
<scrollbox height={6}>
<For each={sources()}>
{(source, index) => (
@@ -179,16 +199,18 @@ export function SourceManager(props: SourceManagerProps) {
: undefined
}
onMouseDown={() => {
setSelectedIndex(index())
setFocusArea("list")
feedStore.toggleSource(source.id)
setSelectedIndex(index());
setFocusArea("list");
feedStore.toggleSource(source.id);
}}
>
<text fg={
focusArea() === "list" && index() === selectedIndex()
? theme.primary
: theme.textMuted
}>
<text
fg={
focusArea() === "list" && index() === selectedIndex()
? theme.primary
: theme.textMuted
}
>
{focusArea() === "list" && index() === selectedIndex()
? ">"
: " "}
@@ -210,49 +232,78 @@ export function SourceManager(props: SourceManagerProps) {
)}
</For>
</scrollbox>
<text fg={theme.textMuted}>Space/Enter to toggle, d to delete, a to add</text>
<text fg={theme.textMuted}>
Space/Enter to toggle, d to delete, a to add
</text>
{/* API settings */}
<box flexDirection="column" gap={1}>
<text fg={isApiSource() ? theme.textMuted : theme.accent}>
{isApiSource() ? "API Settings" : "API Settings (select an API source)"}
{isApiSource()
? "API Settings"
: "API Settings (select an API source)"}
</text>
<box flexDirection="row" gap={2}>
<box
border
padding={0}
backgroundColor={focusArea() === "country" ? theme.primary : undefined}
backgroundColor={
focusArea() === "country" ? theme.primary : undefined
}
>
<text fg={focusArea() === "country" ? theme.primary : theme.textMuted}>
<text
fg={focusArea() === "country" ? theme.primary : theme.textMuted}
>
Country: {sourceCountry()}
</text>
</box>
<box
border
padding={0}
backgroundColor={focusArea() === "language" ? theme.primary : undefined}
backgroundColor={
focusArea() === "language" ? theme.primary : undefined
}
>
<text fg={focusArea() === "language" ? theme.primary : theme.textMuted}>
Language: {sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
<text
fg={
focusArea() === "language" ? theme.primary : theme.textMuted
}
>
Language:{" "}
{sourceLanguage() === "ja_jp" ? "Japanese" : "English"}
</text>
</box>
<box
border
padding={0}
backgroundColor={focusArea() === "explicit" ? theme.primary : undefined}
backgroundColor={
focusArea() === "explicit" ? theme.primary : undefined
}
>
<text fg={focusArea() === "explicit" ? theme.primary : theme.textMuted}>
<text
fg={
focusArea() === "explicit" ? theme.primary : theme.textMuted
}
>
Explicit: {sourceExplicit() ? "Yes" : "No"}
</text>
</box>
</box>
<text fg={theme.textMuted}>Enter/Space to toggle focused setting</text>
<text fg={theme.textMuted}>
Enter/Space to toggle focused setting
</text>
</box>
</box>
{/* Add new source form */}
<box border padding={1} flexDirection="column" gap={1}>
<text fg={focusArea() === "add" || focusArea() === "url" ? theme.primary : theme.textMuted}>
<text
fg={
focusArea() === "add" || focusArea() === "url"
? theme.primary
: theme.textMuted
}
>
Add New Source:
</text>
@@ -272,8 +323,8 @@ export function SourceManager(props: SourceManagerProps) {
<input
value={newSourceUrl()}
onInput={(v) => {
setNewSourceUrl(v)
setError(null)
setNewSourceUrl(v);
setError(null);
}}
placeholder="https://example.com/feed.rss"
focused={props.focused && focusArea() === "url"}
@@ -281,22 +332,15 @@ export function SourceManager(props: SourceManagerProps) {
/>
</box>
<box
border
padding={0}
width={15}
onMouseDown={handleAddSource}
>
<box border padding={0} width={15} onMouseDown={handleAddSource}>
<text fg={theme.success}>[+] Add Source</text>
</box>
</box>
{/* Error message */}
{error() && (
<text fg={theme.error}>{error()}</text>
)}
{error() && <text fg={theme.error}>{error()}</text>}
<text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
</box>
)
);
}

View File

@@ -3,60 +3,60 @@
* Displays user profile information and sync status
*/
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
import { format } from "date-fns"
import { createSignal } from "solid-js";
import { useAuthStore } from "@/stores/auth";
import { format } from "date-fns";
interface SyncProfileProps {
focused?: boolean
onLogout?: () => void
onManageSync?: () => void
focused?: boolean;
onLogout?: () => void;
onManageSync?: () => void;
}
type FocusField = "sync" | "export" | "logout"
type FocusField = "sync" | "export" | "logout";
export function SyncProfile(props: SyncProfileProps) {
const auth = useAuthStore()
const [focusField, setFocusField] = createSignal<FocusField>("sync")
const [lastSyncTime] = createSignal<Date | null>(new Date())
const auth = useAuthStore();
const [focusField, setFocusField] = createSignal<FocusField>("sync");
const [lastSyncTime] = createSignal<Date | null>(new Date());
const fields: FocusField[] = ["sync", "export", "logout"]
const fields: FocusField[] = ["sync", "export", "logout"];
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const currentIndex = fields.indexOf(focusField());
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
: (currentIndex + 1) % fields.length;
setFocusField(fields[nextIndex]);
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "sync" && props.onManageSync) {
props.onManageSync()
props.onManageSync();
} else if (focusField() === "logout" && props.onLogout) {
handleLogout()
handleLogout();
}
}
}
};
const handleLogout = () => {
auth.logout()
auth.logout();
if (props.onLogout) {
props.onLogout()
props.onLogout();
}
}
};
const formatDate = (date: Date | null | undefined): string => {
if (!date) return "Never"
return format(date, "MMM d, yyyy HH:mm")
}
if (!date) return "Never";
return format(date, "MMM d, yyyy HH:mm");
};
const user = () => auth.state().user
const user = () => auth.state().user;
// Get user initials for avatar
const userInitials = () => {
const name = user()?.name || "?"
return name.slice(0, 2).toUpperCase()
}
const name = user()?.name || "?";
return name.slice(0, 2).toUpperCase();
};
return (
<box flexDirection="column" border padding={2} gap={1}>
@@ -69,7 +69,14 @@ export function SyncProfile(props: SyncProfileProps) {
{/* User avatar and info */}
<box flexDirection="row" gap={2}>
{/* ASCII avatar */}
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
<box
border
padding={1}
width={8}
height={4}
justifyContent="center"
alignItems="center"
>
<text fg="cyan">{userInitials()}</text>
</box>
@@ -144,5 +151,5 @@ export function SyncProfile(props: SyncProfileProps) {
<text fg="gray">Tab to navigate, Enter to select</text>
</box>
)
);
}

View File

@@ -5,95 +5,100 @@
* 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"
import { isCavacoreAvailable } from "./RealtimeWaveform"
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"
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut";
const FIELDS: 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 appStore = useAppStore();
const { theme } = useTheme();
const [focusField, setFocusField] = createSignal<FocusField>("bars");
const viz = () => appStore.state().settings.visualizer
const viz = () => appStore.state().settings.visualizer;
const handleKey = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const idx = FIELDS.indexOf(focusField())
const idx = FIELDS.indexOf(focusField());
const next = key.shift
? (idx - 1 + FIELDS.length) % FIELDS.length
: (idx + 1) % FIELDS.length
setFocusField(FIELDS[next])
return
: (idx + 1) % FIELDS.length;
setFocusField(FIELDS[next]);
return;
}
if (key.name === "left" || key.name === "h") {
stepValue(-1)
stepValue(-1);
}
if (key.name === "right" || key.name === "l") {
stepValue(1)
stepValue(1);
}
}
};
const stepValue = (delta: number) => {
const field = focusField()
const v = viz()
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
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
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
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
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
const next = Math.min(
20000,
Math.max(1000, v.highCutOff + delta * 500),
);
appStore.updateVisualizer({ highCutOff: next });
break;
}
}
}
};
useKeyboard(handleKey)
const cavacoreStatus = isCavacoreAvailable()
useKeyboard(handleKey);
return (
<box flexDirection="column" gap={1}>
<text fg={theme.textMuted}>Visualizer</text>
{!cavacoreStatus && (
<text fg={theme.warning}>
cavacore not available using static waveform
</text>
)}
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>Bars:</text>
<text fg={focusField() === "bars" ? theme.primary : theme.textMuted}>
Bars:
</text>
<box border padding={0}>
<text fg={theme.text}>{viz().bars}</text>
</box>
@@ -101,9 +106,17 @@ export function VisualizerSettings() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
<text
fg={
focusField() === "sensitivity" ? theme.primary : theme.textMuted
}
>
Auto Sensitivity:
</text>
<box border padding={0}>
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
<text
fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}
>
{viz().sensitivity === 1 ? "On" : "Off"}
</text>
</box>
@@ -111,7 +124,9 @@ export function VisualizerSettings() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>
Noise Reduction:
</text>
<box border padding={0}>
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
</box>
@@ -119,7 +134,11 @@ export function VisualizerSettings() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
<text
fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}
>
Low Cutoff:
</text>
<box border padding={0}>
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
</box>
@@ -127,7 +146,11 @@ export function VisualizerSettings() {
</box>
<box flexDirection="row" gap={1} alignItems="center">
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
<text
fg={focusField() === "highCut" ? theme.primary : theme.textMuted}
>
High Cutoff:
</text>
<box border padding={0}>
<text fg={theme.text}>{viz().highCutOff} Hz</text>
</box>
@@ -137,5 +160,5 @@ export function VisualizerSettings() {
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
</box>
)
);
}