checkpoint
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
34
src/components/PlaybackControls.tsx
Normal file
34
src/components/PlaybackControls.tsx
Normal 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
114
src/components/Player.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
src/components/PreferencesPanel.tsx
Normal file
130
src/components/PreferencesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
94
src/components/SettingsScreen.tsx
Normal file
94
src/components/SettingsScreen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/components/Waveform.tsx
Normal file
52
src/components/Waveform.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user