file ordering
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* CategoryFilter component - Horizontal category filter tabs
|
||||
*/
|
||||
|
||||
import { For } from "solid-js"
|
||||
import type { DiscoverCategory } from "../stores/discover"
|
||||
|
||||
type CategoryFilterProps = {
|
||||
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
|
||||
|
||||
return (
|
||||
<box
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
border={isSelected()}
|
||||
backgroundColor={isSelected() ? "#444" : undefined}
|
||||
onMouseDown={() => props.onSelect?.(category.id)}
|
||||
>
|
||||
<text fg={isSelected() ? "cyan" : "gray"}>
|
||||
{category.icon} {category.name}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
|
||||
type DiscoverPageProps = {
|
||||
focused: boolean
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
|
||||
const area = focusArea()
|
||||
|
||||
// Tab switches focus between categories and shows
|
||||
if (key.name === "tab") {
|
||||
if (key.shift) {
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||
} else {
|
||||
setFocusArea((a) => (a === "categories" ? "shows" : "categories"))
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
// Select category and move to shows
|
||||
setFocusArea("shows")
|
||||
return
|
||||
}
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setFocusArea("shows")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Shows navigation
|
||||
if (area === "shows") {
|
||||
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 (key.name === "up" || key.name === "k") {
|
||||
if (shows.length === 0) {
|
||||
setFocusArea("categories")
|
||||
return
|
||||
}
|
||||
const newIndex = showIndex() - 1
|
||||
if (newIndex < 0) {
|
||||
setFocusArea("categories")
|
||||
} else {
|
||||
setShowIndex(newIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
// Subscribe/unsubscribe
|
||||
const podcast = shows[showIndex()]
|
||||
if (podcast) {
|
||||
discoverStore.toggleSubscription(podcast.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (key.name === "escape") {
|
||||
if (area === "shows") {
|
||||
setFocusArea("categories")
|
||||
key.stopPropagation()
|
||||
} else {
|
||||
props.onExit?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh with 'r'
|
||||
if (key.name === "r") {
|
||||
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)
|
||||
}
|
||||
|
||||
const handleShowSelect = (index: number) => {
|
||||
setShowIndex(index)
|
||||
setFocusArea("shows")
|
||||
}
|
||||
|
||||
const handleSubscribe = (podcast: { id: string }) => {
|
||||
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" gap={2}>
|
||||
<text fg="gray">
|
||||
{discoverStore.filteredPodcasts().length} shows
|
||||
</text>
|
||||
<box onMouseDown={() => discoverStore.refresh()}>
|
||||
<text fg="cyan">[R] Refresh</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Category Filter */}
|
||||
<box border padding={1}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "categories" ? "cyan" : "gray"}>
|
||||
Categories:
|
||||
</text>
|
||||
<CategoryFilter
|
||||
categories={discoverStore.categories}
|
||||
selectedCategory={discoverStore.selectedCategory()}
|
||||
focused={focusArea() === "categories"}
|
||||
onSelect={handleCategorySelect}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* 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>
|
||||
<TrendingShows
|
||||
podcasts={discoverStore.filteredPodcasts()}
|
||||
selectedIndex={showIndex()}
|
||||
focused={focusArea() === "shows"}
|
||||
isLoading={discoverStore.isLoading()}
|
||||
onSelect={handleShowSelect}
|
||||
onSubscribe={handleSubscribe}
|
||||
/>
|
||||
</box>
|
||||
|
||||
{/* Footer Hints */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
|
||||
import { SyncStatus } from "./SyncStatus"
|
||||
|
||||
export function ExportDialog() {
|
||||
const filename = createSignal("podcast-sync.json")
|
||||
const format = createSignal<"json" | "xml">("json")
|
||||
|
||||
return (
|
||||
<box border title="Export" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text>File:</text>
|
||||
<input value={filename[0]()} onInput={filename[1]} style={{ width: 30 }} />
|
||||
</box>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text>Format:</text>
|
||||
<tab_select
|
||||
options={[
|
||||
{ name: "JSON", description: "Portable" },
|
||||
{ name: "XML", description: "Structured" },
|
||||
]}
|
||||
onSelect={(index) => format[1](index === 0 ? "json" : "xml")}
|
||||
/>
|
||||
</box>
|
||||
<box border>
|
||||
<text>Export {format[0]()} to {filename[0]()}</text>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/**
|
||||
* Feed detail view component for PodTUI
|
||||
* Shows podcast info and episode list
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import type { Feed } from "../types/feed"
|
||||
import type { Episode } from "../types/episode"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface FeedDetailProps {
|
||||
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 episodes = () => {
|
||||
// Sort episodes by publication date (newest first)
|
||||
return [...props.feed.episodes].sort(
|
||||
(a, b) => b.pubDate.getTime() - a.pubDate.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs > 0) {
|
||||
return `${hrs}h ${mins % 60}m`
|
||||
}
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy")
|
||||
}
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
const eps = episodes()
|
||||
|
||||
if (key.name === "escape" && props.onBack) {
|
||||
props.onBack()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "i") {
|
||||
setShowInfo((v) => !v)
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const episode = eps[selectedIndex()]
|
||||
if (episode && props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode)
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(eps.length - 1)
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
handleKeyPress(key)
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with back button */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box border padding={0} onMouseDown={props.onBack}>
|
||||
<text fg="cyan">[Esc] Back</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={() => setShowInfo((v) => !v)}>
|
||||
<text fg="cyan">[i] {showInfo() ? "Hide" : "Show"} Info</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Podcast info section */}
|
||||
<Show when={showInfo()}>
|
||||
<box border padding={1} flexDirection="column" gap={0}>
|
||||
<text>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</text>
|
||||
{props.feed.podcast.author && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">by</text>
|
||||
<text fg="cyan">{props.feed.podcast.author}</text>
|
||||
</box>
|
||||
)}
|
||||
<box height={1} />
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description?.slice(0, 200)}
|
||||
{(props.feed.podcast.description?.length || 0) > 200 ? "..." : ""}
|
||||
</text>
|
||||
<box height={1} />
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Episodes:</text>
|
||||
<text fg="white">{props.feed.episodes.length}</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Updated:</text>
|
||||
<text fg="white">{formatDate(props.feed.lastUpdated)}</text>
|
||||
</box>
|
||||
<text fg={props.feed.visibility === "public" ? "green" : "yellow"}>
|
||||
{props.feed.visibility === "public" ? "[Public]" : "[Private]"}
|
||||
</text>
|
||||
{props.feed.isPinned && (
|
||||
<text fg="yellow">[Pinned]</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Episodes header */}
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Episodes</strong>
|
||||
</text>
|
||||
<text fg="gray">({episodes().length} total)</text>
|
||||
</box>
|
||||
|
||||
{/* Episode list */}
|
||||
<scrollbox height={showInfo() ? 10 : 15} focused={props.focused}>
|
||||
<For each={episodes()}>
|
||||
{(episode, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
padding={1}
|
||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index())
|
||||
if (props.onPlayEpisode) {
|
||||
props.onPlayEpisode(episode)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
{episode.episodeNumber ? `#${episode.episodeNumber} - ` : ""}
|
||||
{episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg="gray">{formatDate(episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(episode.duration)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
{/* Help text */}
|
||||
<text fg="gray">
|
||||
j/k to navigate, Enter to play, i to toggle info, Esc to go back
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Feed filter component for PodTUI
|
||||
* Toggle and filter options for feed list
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { FeedFilter } from "../types/feed"
|
||||
|
||||
interface FeedFilterProps {
|
||||
filter: FeedFilter
|
||||
focused?: boolean
|
||||
onFilterChange: (filter: FeedFilter) => void
|
||||
}
|
||||
|
||||
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 fields: FilterField[] = ["visibility", "sort", "pinned", "search"]
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "visibility") {
|
||||
cycleVisibility()
|
||||
} else if (focusField() === "sort") {
|
||||
cycleSort()
|
||||
} else if (focusField() === "pinned") {
|
||||
togglePinned()
|
||||
}
|
||||
} else if (key.name === "space") {
|
||||
if (focusField() === "pinned") {
|
||||
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 cycleSort = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
|
||||
const nextIndex = (currentIndex + 1) % sortOptions.length
|
||||
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
|
||||
}
|
||||
|
||||
const togglePinned = () => {
|
||||
props.onFilterChange({
|
||||
...props.filter,
|
||||
pinnedOnly: !props.filter.pinnedOnly,
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
setSearchValue(value)
|
||||
props.onFilterChange({ ...props.filter, searchQuery: value })
|
||||
}
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = props.filter.visibility
|
||||
if (vis === "all") return "All"
|
||||
if (vis === "public") return "Public"
|
||||
return "Private"
|
||||
}
|
||||
|
||||
const visibilityColor = () => {
|
||||
const vis = props.filter.visibility
|
||||
if (vis === "public") return "green"
|
||||
if (vis === "private") return "yellow"
|
||||
return "white"
|
||||
}
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = props.filter.sortBy
|
||||
switch (sort) {
|
||||
case "title":
|
||||
return "Title"
|
||||
case "episodeCount":
|
||||
return "Episodes"
|
||||
case "latestEpisode":
|
||||
return "Latest"
|
||||
case "updated":
|
||||
default:
|
||||
return "Updated"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
<text>
|
||||
<strong>Filter Feeds</strong>
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={2} flexWrap="wrap">
|
||||
{/* Visibility filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "visibility" ? "cyan" : "gray"}>Show:</text>
|
||||
<text fg={visibilityColor()}>{visibilityLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Sort filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "sort" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "sort" ? "cyan" : "gray"}>Sort:</text>
|
||||
<text fg="white">{sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Pinned filter */}
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "pinned" ? "cyan" : "gray"}>Pinned:</text>
|
||||
<text fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
|
||||
{props.filter.pinnedOnly ? "Yes" : "No"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Search box */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={focusField() === "search" ? "cyan" : "gray"}>Search:</text>
|
||||
<input
|
||||
value={searchValue()}
|
||||
onInput={handleSearchInput}
|
||||
placeholder="Filter by name..."
|
||||
focused={props.focused && focusField() === "search"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter/Space to toggle</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Feed item component for PodTUI
|
||||
* Displays a single feed/podcast in the list
|
||||
*/
|
||||
|
||||
import type { Feed, FeedVisibility } from "../types/feed"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface FeedItemProps {
|
||||
feed: Feed
|
||||
isSelected: boolean
|
||||
showEpisodeCount?: boolean
|
||||
showLastUpdated?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function FeedItem(props: FeedItemProps) {
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d")
|
||||
}
|
||||
|
||||
const episodeCount = () => props.feed.episodes.length
|
||||
const unplayedCount = () => {
|
||||
// This would be calculated based on episode status
|
||||
return props.feed.episodes.length
|
||||
}
|
||||
|
||||
const visibilityIcon = () => {
|
||||
return props.feed.visibility === "public" ? "[P]" : "[*]"
|
||||
}
|
||||
|
||||
const visibilityColor = () => {
|
||||
return props.feed.visibility === "public" ? "green" : "yellow"
|
||||
}
|
||||
|
||||
const pinnedIndicator = () => {
|
||||
return props.feed.isPinned ? "*" : " "
|
||||
}
|
||||
|
||||
if (props.compact) {
|
||||
// Compact single-line view
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={props.isSelected ? "#333" : undefined}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
{props.feed.customName || props.feed.podcast.title}
|
||||
</text>
|
||||
{props.showEpisodeCount && (
|
||||
<text fg="gray">({episodeCount()})</text>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
// Full view with details
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
border={props.isSelected}
|
||||
borderColor={props.isSelected ? "cyan" : undefined}
|
||||
backgroundColor={props.isSelected ? "#222" : undefined}
|
||||
padding={1}
|
||||
>
|
||||
{/* Title row */}
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={props.isSelected ? "cyan" : "gray"}>
|
||||
{props.isSelected ? ">" : " "}
|
||||
</text>
|
||||
<text fg={visibilityColor()}>{visibilityIcon()}</text>
|
||||
<text fg="yellow">{pinnedIndicator()}</text>
|
||||
<text fg={props.isSelected ? "white" : undefined}>
|
||||
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
|
||||
</text>
|
||||
</box>
|
||||
|
||||
{/* Details row */}
|
||||
<box flexDirection="row" gap={2} paddingLeft={4}>
|
||||
{props.showEpisodeCount && (
|
||||
<text fg="gray">
|
||||
{episodeCount()} episodes ({unplayedCount()} new)
|
||||
</text>
|
||||
)}
|
||||
{props.showLastUpdated && (
|
||||
<text fg="gray">Updated: {formatDate(props.feed.lastUpdated)}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Description (truncated) */}
|
||||
{props.feed.podcast.description && (
|
||||
<box paddingLeft={4} paddingTop={0}>
|
||||
<text fg="gray">
|
||||
{props.feed.podcast.description.slice(0, 60)}
|
||||
{props.feed.podcast.description.length > 60 ? "..." : ""}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
/**
|
||||
* Feed list component for PodTUI
|
||||
* Scrollable list of feeds with keyboard navigation and mouse support
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { FeedItem } from "./FeedItem"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { FeedVisibility, FeedSortField } from "../types/feed"
|
||||
import type { Feed } from "../types/feed"
|
||||
|
||||
interface FeedListProps {
|
||||
focused?: boolean
|
||||
compact?: boolean
|
||||
showEpisodeCount?: boolean
|
||||
showLastUpdated?: boolean
|
||||
onSelectFeed?: (feed: Feed) => void
|
||||
onOpenFeed?: (feed: Feed) => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}
|
||||
|
||||
export function FeedList(props: FeedListProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
|
||||
const filteredFeeds = () => feedStore.getFilteredFeeds()
|
||||
|
||||
const handleKeyPress = (key: { name: string }) => {
|
||||
if (key.name === "escape") {
|
||||
props.onFocusChange?.(false)
|
||||
return
|
||||
}
|
||||
const feeds = filteredFeeds()
|
||||
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1))
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
const feed = feeds[selectedIndex()]
|
||||
if (feed && props.onOpenFeed) {
|
||||
props.onOpenFeed(feed)
|
||||
}
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(feeds.length - 1)
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 5))
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
|
||||
} else if (key.name === "p") {
|
||||
// Toggle pin on selected feed
|
||||
const feed = feeds[selectedIndex()]
|
||||
if (feed) {
|
||||
feedStore.togglePinned(feed.id)
|
||||
}
|
||||
} else if (key.name === "f") {
|
||||
// Cycle visibility filter
|
||||
cycleVisibilityFilter()
|
||||
} else if (key.name === "s") {
|
||||
// Cycle sort
|
||||
cycleSortField()
|
||||
}
|
||||
|
||||
// Notify selection change
|
||||
const selectedFeed = feeds[selectedIndex()]
|
||||
if (selectedFeed && props.onSelectFeed) {
|
||||
props.onSelectFeed(selectedFeed)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
handleKeyPress(key)
|
||||
})
|
||||
|
||||
const cycleVisibilityFilter = () => {
|
||||
const current = feedStore.filter().visibility
|
||||
let next: FeedVisibility | "all"
|
||||
if (current === "all") next = FeedVisibility.PUBLIC
|
||||
else if (current === FeedVisibility.PUBLIC) next = FeedVisibility.PRIVATE
|
||||
else next = "all"
|
||||
feedStore.setFilter({ ...feedStore.filter(), visibility: next })
|
||||
}
|
||||
|
||||
const cycleSortField = () => {
|
||||
const sortOptions: FeedSortField[] = [
|
||||
FeedSortField.UPDATED,
|
||||
FeedSortField.TITLE,
|
||||
FeedSortField.EPISODE_COUNT,
|
||||
FeedSortField.LATEST_EPISODE,
|
||||
]
|
||||
const current = feedStore.filter().sortBy as FeedSortField
|
||||
const idx = sortOptions.indexOf(current)
|
||||
const next = sortOptions[(idx + 1) % sortOptions.length]
|
||||
feedStore.setFilter({ ...feedStore.filter(), sortBy: next })
|
||||
}
|
||||
|
||||
const visibilityLabel = () => {
|
||||
const vis = feedStore.filter().visibility
|
||||
if (vis === "all") return "All"
|
||||
if (vis === "public") return "Public"
|
||||
return "Private"
|
||||
}
|
||||
|
||||
const sortLabel = () => {
|
||||
const sort = feedStore.filter().sortBy
|
||||
switch (sort) {
|
||||
case "title": return "Title"
|
||||
case "episodeCount": return "Episodes"
|
||||
case "latestEpisode": return "Latest"
|
||||
default: return "Updated"
|
||||
}
|
||||
}
|
||||
|
||||
const handleFeedClick = (feed: Feed, index: number) => {
|
||||
setSelectedIndex(index)
|
||||
if (props.onSelectFeed) {
|
||||
props.onSelectFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFeedDoubleClick = (feed: Feed) => {
|
||||
if (props.onOpenFeed) {
|
||||
props.onOpenFeed(feed)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Header with filter controls */}
|
||||
<box flexDirection="row" justifyContent="space-between" paddingBottom={0}>
|
||||
<text>
|
||||
<strong>My Feeds</strong>
|
||||
</text>
|
||||
<text fg="gray">({filteredFeeds().length} feeds)</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleVisibilityFilter}
|
||||
>
|
||||
<text fg="cyan">[f] {visibilityLabel()}</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
onMouseDown={cycleSortField}
|
||||
>
|
||||
<text fg="cyan">[s] {sortLabel()}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Feed list in scrollbox */}
|
||||
<Show
|
||||
when={filteredFeeds().length > 0}
|
||||
fallback={
|
||||
<box border padding={2}>
|
||||
<text fg="gray">
|
||||
No feeds found. Add podcasts from the Discover or Search tabs.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={15} focused={props.focused}>
|
||||
<For each={filteredFeeds()}>
|
||||
{(feed, index) => (
|
||||
<box onMouseDown={() => handleFeedClick(feed, index())}>
|
||||
<FeedItem
|
||||
feed={feed}
|
||||
isSelected={index() === selectedIndex()}
|
||||
compact={props.compact}
|
||||
showEpisodeCount={props.showEpisodeCount ?? true}
|
||||
showLastUpdated={props.showLastUpdated ?? true}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
|
||||
{/* Navigation help */}
|
||||
<box paddingTop={0}>
|
||||
<text fg="gray">
|
||||
Enter open | Esc up | j/k navigate | p pin | f filter | s sort
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* FeedPage - Shows latest episodes across all subscribed shows
|
||||
* 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"
|
||||
|
||||
type FeedPageProps = {
|
||||
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 allEpisodes = () => feedStore.getAllEpisodesChronological()
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return format(date, "MMM d, yyyy")
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await feedStore.refreshAllFeeds()
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
|
||||
const episodes = allEpisodes()
|
||||
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 1))
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
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)
|
||||
} else if (key.name === "home" || key.name === "g") {
|
||||
setSelectedIndex(0)
|
||||
} else if (key.name === "end") {
|
||||
setSelectedIndex(episodes.length - 1)
|
||||
} else if (key.name === "pageup") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 10))
|
||||
} else if (key.name === "pagedown") {
|
||||
setSelectedIndex((i) => Math.min(episodes.length - 1, i + 10))
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
} else if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
{/* Status line */}
|
||||
<Show when={isRefreshing()}>
|
||||
<text fg="yellow">Refreshing feeds...</text>
|
||||
</Show>
|
||||
|
||||
{/* Episode list */}
|
||||
<Show
|
||||
when={allEpisodes().length > 0}
|
||||
fallback={
|
||||
<box padding={2}>
|
||||
<text fg="gray">
|
||||
No episodes yet. Subscribe to shows from Discover or Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused}>
|
||||
<For each={allEpisodes()}>
|
||||
{(item, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={0}
|
||||
paddingBottom={0}
|
||||
backgroundColor={index() === selectedIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => setSelectedIndex(index())}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={index() === selectedIndex() ? "cyan" : "gray"}>
|
||||
{index() === selectedIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === selectedIndex() ? "white" : undefined}>
|
||||
{item.episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<text fg="cyan">{item.feed.podcast.title}</text>
|
||||
<text fg="gray">{formatDate(item.episode.pubDate)}</text>
|
||||
<text fg="gray">{formatDuration(item.episode.duration)}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { detectFormat } from "../utils/file-detector"
|
||||
|
||||
type FilePickerProps = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function FilePicker(props: FilePickerProps) {
|
||||
const format = detectFormat(props.value)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<input
|
||||
value={props.value}
|
||||
onInput={props.onChange}
|
||||
placeholder="/path/to/sync-file.json"
|
||||
style={{ width: 40 }}
|
||||
/>
|
||||
<text>Format: {format}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
|
||||
import { FilePicker } from "./FilePicker"
|
||||
|
||||
export function ImportDialog() {
|
||||
const filePath = createSignal("")
|
||||
|
||||
return (
|
||||
<box border title="Import" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||
<FilePicker value={filePath[0]()} onChange={filePath[1]} />
|
||||
<box border>
|
||||
<text>Import selected file</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Login screen component for PodTUI
|
||||
* Email/password login with links to code validation and OAuth
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { AUTH_CONFIG } from "../config/auth"
|
||||
|
||||
interface LoginScreenProps {
|
||||
focused?: boolean
|
||||
onNavigateToCode?: () => void
|
||||
onNavigateToOAuth?: () => void
|
||||
}
|
||||
|
||||
type FocusField = "email" | "password" | "submit" | "code" | "oauth"
|
||||
|
||||
export function LoginScreen(props: LoginScreenProps) {
|
||||
const auth = useAuthStore()
|
||||
const { theme } = useTheme()
|
||||
const [email, setEmail] = createSignal("")
|
||||
const [password, setPassword] = createSignal("")
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("email")
|
||||
const [emailError, setEmailError] = createSignal<string | null>(null)
|
||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||
|
||||
const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"]
|
||||
|
||||
const validateEmail = (value: string): boolean => {
|
||||
if (!value) {
|
||||
setEmailError("Email is required")
|
||||
return false
|
||||
}
|
||||
if (!AUTH_CONFIG.email.pattern.test(value)) {
|
||||
setEmailError("Invalid email format")
|
||||
return false
|
||||
}
|
||||
setEmailError(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const validatePassword = (value: string): boolean => {
|
||||
if (!value) {
|
||||
setPasswordError("Password is required")
|
||||
return false
|
||||
}
|
||||
if (value.length < AUTH_CONFIG.password.minLength) {
|
||||
setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`)
|
||||
return false
|
||||
}
|
||||
setPasswordError(null)
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isEmailValid = validateEmail(email())
|
||||
const isPasswordValid = validatePassword(password())
|
||||
|
||||
if (!isEmailValid || !isPasswordValid) {
|
||||
return
|
||||
}
|
||||
|
||||
await auth.login({ email: email(), password: password() })
|
||||
}
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "submit") {
|
||||
handleSubmit()
|
||||
} else if (focusField() === "code" && props.onNavigateToCode) {
|
||||
props.onNavigateToCode()
|
||||
} else if (focusField() === "oauth" && props.onNavigateToOAuth) {
|
||||
props.onNavigateToOAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>Sign In</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Email field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={focusField() === "email" ? theme.primary : undefined}>Email:</text>
|
||||
<input
|
||||
value={email()}
|
||||
onInput={setEmail}
|
||||
placeholder="your@email.com"
|
||||
focused={props.focused && focusField() === "email"}
|
||||
width={30}
|
||||
/>
|
||||
{emailError() && (
|
||||
<text fg={theme.error}>{emailError()}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
{/* Password field */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg={focusField() === "password" ? theme.primary : undefined}>
|
||||
Password:
|
||||
</text>
|
||||
<input
|
||||
value={password()}
|
||||
onInput={setPassword}
|
||||
placeholder="********"
|
||||
focused={props.focused && focusField() === "password"}
|
||||
width={30}
|
||||
/>
|
||||
{passwordError() && (
|
||||
<text fg={theme.error}>{passwordError()}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Submit button */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "submit" ? theme.primary : undefined}
|
||||
>
|
||||
<text fg={focusField() === "submit" ? theme.text : undefined}>
|
||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Auth error message */}
|
||||
{auth.error && (
|
||||
<text fg={theme.error}>{auth.error.message}</text>
|
||||
)}
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative auth options */}
|
||||
<text fg={theme.textMuted}>Or authenticate with:</text>
|
||||
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? theme.primary : undefined}
|
||||
>
|
||||
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
|
||||
[C] Sync Code
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
|
||||
>
|
||||
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
|
||||
[O] OAuth Info
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* MyShowsPage - Two-panel file-explorer style view
|
||||
* Left panel: list of subscribed shows
|
||||
* Right panel: episodes for the selected show
|
||||
*/
|
||||
|
||||
import { createSignal, For, Show, createMemo, createEffect } 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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/** Threshold: load more when within this many items of the end */
|
||||
const LOAD_MORE_THRESHOLD = 5
|
||||
|
||||
const shows = () => feedStore.getFilteredFeeds()
|
||||
|
||||
const selectedShow = createMemo(() => {
|
||||
const s = shows()
|
||||
const idx = showIndex()
|
||||
return idx < s.length ? s[idx] : undefined
|
||||
})
|
||||
|
||||
const episodes = createMemo(() => {
|
||||
const show = selectedShow()
|
||||
if (!show) return []
|
||||
return [...show.episodes].sort(
|
||||
(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 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")
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
/** Get download status label for an episode */
|
||||
const downloadLabel = (episodeId: string): string => {
|
||||
const status = downloadStore.getDownloadStatus(episodeId)
|
||||
switch (status) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "[Q]"
|
||||
case DownloadStatus.DOWNLOADING: {
|
||||
const pct = downloadStore.getDownloadProgress(episodeId)
|
||||
return `[${pct}%]`
|
||||
}
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "[DL]"
|
||||
case DownloadStatus.FAILED:
|
||||
return "[ERR]"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/** Get download status color */
|
||||
const downloadColor = (episodeId: string): string => {
|
||||
const status = downloadStore.getDownloadStatus(episodeId)
|
||||
switch (status) {
|
||||
case DownloadStatus.QUEUED:
|
||||
return "yellow"
|
||||
case DownloadStatus.DOWNLOADING:
|
||||
return "cyan"
|
||||
case DownloadStatus.COMPLETED:
|
||||
return "green"
|
||||
case DownloadStatus.FAILED:
|
||||
return "red"
|
||||
default:
|
||||
return "gray"
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const show = selectedShow()
|
||||
if (!show) return
|
||||
setIsRefreshing(true)
|
||||
await feedStore.refreshFeed(show.id)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
const handleUnsubscribe = () => {
|
||||
const show = selectedShow()
|
||||
if (!show) return
|
||||
feedStore.removeFeed(show.id)
|
||||
setShowIndex((i) => Math.max(0, i - 1))
|
||||
setEpisodeIndex(0)
|
||||
}
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
|
||||
const pane = focusPane()
|
||||
|
||||
// Navigate between panes
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
if (pane === "shows" && selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
if (pane === "episodes") {
|
||||
setFocusPane("shows")
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === "tab") {
|
||||
if (pane === "shows" && selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
} else {
|
||||
setFocusPane("shows")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pane === "shows") {
|
||||
const s = shows()
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
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)
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (selectedShow()) {
|
||||
setFocusPane("episodes")
|
||||
setEpisodeIndex(0)
|
||||
}
|
||||
} else if (key.name === "d") {
|
||||
handleUnsubscribe()
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
} else if (key.name === "escape") {
|
||||
props.onExit?.()
|
||||
}
|
||||
} else if (pane === "episodes") {
|
||||
const eps = episodes()
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 1))
|
||||
} else if (key.name === "up" || key.name === "k") {
|
||||
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)
|
||||
} else if (key.name === "d") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
} else if (key.name === "pageup") {
|
||||
setEpisodeIndex((i) => Math.max(0, i - 10))
|
||||
} else if (key.name === "pagedown") {
|
||||
setEpisodeIndex((i) => Math.min(eps.length - 1, i + 10))
|
||||
} else if (key.name === "r") {
|
||||
handleRefresh()
|
||||
} else if (key.name === "escape") {
|
||||
setFocusPane("shows")
|
||||
key.stopPropagation()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
showsPanel: () => (
|
||||
<box flexDirection="column" height="100%">
|
||||
<Show when={isRefreshing()}>
|
||||
<text fg="yellow">Refreshing...</text>
|
||||
</Show>
|
||||
<Show
|
||||
when={shows().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">
|
||||
No shows yet. Subscribe from Discover or Search.
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused && focusPane() === "shows"}>
|
||||
<For each={shows()}>
|
||||
{(feed, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === showIndex() ? "#333" : undefined}
|
||||
onMouseDown={() => {
|
||||
setShowIndex(index())
|
||||
setEpisodeIndex(0)
|
||||
}}
|
||||
>
|
||||
<text fg={index() === showIndex() ? "cyan" : "gray"}>
|
||||
{index() === showIndex() ? ">" : " "}
|
||||
</text>
|
||||
<text fg={index() === showIndex() ? "white" : undefined}>
|
||||
{feed.customName || feed.podcast.title}
|
||||
</text>
|
||||
<text fg="gray">({feed.episodes.length})</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
|
||||
episodesPanel: () => (
|
||||
<box flexDirection="column" height="100%">
|
||||
<Show
|
||||
when={selectedShow()}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">Select a show</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={episodes().length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">No episodes. Press [r] to refresh.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height="100%" focused={props.focused && focusPane() === "episodes"}>
|
||||
<For each={episodes()}>
|
||||
{(episode, index) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
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} ` : ""}
|
||||
{episode.title}
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} paddingLeft={2}>
|
||||
<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>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={feedStore.isLoadingMore()}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<text fg="yellow">Loading more episodes...</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
|
||||
<box paddingLeft={2} paddingTop={1}>
|
||||
<text fg="gray">Scroll down for more episodes</text>
|
||||
</box>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
),
|
||||
|
||||
focusPane,
|
||||
selectedShow,
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* OAuth placeholder component for PodTUI
|
||||
* Displays OAuth limitations and alternative authentication methods
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
|
||||
|
||||
interface OAuthPlaceholderProps {
|
||||
focused?: boolean
|
||||
onBack?: () => void
|
||||
onNavigateToCode?: () => void
|
||||
}
|
||||
|
||||
type FocusField = "code" | "back"
|
||||
|
||||
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("code")
|
||||
|
||||
const fields: FocusField[] = ["code", "back"]
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "code" && props.onNavigateToCode) {
|
||||
props.onNavigateToCode()
|
||||
} else if (focusField() === "back" && props.onBack) {
|
||||
props.onBack()
|
||||
}
|
||||
} else if (key.name === "escape" && props.onBack) {
|
||||
props.onBack()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>OAuth Authentication</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* OAuth providers list */}
|
||||
<text fg="cyan">Available OAuth Providers:</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
{OAUTH_PROVIDERS.map((provider) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={provider.enabled ? "green" : "gray"}>
|
||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
||||
</text>
|
||||
<text fg="gray">- {provider.description}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Limitation message */}
|
||||
<box border padding={1} borderColor="yellow">
|
||||
<text fg="yellow">Terminal Limitations</text>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={1}>
|
||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
||||
<text fg="gray">{line}</text>
|
||||
))}
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Alternative options */}
|
||||
<text fg="cyan">Recommended Alternatives:</text>
|
||||
|
||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[1]</text>
|
||||
<text fg="white">Use a sync code from the web portal</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[2]</text>
|
||||
<text fg="white">Use email/password authentication</text>
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="green">[3]</text>
|
||||
<text fg="white">Use file-based sync (no account needed)</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "code" ? "#333" : undefined}
|
||||
>
|
||||
<text fg={focusField() === "code" ? "cyan" : undefined}>
|
||||
[C] Enter Sync Code
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "back" ? "#333" : undefined}
|
||||
>
|
||||
<text fg={focusField() === "back" ? "yellow" : "gray"}>
|
||||
[Esc] Back to Login
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter to select, Esc to go back</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { BackendName } from "../utils/audio-player"
|
||||
|
||||
type PlaybackControlsProps = {
|
||||
isPlaying: boolean
|
||||
volume: number
|
||||
speed: number
|
||||
backendName?: BackendName
|
||||
hasAudioUrl?: boolean
|
||||
onToggle: () => void
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onVolumeChange: (value: number) => void
|
||||
onSpeedChange: (value: number) => void
|
||||
}
|
||||
|
||||
const BACKEND_LABELS: Record<BackendName, string> = {
|
||||
mpv: "mpv",
|
||||
ffplay: "ffplay",
|
||||
afplay: "afplay",
|
||||
system: "system",
|
||||
none: "none",
|
||||
}
|
||||
|
||||
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||
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>
|
||||
{props.backendName && props.backendName !== "none" && (
|
||||
<box flexDirection="row" gap={1} marginLeft={2}>
|
||||
<text fg="gray">via</text>
|
||||
<text fg="cyan">{BACKEND_LABELS[props.backendName]}</text>
|
||||
</box>
|
||||
)}
|
||||
{props.backendName === "none" && (
|
||||
<box marginLeft={2}>
|
||||
<text fg="yellow">No audio player found</text>
|
||||
</box>
|
||||
)}
|
||||
{props.hasAudioUrl === false && (
|
||||
<box marginLeft={2}>
|
||||
<text fg="yellow">No audio URL</text>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* PodcastCard component - Reusable card for displaying podcast info
|
||||
*/
|
||||
|
||||
import { Show, For } from "solid-js"
|
||||
import type { Podcast } from "../types/podcast"
|
||||
|
||||
type PodcastCardProps = {
|
||||
podcast: Podcast
|
||||
selected: boolean
|
||||
compact?: boolean
|
||||
onSelect?: () => void
|
||||
onSubscribe?: () => void
|
||||
}
|
||||
|
||||
export function PodcastCard(props: PodcastCardProps) {
|
||||
const handleSubscribeClick = () => {
|
||||
props.onSubscribe?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
backgroundColor={props.selected ? "#333" : undefined}
|
||||
onMouseDown={props.onSelect}
|
||||
>
|
||||
{/* Title Row */}
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text fg={props.selected ? "cyan" : "white"}>
|
||||
<strong>{props.podcast.title}</strong>
|
||||
</text>
|
||||
|
||||
<Show when={props.podcast.isSubscribed}>
|
||||
<text fg="green">[+]</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Author */}
|
||||
<Show when={props.podcast.author && !props.compact}>
|
||||
<text fg="gray">by {props.podcast.author}</text>
|
||||
</Show>
|
||||
|
||||
{/* Description */}
|
||||
<Show when={props.podcast.description && !props.compact}>
|
||||
<text fg={props.selected ? "white" : "gray"}>
|
||||
{props.podcast.description!.length > 80
|
||||
? props.podcast.description!.slice(0, 80) + "..."
|
||||
: props.podcast.description}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
{/* Categories and Subscribe Button */}
|
||||
<box flexDirection="row" justifyContent="space-between" marginTop={props.compact ? 0 : 1}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Show when={(props.podcast.categories ?? []).length > 0}>
|
||||
<For each={(props.podcast.categories ?? []).slice(0, 2)}>
|
||||
{(cat) => <text fg="yellow">[{cat}]</text>}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={props.selected}>
|
||||
<box onMouseDown={handleSubscribeClick}>
|
||||
<text fg={props.podcast.isSubscribed ? "red" : "green"}>
|
||||
{props.podcast.isSubscribed ? "[Unsubscribe]" : "[Subscribe]"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import type { ThemeName } from "../types/settings"
|
||||
|
||||
type FocusField = "theme" | "font" | "speed" | "explicit" | "auto"
|
||||
|
||||
const THEME_LABELS: Array<{ value: ThemeName; label: string }> = [
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "catppuccin", label: "Catppuccin" },
|
||||
{ value: "gruvbox", label: "Gruvbox" },
|
||||
{ value: "tokyo", label: "Tokyo" },
|
||||
{ value: "nord", label: "Nord" },
|
||||
{ value: "custom", label: "Custom" },
|
||||
]
|
||||
|
||||
export function PreferencesPanel() {
|
||||
const appStore = useAppStore()
|
||||
const { theme } = useTheme()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("theme")
|
||||
|
||||
const settings = () => appStore.state().settings
|
||||
const preferences = () => appStore.state().preferences
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const fields: FocusField[] = ["theme", "font", "speed", "explicit", "auto"]
|
||||
const idx = fields.indexOf(focusField())
|
||||
const next = key.shift
|
||||
? (idx - 1 + fields.length) % fields.length
|
||||
: (idx + 1) % fields.length
|
||||
setFocusField(fields[next])
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1)
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1)
|
||||
}
|
||||
if (key.name === "space" || key.name === "return" || 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={theme.textMuted}>Preferences</text>
|
||||
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "theme" ? theme.primary : theme.textMuted}>Theme:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{THEME_LABELS.find((t) => t.value === settings().theme)?.label}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "font" ? theme.primary : theme.textMuted}>Font Size:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{settings().fontSize}px</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "speed" ? theme.primary : theme.textMuted}>Playback:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{settings().playbackSpeed}x</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "explicit" ? theme.primary : theme.textMuted}>Show Explicit:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={preferences().showExplicit ? theme.success : theme.textMuted}>
|
||||
{preferences().showExplicit ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "auto" ? theme.primary : theme.textMuted}>Auto Download:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={preferences().autoDownload ? theme.success : theme.textMuted}>
|
||||
{preferences().autoDownload ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Space]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
/**
|
||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
||||
*
|
||||
* Replaces MergedWaveform during playback. 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"
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────
|
||||
|
||||
export type RealtimeWaveformProps = {
|
||||
/** Audio URL — used to start the ffmpeg decode stream */
|
||||
audioUrl: string
|
||||
/** Current playback position in seconds */
|
||||
position: number
|
||||
/** Total duration in seconds */
|
||||
duration: number
|
||||
/** Whether audio is currently playing */
|
||||
isPlaying: boolean
|
||||
/** Playback speed multiplier (default: 1) */
|
||||
speed?: number
|
||||
/** Number of frequency bars / columns */
|
||||
resolution?: number
|
||||
/** Callback when user clicks to seek */
|
||||
onSeek?: (seconds: number) => void
|
||||
/** Visualizer configuration overrides */
|
||||
visualizerConfig?: Partial<CavaCoreConfig>
|
||||
}
|
||||
|
||||
/** Unicode lower block elements: space (silence) through full block (max) */
|
||||
const BARS = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"]
|
||||
|
||||
/** Target frame interval in ms (~30 fps) */
|
||||
const FRAME_INTERVAL = 33
|
||||
|
||||
/** Number of PCM samples to read per frame (512 is a good FFT window) */
|
||||
const SAMPLES_PER_FRAME = 512
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────
|
||||
|
||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
||||
const resolution = () => props.resolution ?? 32
|
||||
|
||||
// Frequency bar values (0.0–1.0 per bar)
|
||||
const [barData, setBarData] = createSignal<number[]>([])
|
||||
|
||||
// Track whether cavacore is available
|
||||
const [available, setAvailable] = createSignal(false)
|
||||
|
||||
let cava: CavaCore | null = null
|
||||
let reader: AudioStreamReader | null = null
|
||||
let frameTimer: ReturnType<typeof setInterval> | null = null
|
||||
let sampleBuffer: Float64Array | null = null
|
||||
|
||||
// ── Lifecycle: init cavacore once ──────────────────────────────────
|
||||
|
||||
const initCava = () => {
|
||||
if (cava) return true
|
||||
|
||||
cava = loadCavaCore()
|
||||
if (!cava) {
|
||||
setAvailable(false)
|
||||
return false
|
||||
}
|
||||
|
||||
setAvailable(true)
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||
|
||||
const startVisualization = (url: string, position: number, speed: number) => {
|
||||
stopVisualization()
|
||||
|
||||
if (!url || !initCava() || !cava) return
|
||||
|
||||
// Initialize cavacore with current resolution + any overrides
|
||||
const config: CavaCoreConfig = {
|
||||
bars: resolution(),
|
||||
sampleRate: 44100,
|
||||
channels: 1,
|
||||
...props.visualizerConfig,
|
||||
}
|
||||
cava.init(config)
|
||||
|
||||
// Pre-allocate sample read buffer
|
||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME)
|
||||
|
||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
||||
if (!reader || reader.url !== url) {
|
||||
if (reader) reader.stop()
|
||||
reader = new AudioStreamReader({ url })
|
||||
}
|
||||
reader.start(position, speed)
|
||||
|
||||
// Start render loop
|
||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL)
|
||||
}
|
||||
|
||||
const stopVisualization = () => {
|
||||
if (frameTimer) {
|
||||
clearInterval(frameTimer)
|
||||
frameTimer = null
|
||||
}
|
||||
if (reader) {
|
||||
reader.stop()
|
||||
// Don't null reader — we reuse it across start/stop cycles
|
||||
}
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
}
|
||||
sampleBuffer = null
|
||||
}
|
||||
|
||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||
|
||||
const renderFrame = () => {
|
||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return
|
||||
|
||||
// Read available PCM samples from the stream
|
||||
const count = reader.read(sampleBuffer)
|
||||
if (count === 0) return
|
||||
|
||||
// Feed samples to cavacore → get frequency bars
|
||||
const input = count < sampleBuffer.length
|
||||
? sampleBuffer.subarray(0, count)
|
||||
: sampleBuffer
|
||||
const output = cava.execute(input)
|
||||
|
||||
// Copy bar values to a new array for the signal
|
||||
setBarData(Array.from(output))
|
||||
}
|
||||
|
||||
// ── Single unified effect: respond to all prop changes ─────────────
|
||||
//
|
||||
// Instead of three competing effects that each independently call
|
||||
// startVisualization() and race against each other, we use ONE effect
|
||||
// that tracks all relevant inputs. Position is read with untrack()
|
||||
// so normal playback drift doesn't trigger restarts.
|
||||
//
|
||||
// SolidJS on() with an array of accessors compares each element
|
||||
// individually, so the effect only fires when a value actually changes.
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
() => props.isPlaying,
|
||||
() => props.audioUrl,
|
||||
() => props.speed ?? 1,
|
||||
resolution,
|
||||
],
|
||||
([playing, url, speed]) => {
|
||||
if (playing && url) {
|
||||
const pos = untrack(() => props.position)
|
||||
startVisualization(url, pos, speed)
|
||||
} else {
|
||||
stopVisualization()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
||||
//
|
||||
// Watches position and restarts the reader (not the whole pipeline)
|
||||
// only on significant jumps (>2s), which indicate a user seek.
|
||||
// This is intentionally a separate effect — it should NOT trigger a
|
||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
||||
|
||||
let lastSyncPosition = 0
|
||||
createEffect(
|
||||
on(
|
||||
() => props.position,
|
||||
(pos) => {
|
||||
if (!props.isPlaying || !reader?.running) {
|
||||
lastSyncPosition = pos
|
||||
return
|
||||
}
|
||||
|
||||
const delta = Math.abs(pos - lastSyncPosition)
|
||||
lastSyncPosition = pos
|
||||
|
||||
if (delta > 2) {
|
||||
const speed = props.speed ?? 1
|
||||
reader.restart(pos, speed)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Cleanup on unmount
|
||||
onCleanup(() => {
|
||||
stopVisualization()
|
||||
if (reader) {
|
||||
reader.stop()
|
||||
reader = null
|
||||
}
|
||||
// Don't null cava itself — it can be reused. But do destroy its plan.
|
||||
if (cava?.isReady) {
|
||||
cava.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Rendering ──────────────────────────────────────────────────────
|
||||
|
||||
const playedRatio = () =>
|
||||
props.duration <= 0 ? 0 : Math.min(1, props.position / props.duration)
|
||||
|
||||
const renderLine = () => {
|
||||
const bars = barData()
|
||||
const numBars = resolution()
|
||||
|
||||
// If no data yet, show empty placeholder
|
||||
if (bars.length === 0) {
|
||||
const placeholder = ".".repeat(numBars)
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg="#3b4252">{placeholder}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const played = Math.floor(numBars * playedRatio())
|
||||
const playedColor = 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("")
|
||||
|
||||
const futureChars = bars
|
||||
.slice(played)
|
||||
.map((v) => BARS[Math.min(BARS.length - 1, Math.floor(v * BARS.length))])
|
||||
.join("")
|
||||
|
||||
return (
|
||||
<box flexDirection="row" gap={0}>
|
||||
<text fg={playedColor}>{playedChars || " "}</text>
|
||||
<text fg={futureColor}>{futureChars || " "}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = (event: { x: number }) => {
|
||||
const numBars = 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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())}</>
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function ResultCard(props: ResultCardProps) {
|
||||
const podcast = () => props.result.podcast
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
border={props.selected}
|
||||
borderColor={props.selected ? "cyan" : undefined}
|
||||
backgroundColor={props.selected ? "#222" : undefined}
|
||||
onMouseDown={props.onSelect}
|
||||
>
|
||||
<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>
|
||||
</text>
|
||||
<SourceBadge
|
||||
sourceId={props.result.sourceId}
|
||||
sourceName={props.result.sourceName}
|
||||
sourceType={props.result.sourceType}
|
||||
/>
|
||||
</box>
|
||||
<Show when={podcast().isSubscribed}>
|
||||
<text fg="green">[Subscribed]</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show when={podcast().author}>
|
||||
<text fg="gray">by {podcast().author}</text>
|
||||
</Show>
|
||||
|
||||
<Show when={podcast().description}>
|
||||
{(description) => (
|
||||
<text fg={props.selected ? "white" : "gray"}>
|
||||
{description().length > 120
|
||||
? description().slice(0, 120) + "..."
|
||||
: description()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={(podcast().categories ?? []).length > 0}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
{(podcast().categories ?? []).slice(0, 3).map((category) => (
|
||||
<text fg="yellow">[{category}]</text>
|
||||
))}
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!podcast().isSubscribed}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={18}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation?.()
|
||||
props.onSubscribe?.()
|
||||
}}
|
||||
>
|
||||
<text fg="cyan">[+] Add to Feeds</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* SearchHistory component for displaying and managing search history
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
|
||||
type SearchHistoryProps = {
|
||||
history: string[]
|
||||
focused: boolean
|
||||
selectedIndex: number
|
||||
onSelect?: (query: string) => void
|
||||
onRemove?: (query: string) => void
|
||||
onClear?: () => void
|
||||
onChange?: (index: number) => void
|
||||
}
|
||||
|
||||
export function SearchHistory(props: SearchHistoryProps) {
|
||||
const handleSearchClick = (index: number, query: string) => {
|
||||
props.onChange?.(index)
|
||||
props.onSelect?.(query)
|
||||
}
|
||||
|
||||
const handleRemoveClick = (query: string) => {
|
||||
props.onRemove?.(query)
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg="gray">Recent Searches</text>
|
||||
<Show when={props.history.length > 0}>
|
||||
<box onMouseDown={() => props.onClear?.()} padding={0}>
|
||||
<text fg="red">[Clear All]</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<Show
|
||||
when={props.history.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">No recent searches</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<scrollbox height={10}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.history}>
|
||||
{(query, index) => {
|
||||
const isSelected = () => index() === props.selectedIndex && props.focused
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={isSelected() ? "#333" : undefined}
|
||||
onMouseDown={() => handleSearchClick(index(), query)}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">{">"}</text>
|
||||
<text fg={isSelected() ? "cyan" : "white"}>{query}</text>
|
||||
</box>
|
||||
<box onMouseDown={() => handleRemoveClick(query)} padding={0}>
|
||||
<text fg="red">[x]</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* 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"
|
||||
|
||||
type SearchPageProps = {
|
||||
focused: boolean
|
||||
onSubscribe?: (result: SearchResult) => void
|
||||
onInputFocusChange?: (focused: boolean) => void
|
||||
onExit?: () => void
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Keep parent informed about input focus state
|
||||
createEffect(() => {
|
||||
const isInputFocused = props.focused && focusArea() === "input"
|
||||
props.onInputFocusChange?.(isInputFocused)
|
||||
})
|
||||
|
||||
const handleSearch = async () => {
|
||||
const query = inputValue().trim()
|
||||
if (query) {
|
||||
await searchStore.search(query)
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setResultIndex(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleHistorySelect = async (query: string) => {
|
||||
setInputValue(query)
|
||||
await searchStore.search(query)
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
setResultIndex(0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResultSelect = (result: SearchResult) => {
|
||||
props.onSubscribe?.(result)
|
||||
searchStore.markSubscribed(result.podcast.id)
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useKeyboard((key) => {
|
||||
if (!props.focused) return
|
||||
|
||||
const area = focusArea()
|
||||
|
||||
// Enter to search from input
|
||||
if ((key.name === "return" || key.name === "enter") && area === "input") {
|
||||
handleSearch()
|
||||
return
|
||||
}
|
||||
|
||||
// Tab to cycle focus areas
|
||||
if (key.name === "tab" && !key.shift) {
|
||||
if (area === "input") {
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
} else if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
}
|
||||
} else if (area === "results") {
|
||||
if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
}
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "tab" && key.shift) {
|
||||
if (area === "input") {
|
||||
if (searchStore.history().length > 0) {
|
||||
setFocusArea("history")
|
||||
} else if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
}
|
||||
} else if (area === "history") {
|
||||
if (searchStore.results().length > 0) {
|
||||
setFocusArea("results")
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
}
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Up/Down for results and history
|
||||
if (area === "results") {
|
||||
const results = searchStore.results()
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
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
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const result = results[resultIndex()]
|
||||
if (result) handleResultSelect(result)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (area === "history") {
|
||||
const history = searchStore.history()
|
||||
if (key.name === "down" || key.name === "j") {
|
||||
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
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
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?.()
|
||||
} else {
|
||||
setFocusArea("input")
|
||||
key.stopPropagation()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// "/" focuses search input
|
||||
if (key.name === "/" && area !== "input") {
|
||||
setFocusArea("input")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" height="100%" gap={1}>
|
||||
{/* Search Header */}
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text>
|
||||
<strong>Search Podcasts</strong>
|
||||
</text>
|
||||
|
||||
{/* Search Input */}
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg="gray">Search:</text>
|
||||
<input
|
||||
value={inputValue()}
|
||||
onInput={(value) => {
|
||||
setInputValue(value)
|
||||
}}
|
||||
placeholder="Enter podcast name, topic, or author..."
|
||||
focused={props.focused && focusArea() === "input"}
|
||||
width={50}
|
||||
/>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
onMouseDown={handleSearch}
|
||||
>
|
||||
<text fg="cyan">[Enter] Search</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Status */}
|
||||
<Show when={searchStore.isSearching()}>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</Show>
|
||||
<Show when={searchStore.error()}>
|
||||
<text fg="red">{searchStore.error()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Main Content - Results or History */}
|
||||
<box flexDirection="row" height="100%" gap={2}>
|
||||
{/* Results Panel */}
|
||||
<box flexDirection="column" flexGrow={1} border>
|
||||
<box padding={1}>
|
||||
<text fg={focusArea() === "results" ? "cyan" : "gray"}>
|
||||
Results ({searchStore.results().length})
|
||||
</text>
|
||||
</box>
|
||||
<Show
|
||||
when={searchStore.results().length > 0}
|
||||
fallback={
|
||||
<box padding={2}>
|
||||
<text fg="gray">
|
||||
{searchStore.query()
|
||||
? "No results found"
|
||||
: "Enter a search term to find podcasts"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<SearchResults
|
||||
results={searchStore.results()}
|
||||
selectedIndex={resultIndex()}
|
||||
focused={focusArea() === "results"}
|
||||
onSelect={handleResultSelect}
|
||||
onChange={setResultIndex}
|
||||
isSearching={searchStore.isSearching()}
|
||||
error={searchStore.error()}
|
||||
/>
|
||||
</Show>
|
||||
</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"}
|
||||
onSelect={handleHistorySelect}
|
||||
onRemove={searchStore.removeFromHistory}
|
||||
onClear={searchStore.clearHistory}
|
||||
onChange={setHistoryIndex}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Footer Hints */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg="gray">[Tab] Switch focus</text>
|
||||
<text fg="gray">[/] Focus search</text>
|
||||
<text fg="gray">[Enter] Select</text>
|
||||
<text fg="gray">[Esc] Up</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* SearchResults component for displaying podcast search results
|
||||
*/
|
||||
|
||||
import { For, Show } from "solid-js"
|
||||
import type { SearchResult } from "../types/source"
|
||||
import { ResultCard } from "./ResultCard"
|
||||
import { ResultDetail } from "./ResultDetail"
|
||||
|
||||
type SearchResultsProps = {
|
||||
results: SearchResult[]
|
||||
selectedIndex: number
|
||||
focused: boolean
|
||||
onSelect?: (result: SearchResult) => void
|
||||
onChange?: (index: number) => void
|
||||
isSearching?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function SearchResults(props: SearchResultsProps) {
|
||||
const handleSelect = (index: number) => {
|
||||
props.onChange?.(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={!props.isSearching} fallback={
|
||||
<box padding={1}>
|
||||
<text fg="yellow">Searching...</text>
|
||||
</box>
|
||||
}>
|
||||
<Show
|
||||
when={!props.error}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="red">{props.error}</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={props.results.length > 0}
|
||||
fallback={
|
||||
<box padding={1}>
|
||||
<text fg="gray">No results found. Try a different search term.</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={1} height="100%">
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<scrollbox height="100%">
|
||||
<box flexDirection="column" gap={1}>
|
||||
<For each={props.results}>
|
||||
{(result, index) => (
|
||||
<ResultCard
|
||||
result={result}
|
||||
selected={index() === props.selectedIndex}
|
||||
onSelect={() => handleSelect(index())}
|
||||
onSubscribe={() => props.onSelect?.(result)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
<box width={36}>
|
||||
<ResultDetail
|
||||
result={props.results[props.selectedIndex]}
|
||||
onSubscribe={(result) => props.onSelect?.(result)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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
|
||||
}
|
||||
|
||||
type SectionId = "sync" | "sources" | "preferences" | "visualizer" | "account"
|
||||
|
||||
const SECTIONS: Array<{ id: SectionId; label: string }> = [
|
||||
{ id: "sync", label: "Sync" },
|
||||
{ id: "sources", label: "Sources" },
|
||||
{ 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")
|
||||
|
||||
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("visualizer")
|
||||
if (key.name === "5") 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={theme.textMuted}>[Tab] Switch section | 1-5 jump | Esc up</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<For each={SECTIONS}>
|
||||
{(section, index) => (
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={activeSection() === section.id ? theme.primary : undefined}
|
||||
onMouseDown={() => setActiveSection(section.id)}
|
||||
>
|
||||
<text fg={activeSection() === section.id ? theme.text : theme.textMuted}>
|
||||
[{index() + 1}] {section.label}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
|
||||
<box border flexGrow={1} padding={1} flexDirection="column" gap={1}>
|
||||
{activeSection() === "sync" && <SyncPanel />}
|
||||
{activeSection() === "sources" && <SourceManager focused />}
|
||||
{activeSection() === "preferences" && <PreferencesPanel />}
|
||||
{activeSection() === "visualizer" && <VisualizerSettings />}
|
||||
{activeSection() === "account" && (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<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}>
|
||||
{props.accountLabel}
|
||||
</text>
|
||||
</box>
|
||||
<box border padding={0} onMouseDown={() => props.onOpenAccount?.()}>
|
||||
<text fg={theme.primary}>[A] Manage Account</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Enter to dive | Esc up</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Source management component for PodTUI
|
||||
* Add, remove, and configure podcast sources
|
||||
*/
|
||||
|
||||
import { createSignal, For } from "solid-js"
|
||||
import { useFeedStore } from "../stores/feed"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { SourceType } from "../types/source"
|
||||
import type { PodcastSource } from "../types/source"
|
||||
|
||||
interface SourceManagerProps {
|
||||
focused?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
type FocusArea = "list" | "add" | "url" | "country" | "explicit" | "language"
|
||||
|
||||
export function SourceManager(props: SourceManagerProps) {
|
||||
const feedStore = useFeedStore()
|
||||
const { theme } = useTheme()
|
||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||
const [focusArea, setFocusArea] = createSignal<FocusArea>("list")
|
||||
const [newSourceUrl, setNewSourceUrl] = createSignal("")
|
||||
const [newSourceName, setNewSourceName] = createSignal("")
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
|
||||
const sources = () => feedStore.sources()
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "escape") {
|
||||
if (focusArea() !== "list") {
|
||||
setFocusArea("list")
|
||||
setError(null)
|
||||
} else if (props.onClose) {
|
||||
props.onClose()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "tab") {
|
||||
const areas: FocusArea[] = [
|
||||
"list",
|
||||
"country",
|
||||
"language",
|
||||
"explicit",
|
||||
"add",
|
||||
"url",
|
||||
]
|
||||
const idx = areas.indexOf(focusArea())
|
||||
const nextIdx = key.shift
|
||||
? (idx - 1 + areas.length) % areas.length
|
||||
: (idx + 1) % areas.length
|
||||
setFocusArea(areas[nextIdx])
|
||||
return
|
||||
}
|
||||
|
||||
if (focusArea() === "list") {
|
||||
if (key.name === "up" || key.name === "k") {
|
||||
setSelectedIndex((i) => Math.max(0, i - 1))
|
||||
} else if (key.name === "down" || key.name === "j") {
|
||||
setSelectedIndex((i) => Math.min(sources().length - 1, i + 1))
|
||||
} else if (key.name === "return" || key.name === "enter" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (source) {
|
||||
feedStore.toggleSource(source.id)
|
||||
}
|
||||
} else if (key.name === "d" || key.name === "delete") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (source) {
|
||||
const removed = feedStore.removeSource(source.id)
|
||||
if (!removed) {
|
||||
setError("Cannot remove default sources")
|
||||
}
|
||||
}
|
||||
} else if (key.name === "a") {
|
||||
setFocusArea("add")
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "country") {
|
||||
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (source && source.type === SourceType.API) {
|
||||
const next = source.country === "US" ? "GB" : "US"
|
||||
feedStore.updateSource(source.id, { country: next })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "explicit") {
|
||||
if (key.name === "enter" || key.name === "return" || key.name === "space") {
|
||||
const source = sources()[selectedIndex()]
|
||||
if (source && source.type === SourceType.API) {
|
||||
feedStore.updateSource(source.id, { allowExplicit: !source.allowExplicit })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (focusArea() === "language") {
|
||||
if (key.name === "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 handleAddSource = () => {
|
||||
const url = newSourceUrl().trim()
|
||||
const name = newSourceName().trim() || `Custom Source`
|
||||
|
||||
if (!url) {
|
||||
setError("URL is required")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url)
|
||||
} catch {
|
||||
setError("Invalid URL format")
|
||||
return
|
||||
}
|
||||
|
||||
feedStore.addSource({
|
||||
name,
|
||||
type: "rss" as SourceType,
|
||||
baseUrl: url,
|
||||
enabled: true,
|
||||
description: `Custom RSS feed: ${url}`,
|
||||
})
|
||||
|
||||
setNewSourceUrl("")
|
||||
setNewSourceName("")
|
||||
setFocusArea("list")
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const getSourceIcon = (source: PodcastSource) => {
|
||||
if (source.type === SourceType.API) return "[API]"
|
||||
if (source.type === SourceType.RSS) return "[RSS]"
|
||||
return "[?]"
|
||||
}
|
||||
|
||||
const selectedSource = () => sources()[selectedIndex()]
|
||||
const isApiSource = () => selectedSource()?.type === SourceType.API
|
||||
const sourceCountry = () => selectedSource()?.country || "US"
|
||||
const sourceExplicit = () => selectedSource()?.allowExplicit !== false
|
||||
const sourceLanguage = () => selectedSource()?.language || "en_us"
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text>
|
||||
<strong>Podcast Sources</strong>
|
||||
</text>
|
||||
<box border padding={0} onMouseDown={props.onClose}>
|
||||
<text fg={theme.primary}>[Esc] Close</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Manage where to search for podcasts</text>
|
||||
|
||||
{/* Source list */}
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "list" ? theme.primary : theme.textMuted}>Sources:</text>
|
||||
<scrollbox height={6}>
|
||||
<For each={sources()}>
|
||||
{(source, index) => (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
padding={0}
|
||||
backgroundColor={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? theme.primary
|
||||
: undefined
|
||||
}
|
||||
onMouseDown={() => {
|
||||
setSelectedIndex(index())
|
||||
setFocusArea("list")
|
||||
feedStore.toggleSource(source.id)
|
||||
}}
|
||||
>
|
||||
<text fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? theme.primary
|
||||
: theme.textMuted
|
||||
}>
|
||||
{focusArea() === "list" && index() === selectedIndex()
|
||||
? ">"
|
||||
: " "}
|
||||
</text>
|
||||
<text fg={source.enabled ? theme.success : theme.error}>
|
||||
{source.enabled ? "[x]" : "[ ]"}
|
||||
</text>
|
||||
<text fg={theme.accent}>{getSourceIcon(source)}</text>
|
||||
<text
|
||||
fg={
|
||||
focusArea() === "list" && index() === selectedIndex()
|
||||
? theme.text
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{source.name}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<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)"}
|
||||
</text>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusArea() === "country" ? theme.primary : undefined}
|
||||
>
|
||||
<text fg={focusArea() === "country" ? theme.primary : theme.textMuted}>
|
||||
Country: {sourceCountry()}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
backgroundColor={focusArea() === "language" ? theme.primary : undefined}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Add new source form */}
|
||||
<box border padding={1} flexDirection="column" gap={1}>
|
||||
<text fg={focusArea() === "add" || focusArea() === "url" ? theme.primary : theme.textMuted}>
|
||||
Add New Source:
|
||||
</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>Name:</text>
|
||||
<input
|
||||
value={newSourceName()}
|
||||
onInput={setNewSourceName}
|
||||
placeholder="My Custom Feed"
|
||||
focused={props.focused && focusArea() === "add"}
|
||||
width={25}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>URL:</text>
|
||||
<input
|
||||
value={newSourceUrl()}
|
||||
onInput={(v) => {
|
||||
setNewSourceUrl(v)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="https://example.com/feed.rss"
|
||||
focused={props.focused && focusArea() === "url"}
|
||||
width={35}
|
||||
/>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={0}
|
||||
width={15}
|
||||
onMouseDown={handleAddSource}
|
||||
>
|
||||
<text fg={theme.success}>[+] Add Source</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Error message */}
|
||||
{error() && (
|
||||
<text fg={theme.error}>{error()}</text>
|
||||
)}
|
||||
|
||||
<text fg={theme.textMuted}>Tab to switch sections, Esc to close</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
type SyncErrorProps = {
|
||||
message: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function SyncError(props: SyncErrorProps) {
|
||||
return (
|
||||
<box border title="Error" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||
<text>{props.message}</text>
|
||||
<box border onMouseDown={props.onRetry}>
|
||||
<text>Retry</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
|
||||
import { ImportDialog } from "./ImportDialog"
|
||||
import { ExportDialog } from "./ExportDialog"
|
||||
import { SyncStatus } from "./SyncStatus"
|
||||
|
||||
export function SyncPanel() {
|
||||
const mode = createSignal<"import" | "export" | null>(null)
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<box border onMouseDown={() => mode[1]("import")}>
|
||||
<text>Import</text>
|
||||
</box>
|
||||
<box border onMouseDown={() => mode[1]("export")}>
|
||||
<text>Export</text>
|
||||
</box>
|
||||
</box>
|
||||
<SyncStatus />
|
||||
{mode[0]() === "import" ? <ImportDialog /> : null}
|
||||
{mode[0]() === "export" ? <ExportDialog /> : null}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* Sync profile component for PodTUI
|
||||
* Displays user profile information and sync status
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import { format } from "date-fns"
|
||||
|
||||
interface SyncProfileProps {
|
||||
focused?: boolean
|
||||
onLogout?: () => void
|
||||
onManageSync?: () => void
|
||||
}
|
||||
|
||||
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 fields: FocusField[] = ["sync", "export", "logout"]
|
||||
|
||||
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const currentIndex = fields.indexOf(focusField())
|
||||
const nextIndex = key.shift
|
||||
? (currentIndex - 1 + fields.length) % fields.length
|
||||
: (currentIndex + 1) % fields.length
|
||||
setFocusField(fields[nextIndex])
|
||||
} else if (key.name === "return" || key.name === "enter") {
|
||||
if (focusField() === "sync" && props.onManageSync) {
|
||||
props.onManageSync()
|
||||
} else if (focusField() === "logout" && props.onLogout) {
|
||||
handleLogout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
auth.logout()
|
||||
if (props.onLogout) {
|
||||
props.onLogout()
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null | undefined): string => {
|
||||
if (!date) return "Never"
|
||||
return format(date, "MMM d, yyyy HH:mm")
|
||||
}
|
||||
|
||||
const user = () => auth.state().user
|
||||
|
||||
// Get user initials for avatar
|
||||
const userInitials = () => {
|
||||
const name = user()?.name || "?"
|
||||
return name.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" border padding={2} gap={1}>
|
||||
<text>
|
||||
<strong>User Profile</strong>
|
||||
</text>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* User avatar and info */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
{/* ASCII avatar */}
|
||||
<box border padding={1} width={8} height={4} justifyContent="center" alignItems="center">
|
||||
<text fg="cyan">{userInitials()}</text>
|
||||
</box>
|
||||
|
||||
{/* User details */}
|
||||
<box flexDirection="column" gap={0}>
|
||||
<text fg="white">{user()?.name || "Guest User"}</text>
|
||||
<text fg="gray">{user()?.email || "No email"}</text>
|
||||
<text fg="gray">Joined: {formatDate(user()?.createdAt)}</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Sync status section */}
|
||||
<box border padding={1} flexDirection="column" gap={0}>
|
||||
<text fg="cyan">Sync Status</text>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Status:</text>
|
||||
<text fg={user()?.syncEnabled ? "green" : "yellow"}>
|
||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Last Sync:</text>
|
||||
<text fg="white">{formatDate(lastSyncTime())}</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg="gray">Method:</text>
|
||||
<text fg="white">File-based (JSON/XML)</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "sync" ? "#333" : undefined}
|
||||
>
|
||||
<text fg={focusField() === "sync" ? "cyan" : undefined}>
|
||||
[S] Manage Sync
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "export" ? "#333" : undefined}
|
||||
>
|
||||
<text fg={focusField() === "export" ? "cyan" : undefined}>
|
||||
[E] Export Data
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box
|
||||
border
|
||||
padding={1}
|
||||
backgroundColor={focusField() === "logout" ? "#333" : undefined}
|
||||
>
|
||||
<text fg={focusField() === "logout" ? "red" : "gray"}>
|
||||
[L] Logout
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box height={1} />
|
||||
|
||||
<text fg="gray">Tab to navigate, Enter to select</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
type SyncProgressProps = {
|
||||
value: number
|
||||
}
|
||||
|
||||
export function SyncProgress(props: SyncProgressProps) {
|
||||
const width = 30
|
||||
let filled = (props.value / 100) * width
|
||||
filled = filled >= 0 ? filled : 0
|
||||
filled = filled <= width ? filled : width
|
||||
filled = filled | 0
|
||||
if (filled < 0) filled = 0
|
||||
if (filled > width) filled = width
|
||||
|
||||
let bar = ""
|
||||
for (let i = 0; i < width; i += 1) {
|
||||
bar += i < filled ? "#" : "-"
|
||||
}
|
||||
|
||||
return (
|
||||
<box style={{ flexDirection: "column" }}>
|
||||
<text>{bar}</text>
|
||||
<text>{props.value}%</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
const createSignal = <T,>(value: T): [() => T, (next: T) => void] => {
|
||||
let current = value
|
||||
return [() => current, (next) => {
|
||||
current = next
|
||||
}]
|
||||
}
|
||||
|
||||
import { SyncProgress } from "./SyncProgress"
|
||||
import { SyncError } from "./SyncError"
|
||||
|
||||
type SyncState = "idle" | "syncing" | "complete" | "error"
|
||||
|
||||
export function SyncStatus() {
|
||||
const state = createSignal<SyncState>("idle")
|
||||
const message = createSignal("Idle")
|
||||
const progress = createSignal(0)
|
||||
|
||||
const toggle = () => {
|
||||
if (state[0]() === "idle") {
|
||||
state[1]("syncing")
|
||||
message[1]("Syncing...")
|
||||
progress[1](40)
|
||||
} else if (state[0]() === "syncing") {
|
||||
state[1]("complete")
|
||||
message[1]("Sync complete")
|
||||
progress[1](100)
|
||||
} else if (state[0]() === "complete") {
|
||||
state[1]("error")
|
||||
message[1]("Sync failed")
|
||||
} else {
|
||||
state[1]("idle")
|
||||
message[1]("Idle")
|
||||
progress[1](0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<box border title="Sync Status" style={{ padding: 1, flexDirection: "column", gap: 1 }}>
|
||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
||||
<text>Status:</text>
|
||||
<text>{message[0]()}</text>
|
||||
</box>
|
||||
<SyncProgress value={progress[0]()} />
|
||||
{state[0]() === "error" ? <SyncError message={message[0]()} onRetry={() => toggle()} /> : null}
|
||||
<box border onMouseDown={toggle}>
|
||||
<text>Cycle Status</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from "../context/ThemeContext";
|
||||
import { useTheme } from "@/context/ThemeContext";
|
||||
|
||||
export type TabId =
|
||||
| "feed"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* TrendingShows component - Grid/list of trending podcasts
|
||||
*/
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function TrendingShows(props: TrendingShowsProps) {
|
||||
return (
|
||||
<box flexDirection="column" height="100%">
|
||||
<Show when={props.isLoading}>
|
||||
<box padding={2}>
|
||||
<text fg="yellow">Loading trending shows...</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.isLoading && props.podcasts.length === 0}>
|
||||
<box padding={2}>
|
||||
<text fg="gray">No podcasts found in this category.</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.isLoading && props.podcasts.length > 0}>
|
||||
<scrollbox height={15}>
|
||||
<box flexDirection="column">
|
||||
<For each={props.podcasts}>
|
||||
{(podcast, index) => (
|
||||
<PodcastCard
|
||||
podcast={podcast}
|
||||
selected={index() === props.selectedIndex && props.focused}
|
||||
onSelect={() => props.onSelect?.(index())}
|
||||
onSubscribe={() => props.onSubscribe?.(podcast)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* VisualizerSettings — settings panel for the real-time audio visualizer.
|
||||
*
|
||||
* Allows adjusting bar count, noise reduction, sensitivity, and
|
||||
* frequency cutoffs. All changes persist via the app store.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useAppStore } from "../stores/app"
|
||||
import { useTheme } from "../context/ThemeContext"
|
||||
import { isCavacoreAvailable } from "./RealtimeWaveform"
|
||||
|
||||
type FocusField = "bars" | "sensitivity" | "noise" | "lowCut" | "highCut"
|
||||
|
||||
const FIELDS: FocusField[] = ["bars", "sensitivity", "noise", "lowCut", "highCut"]
|
||||
|
||||
export function VisualizerSettings() {
|
||||
const appStore = useAppStore()
|
||||
const { theme } = useTheme()
|
||||
const [focusField, setFocusField] = createSignal<FocusField>("bars")
|
||||
|
||||
const viz = () => appStore.state().settings.visualizer
|
||||
|
||||
const handleKey = (key: { name: string; shift?: boolean }) => {
|
||||
if (key.name === "tab") {
|
||||
const idx = FIELDS.indexOf(focusField())
|
||||
const next = key.shift
|
||||
? (idx - 1 + FIELDS.length) % FIELDS.length
|
||||
: (idx + 1) % FIELDS.length
|
||||
setFocusField(FIELDS[next])
|
||||
return
|
||||
}
|
||||
|
||||
if (key.name === "left" || key.name === "h") {
|
||||
stepValue(-1)
|
||||
}
|
||||
if (key.name === "right" || key.name === "l") {
|
||||
stepValue(1)
|
||||
}
|
||||
}
|
||||
|
||||
const stepValue = (delta: number) => {
|
||||
const field = focusField()
|
||||
const v = viz()
|
||||
|
||||
switch (field) {
|
||||
case "bars": {
|
||||
// Step by 8: 8, 16, 24, 32, ..., 128
|
||||
const next = Math.min(128, Math.max(8, v.bars + delta * 8))
|
||||
appStore.updateVisualizer({ bars: next })
|
||||
break
|
||||
}
|
||||
case "sensitivity": {
|
||||
// Toggle: 0 (manual) or 1 (auto)
|
||||
appStore.updateVisualizer({ sensitivity: v.sensitivity === 1 ? 0 : 1 })
|
||||
break
|
||||
}
|
||||
case "noise": {
|
||||
// Step by 0.05: 0.0 – 1.0
|
||||
const next = Math.min(1, Math.max(0, Number((v.noiseReduction + delta * 0.05).toFixed(2))))
|
||||
appStore.updateVisualizer({ noiseReduction: next })
|
||||
break
|
||||
}
|
||||
case "lowCut": {
|
||||
// Step by 10: 20 – 500 Hz
|
||||
const next = Math.min(500, Math.max(20, v.lowCutOff + delta * 10))
|
||||
appStore.updateVisualizer({ lowCutOff: next })
|
||||
break
|
||||
}
|
||||
case "highCut": {
|
||||
// Step by 500: 1000 – 20000 Hz
|
||||
const next = Math.min(20000, Math.max(1000, v.highCutOff + delta * 500))
|
||||
appStore.updateVisualizer({ highCutOff: next })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(handleKey)
|
||||
|
||||
const cavacoreStatus = isCavacoreAvailable()
|
||||
|
||||
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>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().bars}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-8]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "sensitivity" ? theme.primary : theme.textMuted}>Auto Sensitivity:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={viz().sensitivity === 1 ? theme.success : theme.textMuted}>
|
||||
{viz().sensitivity === 1 ? "On" : "Off"}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "noise" ? theme.primary : theme.textMuted}>Noise Reduction:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().noiseReduction.toFixed(2)}</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-0.05]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "lowCut" ? theme.primary : theme.textMuted}>Low Cutoff:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().lowCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-10]</text>
|
||||
</box>
|
||||
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text fg={focusField() === "highCut" ? theme.primary : theme.textMuted}>High Cutoff:</text>
|
||||
<box border padding={0}>
|
||||
<text fg={theme.text}>{viz().highCutOff} Hz</text>
|
||||
</box>
|
||||
<text fg={theme.textMuted}>[Left/Right +/-500]</text>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<text fg={theme.textMuted}>Tab to move focus, Left/Right to adjust</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user