checkpoint

This commit is contained in:
2026-02-04 12:10:30 -05:00
parent b8549777ba
commit cdabf2c3e0
22 changed files with 1176 additions and 18 deletions

View File

@@ -10,6 +10,7 @@ import { TrendingShows } from "./TrendingShows"
type DiscoverPageProps = {
focused: boolean
onExit?: () => void
}
type FocusArea = "categories" | "shows"
@@ -36,6 +37,11 @@ export function DiscoverPage(props: DiscoverPageProps) {
return
}
if (key.name === "enter" && area === "categories") {
setFocusArea("shows")
return
}
// Category navigation
if (area === "categories") {
if (key.name === "left" || key.name === "h") {
@@ -96,6 +102,15 @@ export function DiscoverPage(props: DiscoverPageProps) {
}
}
if (key.name === "escape") {
if (area === "shows") {
setFocusArea("categories")
} else {
props.onExit?.()
}
return
}
// Refresh with 'r'
if (key.name === "r") {
discoverStore.refresh()
@@ -177,6 +192,7 @@ export function DiscoverPage(props: DiscoverPageProps) {
<text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[j/k] Navigate</text>
<text fg="gray">[Enter] Subscribe</text>
<text fg="gray">[Esc] Up</text>
<text fg="gray">[R] Refresh</text>
</box>
</box>

View File

@@ -17,6 +17,7 @@ interface FeedListProps {
showLastUpdated?: boolean
onSelectFeed?: (feed: Feed) => void
onOpenFeed?: (feed: Feed) => void
onFocusChange?: (focused: boolean) => void
}
export function FeedList(props: FeedListProps) {
@@ -26,6 +27,10 @@ export function FeedList(props: FeedListProps) {
const filteredFeeds = () => feedStore.getFilteredFeeds()
const handleKeyPress = (key: { name: string }) => {
if (key.name === "escape") {
props.onFocusChange?.(false)
return
}
const feeds = filteredFeeds()
if (key.name === "up" || key.name === "k") {
@@ -180,7 +185,7 @@ export function FeedList(props: FeedListProps) {
{/* Navigation help */}
<box paddingTop={0}>
<text fg="gray">
j/k navigate | Enter open | p pin | f filter | s sort | Click to select
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
</text>
</box>
</box>

View File

@@ -1,14 +1,21 @@
import type { JSX } from "solid-js"
import type { ThemeColors } from "../types/settings"
type LayoutProps = {
header?: JSX.Element
footer?: JSX.Element
children?: JSX.Element
theme?: ThemeColors
}
export function Layout(props: LayoutProps) {
return (
<box style={{ flexDirection: "column", width: "100%", height: "100%" }}>
<box
flexDirection="column"
width="100%"
height="100%"
backgroundColor={props.theme?.background}
>
{props.header ? <box style={{ height: 3 }}>{props.header}</box> : <text></text>}
<box style={{ flexGrow: 1 }}>{props.children}</box>
{props.footer ? <box style={{ height: 1 }}>{props.footer}</box> : <text></text>}

View File

@@ -0,0 +1,34 @@
type PlaybackControlsProps = {
isPlaying: boolean
volume: number
speed: number
onToggle: () => void
onPrev: () => void
onNext: () => void
onVolumeChange: (value: number) => void
onSpeedChange: (value: number) => void
}
export function PlaybackControls(props: PlaybackControlsProps) {
return (
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
<box border padding={0} onMouseDown={props.onPrev}>
<text fg="cyan">[Prev]</text>
</box>
<box border padding={0} onMouseDown={props.onToggle}>
<text fg="cyan">{props.isPlaying ? "[Pause]" : "[Play]"}</text>
</box>
<box border padding={0} onMouseDown={props.onNext}>
<text fg="cyan">[Next]</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg="gray">Vol</text>
<text fg="white">{Math.round(props.volume * 100)}%</text>
</box>
<box flexDirection="row" gap={1} marginLeft={2}>
<text fg="gray">Speed</text>
<text fg="white">{props.speed}x</text>
</box>
</box>
)
}

114
src/components/Player.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls"
import { Waveform } from "./Waveform"
import { createWaveform } from "../utils/waveform"
import type { Episode } from "../types/episode"
type PlayerProps = {
focused: boolean
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 [isPlaying, setIsPlaying] = createSignal(false)
const [position, setPosition] = createSignal(0)
const [volume, setVolume] = createSignal(0.7)
const [speed, setSpeed] = createSignal(1)
const waveform = () => createWaveform(64)
useKeyboard((key: { name: string }) => {
if (!props.focused) return
if (key.name === "space") {
setIsPlaying((value: boolean) => !value)
return
}
if (key.name === "escape") {
props.onExit?.()
return
}
if (key.name === "left") {
setPosition((value: number) => Math.max(0, value - 10))
}
if (key.name === "right") {
setPosition((value: number) => Math.min(SAMPLE_EPISODE.duration, value + 10))
}
if (key.name === "up") {
setVolume((value: number) => Math.min(1, Number((value + 0.05).toFixed(2))))
}
if (key.name === "down") {
setVolume((value: number) => Math.max(0, Number((value - 0.05).toFixed(2))))
}
if (key.name === "s") {
setSpeed((value: number) => (value >= 2 ? 0.5 : Number((value + 0.25).toFixed(2))))
}
})
const progressPercent = () => Math.round((position() / SAMPLE_EPISODE.duration) * 100)
return (
<box flexDirection="column" gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text>
<strong>Now Playing</strong>
</text>
<text fg="gray">
Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")}
</text>
</box>
<box border padding={1} flexDirection="column" gap={1}>
<text fg="white">
<strong>{SAMPLE_EPISODE.title}</strong>
</text>
<text fg="gray">{SAMPLE_EPISODE.description}</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Progress:</text>
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
<box
width={`${progressPercent()}%`}
height={1}
backgroundColor={isPlaying() ? "#6fa8ff" : "#7d8590"}
/>
</box>
<text fg="gray">{progressPercent()}%</text>
</box>
<Waveform
data={waveform()}
position={position()}
duration={SAMPLE_EPISODE.duration}
isPlaying={isPlaying()}
onSeek={(next: number) => setPosition(next)}
/>
</box>
</box>
<PlaybackControls
isPlaying={isPlaying()}
volume={volume()}
speed={speed()}
onToggle={() => setIsPlaying((value: boolean) => !value)}
onPrev={() => setPosition(0)}
onNext={() => setPosition(SAMPLE_EPISODE.duration)}
onSpeedChange={setSpeed}
onVolumeChange={setVolume}
/>
<text fg="gray">Enter dive | Esc up | Space play/pause | Left/Right seek</text>
</box>
)
}

View File

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

View File

@@ -13,6 +13,7 @@ type SearchPageProps = {
focused: boolean
onSubscribe?: (result: SearchResult) => void
onInputFocusChange?: (focused: boolean) => void
onExit?: () => void
}
type FocusArea = "input" | "results" | "history"
@@ -34,6 +35,9 @@ export function SearchPage(props: SearchPageProps) {
props.onInputFocusChange?.(false)
}
}
if (props.focused && focusArea() === "input") {
props.onInputFocusChange?.(true)
}
}
const handleHistorySelect = async (query: string) => {
@@ -144,10 +148,14 @@ export function SearchPage(props: SearchPageProps) {
}
}
// Escape goes back to input
// Escape goes back to input or up one level
if (key.name === "escape") {
setFocusArea("input")
props.onInputFocusChange?.(true)
if (area === "input") {
props.onExit?.()
} else {
setFocusArea("input")
props.onInputFocusChange?.(true)
}
return
}
@@ -261,7 +269,7 @@ export function SearchPage(props: SearchPageProps) {
<text fg="gray">[Tab] Switch focus</text>
<text fg="gray">[/] Focus search</text>
<text fg="gray">[Enter] Select</text>
<text fg="gray">[Esc] Back to search</text>
<text fg="gray">[Esc] Up</text>
</box>
</box>
)

View File

@@ -0,0 +1,94 @@
import { createSignal } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { SourceManager } from "./SourceManager"
import { PreferencesPanel } from "./PreferencesPanel"
import { SyncPanel } from "./SyncPanel"
type SettingsScreenProps = {
accountLabel: string
accountStatus: "signed-in" | "signed-out"
onOpenAccount?: () => void
onExit?: () => void
}
type SectionId = "sync" | "sources" | "preferences" | "account"
const SECTIONS: Array<{ id: SectionId; label: string }> = [
{ id: "sync", label: "Sync" },
{ id: "sources", label: "Sources" },
{ id: "preferences", label: "Preferences" },
{ id: "account", label: "Account" },
]
export function SettingsScreen(props: SettingsScreenProps) {
const [activeSection, setActiveSection] = createSignal<SectionId>("sync")
useKeyboard((key) => {
if (key.name === "escape") {
props.onExit?.()
return
}
if (key.name === "tab") {
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
}
if (key.name === "1") setActiveSection("sync")
if (key.name === "2") setActiveSection("sources")
if (key.name === "3") setActiveSection("preferences")
if (key.name === "4") setActiveSection("account")
})
return (
<box flexDirection="column" gap={1} height="100%">
<box flexDirection="row" justifyContent="space-between" alignItems="center">
<text>
<strong>Settings</strong>
</text>
<text fg="gray">[Tab] Switch section | 1-4 jump | Esc up</text>
</box>
<box flexDirection="row" gap={1}>
{SECTIONS.map((section, index) => (
<box
border
padding={0}
backgroundColor={activeSection() === section.id ? "#2b303b" : undefined}
onMouseDown={() => setActiveSection(section.id)}
>
<text fg={activeSection() === section.id ? "cyan" : "gray"}>
[{index + 1}] {section.label}
</text>
</box>
))}
</box>
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
{activeSection() === "sync" && <SyncPanel />}
{activeSection() === "sources" && <SourceManager focused />}
{activeSection() === "preferences" && <PreferencesPanel />}
{activeSection() === "account" && (
<box flexDirection="column" gap={1}>
<text fg="gray">Account</text>
<box flexDirection="row" gap={2} alignItems="center">
<text fg="gray">Status:</text>
<text fg={props.accountStatus === "signed-in" ? "green" : "yellow"}>
{props.accountLabel}
</text>
</box>
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
<text fg="cyan">[A] Manage Account</text>
</box>
</box>
)}
</box>
<text fg="gray">Enter to dive | Esc up</text>
</box>
)
}

View File

@@ -0,0 +1,52 @@
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>
)
}