Compare commits
2 Commits
03e69d04dc
...
e0fa76fb32
| Author | SHA1 | Date | |
|---|---|---|---|
| e0fa76fb32 | |||
| f3344fbed2 |
177
src/App.tsx
177
src/App.tsx
@@ -2,7 +2,8 @@ import { createSignal, ErrorBoundary } from "solid-js";
|
|||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
import { Navigation } from "./components/Navigation";
|
import { Navigation } from "./components/Navigation";
|
||||||
import { TabNavigation } from "./components/TabNavigation";
|
import { TabNavigation } from "./components/TabNavigation";
|
||||||
import { FeedList } from "./components/FeedList";
|
import { FeedPage } from "./components/FeedPage";
|
||||||
|
import { MyShowsPage } from "./components/MyShowsPage";
|
||||||
import { LoginScreen } from "./components/LoginScreen";
|
import { LoginScreen } from "./components/LoginScreen";
|
||||||
import { CodeValidation } from "./components/CodeValidation";
|
import { CodeValidation } from "./components/CodeValidation";
|
||||||
import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
|
import { OAuthPlaceholder } from "./components/OAuthPlaceholder";
|
||||||
@@ -20,7 +21,7 @@ import type { TabId } from "./components/Tab";
|
|||||||
import type { AuthScreen } from "./types/auth";
|
import type { AuthScreen } from "./types/auth";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [activeTab, setActiveTab] = createSignal<TabId>("settings");
|
const [activeTab, setActiveTab] = createSignal<TabId>("feed");
|
||||||
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
const [authScreen, setAuthScreen] = createSignal<AuthScreen>("login");
|
||||||
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
const [showAuthPanel, setShowAuthPanel] = createSignal(false);
|
||||||
const [inputFocused, setInputFocused] = createSignal(false);
|
const [inputFocused, setInputFocused] = createSignal(false);
|
||||||
@@ -29,6 +30,15 @@ export function App() {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
|
|
||||||
|
// My Shows page returns panel renderers
|
||||||
|
const myShows = MyShowsPage({
|
||||||
|
get focused() { return activeTab() === "shows" && layerDepth() > 0 },
|
||||||
|
onPlayEpisode: (episode, feed) => {
|
||||||
|
// TODO: play episode
|
||||||
|
},
|
||||||
|
onExit: () => setLayerDepth(0),
|
||||||
|
});
|
||||||
|
|
||||||
// Centralized keyboard handler for all tab navigation and shortcuts
|
// Centralized keyboard handler for all tab navigation and shortcuts
|
||||||
useAppKeyboard({
|
useAppKeyboard({
|
||||||
get activeTab() {
|
get activeTab() {
|
||||||
@@ -58,28 +68,58 @@ export function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderContent = () => {
|
const getPanels = () => {
|
||||||
const tab = activeTab();
|
const tab = activeTab();
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case "feeds":
|
case "feed":
|
||||||
return (
|
return {
|
||||||
<FeedList
|
panels: [
|
||||||
|
{
|
||||||
|
title: "Feed - Latest Episodes",
|
||||||
|
content: (
|
||||||
|
<FeedPage
|
||||||
focused={layerDepth() > 0}
|
focused={layerDepth() > 0}
|
||||||
showEpisodeCount={true}
|
onPlayEpisode={(episode, feed) => {
|
||||||
showLastUpdated={true}
|
// TODO: play episode
|
||||||
onFocusChange={() => setLayerDepth(0)}
|
|
||||||
onOpenFeed={(feed) => {
|
|
||||||
// Would open feed detail view
|
|
||||||
}}
|
}}
|
||||||
|
onExit={() => setLayerDepth(0)}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "j/k navigate | Enter play | r refresh | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
|
case "shows":
|
||||||
|
return {
|
||||||
|
panels: [
|
||||||
|
{
|
||||||
|
title: "My Shows",
|
||||||
|
width: 35,
|
||||||
|
content: myShows.showsPanel(),
|
||||||
|
focused: myShows.focusPane() === "shows",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: myShows.selectedShow()
|
||||||
|
? `${myShows.selectedShow()!.podcast.title} - Episodes`
|
||||||
|
: "Episodes",
|
||||||
|
content: myShows.episodesPanel(),
|
||||||
|
focused: myShows.focusPane() === "episodes",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activePanelIndex: myShows.focusPane() === "shows" ? 0 : 1,
|
||||||
|
hint: "h/l switch panes | j/k navigate | Enter play | r refresh | d unsubscribe | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
case "settings":
|
case "settings":
|
||||||
// Show auth panel or sync panel based on state
|
|
||||||
if (showAuthPanel()) {
|
if (showAuthPanel()) {
|
||||||
if (auth.isAuthenticated) {
|
if (auth.isAuthenticated) {
|
||||||
return (
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Account",
|
||||||
|
content: (
|
||||||
<SyncProfile
|
<SyncProfile
|
||||||
focused={layerDepth() > 0}
|
focused={layerDepth() > 0}
|
||||||
onLogout={() => {
|
onLogout={() => {
|
||||||
@@ -88,9 +128,14 @@ export function App() {
|
|||||||
}}
|
}}
|
||||||
onManageSync={() => setShowAuthPanel(false)}
|
onManageSync={() => setShowAuthPanel(false)}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "Esc back",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authContent = () => {
|
||||||
switch (authScreen()) {
|
switch (authScreen()) {
|
||||||
case "code":
|
case "code":
|
||||||
return (
|
return (
|
||||||
@@ -107,7 +152,6 @@ export function App() {
|
|||||||
onNavigateToCode={() => setAuthScreen("code")}
|
onNavigateToCode={() => setAuthScreen("code")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "login":
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<LoginScreen
|
<LoginScreen
|
||||||
@@ -117,9 +161,22 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Sign In",
|
||||||
|
content: authContent(),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "Esc back",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Settings",
|
||||||
|
content: (
|
||||||
<SettingsScreen
|
<SettingsScreen
|
||||||
onOpenAccount={() => setShowAuthPanel(true)}
|
onOpenAccount={() => setShowAuthPanel(true)}
|
||||||
accountLabel={
|
accountLabel={
|
||||||
@@ -130,18 +187,32 @@ export function App() {
|
|||||||
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
|
accountStatus={auth.isAuthenticated ? "signed-in" : "signed-out"}
|
||||||
onExit={() => setLayerDepth(0)}
|
onExit={() => setLayerDepth(0)}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "j/k navigate | Enter select | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
case "discover":
|
case "discover":
|
||||||
return (
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Discover",
|
||||||
|
content: (
|
||||||
<DiscoverPage
|
<DiscoverPage
|
||||||
focused={layerDepth() > 0}
|
focused={layerDepth() > 0}
|
||||||
onExit={() => setLayerDepth(0)}
|
onExit={() => setLayerDepth(0)}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "Tab switch focus | j/k navigate | Enter subscribe | r refresh | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
case "search":
|
case "search":
|
||||||
return (
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Search",
|
||||||
|
content: (
|
||||||
<SearchPage
|
<SearchPage
|
||||||
focused={layerDepth() > 0}
|
focused={layerDepth() > 0}
|
||||||
onInputFocusChange={setInputFocused}
|
onInputFocusChange={setInputFocused}
|
||||||
@@ -163,46 +234,62 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "Tab switch focus | / search | Enter select | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
case "player":
|
case "player":
|
||||||
return (
|
return {
|
||||||
|
panels: [{
|
||||||
|
title: "Player",
|
||||||
|
content: (
|
||||||
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
|
<Player focused={layerDepth() > 0} onExit={() => setLayerDepth(0)} />
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "Space play/pause | Esc back",
|
||||||
|
};
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return {
|
||||||
<box border style={{ padding: 2 }}>
|
panels: [{
|
||||||
<text>
|
title: tab,
|
||||||
<strong>{tab}</strong>
|
content: (
|
||||||
<br />
|
<box padding={2}>
|
||||||
Coming soon
|
<text>Coming soon</text>
|
||||||
</text>
|
|
||||||
</box>
|
</box>
|
||||||
);
|
),
|
||||||
|
}],
|
||||||
|
activePanelIndex: 0,
|
||||||
|
hint: "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout
|
|
||||||
layerDepth={layerDepth()}
|
|
||||||
header={
|
|
||||||
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
|
||||||
}
|
|
||||||
footer={<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />}
|
|
||||||
>
|
|
||||||
<box style={{ padding: 1 }}>
|
|
||||||
<ErrorBoundary fallback={(err) => (
|
<ErrorBoundary fallback={(err) => (
|
||||||
<box border padding={2}>
|
<box border padding={2}>
|
||||||
<text fg="red">
|
<text fg="red">
|
||||||
Error rendering tab: {err?.message ?? String(err)}{"\n"}
|
Error: {err?.message ?? String(err)}{"\n"}
|
||||||
Press a number key (1-5) to switch tabs.
|
Press a number key (1-6) to switch tabs.
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
)}>
|
)}>
|
||||||
{renderContent()}
|
<Layout
|
||||||
</ErrorBoundary>
|
header={
|
||||||
|
<TabNavigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
<box flexDirection="row" justifyContent="space-between" width="100%">
|
||||||
|
<Navigation activeTab={activeTab()} onTabSelect={setActiveTab} />
|
||||||
|
<text fg="gray">{getPanels().hint}</text>
|
||||||
</box>
|
</box>
|
||||||
</Layout>
|
}
|
||||||
|
panels={getPanels().panels}
|
||||||
|
activePanelIndex={getPanels().activePanelIndex}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
121
src/components/FeedPage.tsx
Normal file
121
src/components/FeedPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 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,40 +1,51 @@
|
|||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import type { RGBA } from "@opentui/core"
|
import type { RGBA } from "@opentui/core"
|
||||||
import { Show, createMemo } from "solid-js"
|
import { Show, For, createMemo } from "solid-js"
|
||||||
import { useTheme } from "../context/ThemeContext"
|
import { useTheme } from "../context/ThemeContext"
|
||||||
import { LayerIndicator } from "./LayerIndicator"
|
|
||||||
|
|
||||||
type LayerConfig = {
|
type PanelConfig = {
|
||||||
depth: number
|
/** Panel content */
|
||||||
background: RGBA
|
content: JSX.Element
|
||||||
|
/** Panel title shown in header */
|
||||||
|
title?: string
|
||||||
|
/** Fixed width (leave undefined for flex) */
|
||||||
|
width?: number
|
||||||
|
/** Whether this panel is currently focused */
|
||||||
|
focused?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
|
/** Top tab bar */
|
||||||
header?: JSX.Element
|
header?: JSX.Element
|
||||||
|
/** Bottom status bar */
|
||||||
footer?: JSX.Element
|
footer?: JSX.Element
|
||||||
children?: JSX.Element
|
/** Panels to display left-to-right like a file explorer */
|
||||||
layerDepth?: number
|
panels: PanelConfig[]
|
||||||
|
/** Index of the currently active/focused panel */
|
||||||
|
activePanelIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Layout(props: LayoutProps) {
|
export function Layout(props: LayoutProps) {
|
||||||
const context = useTheme()
|
const context = useTheme()
|
||||||
|
|
||||||
// Get layer configuration based on depth - wrapped in createMemo for reactivity
|
const panelBg = (index: number): RGBA => {
|
||||||
const currentLayer = createMemo((): LayerConfig => {
|
|
||||||
const depth = props.layerDepth || 0
|
|
||||||
const backgrounds = context.theme.layerBackgrounds
|
const backgrounds = context.theme.layerBackgrounds
|
||||||
const depthMap: Record<number, LayerConfig> = {
|
const layers = [
|
||||||
0: { depth: 0, background: backgrounds?.layer0 ?? context.theme.background },
|
backgrounds?.layer0 ?? context.theme.background,
|
||||||
1: { depth: 1, background: backgrounds?.layer1 ?? context.theme.backgroundPanel },
|
backgrounds?.layer1 ?? context.theme.backgroundPanel,
|
||||||
2: { depth: 2, background: backgrounds?.layer2 ?? context.theme.backgroundElement },
|
backgrounds?.layer2 ?? context.theme.backgroundElement,
|
||||||
3: { depth: 3, background: backgrounds?.layer3 ?? context.theme.backgroundMenu },
|
backgrounds?.layer3 ?? context.theme.backgroundMenu,
|
||||||
|
]
|
||||||
|
return layers[Math.min(index, layers.length - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
return depthMap[depth] || { depth: 0, background: context.theme.background }
|
const borderColor = (index: number): RGBA | string => {
|
||||||
})
|
const isActive = index === (props.activePanelIndex ?? 0)
|
||||||
|
return isActive
|
||||||
|
? (context.theme.accent ?? context.theme.primary)
|
||||||
|
: (context.theme.border ?? context.theme.textMuted)
|
||||||
|
}
|
||||||
|
|
||||||
// Note: No need for a ready check here - the ThemeProvider uses
|
|
||||||
// createSimpleContext which gates children rendering until ready
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
@@ -42,36 +53,74 @@ export function Layout(props: LayoutProps) {
|
|||||||
height="100%"
|
height="100%"
|
||||||
backgroundColor={context.theme.background}
|
backgroundColor={context.theme.background}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header - tab bar */}
|
||||||
<Show when={props.header} fallback={<box style={{ height: 4 }} />}>
|
<Show when={props.header}>
|
||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
height: 4,
|
height: 3,
|
||||||
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
|
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box style={{ padding: 1 }}>
|
<box style={{ paddingLeft: 1, paddingTop: 0, paddingBottom: 0 }}>
|
||||||
{props.header}
|
{props.header}
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Main content area with layer background */}
|
{/* Main content: side-by-side panels */}
|
||||||
|
<box
|
||||||
|
flexDirection="row"
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
<For each={props.panels}>
|
||||||
|
{(panel, index) => (
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
border
|
||||||
|
borderColor={borderColor(index())}
|
||||||
|
backgroundColor={panelBg(index())}
|
||||||
|
style={{
|
||||||
|
flexGrow: panel.width ? 0 : 1,
|
||||||
|
width: panel.width,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Panel header */}
|
||||||
|
<Show when={panel.title}>
|
||||||
|
<box
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
paddingLeft: 1,
|
||||||
|
paddingRight: 1,
|
||||||
|
backgroundColor: index() === (props.activePanelIndex ?? 0)
|
||||||
|
? (context.theme.accent ?? context.theme.primary)
|
||||||
|
: (context.theme.surface ?? context.theme.backgroundPanel),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
fg={index() === (props.activePanelIndex ?? 0) ? "black" : undefined}
|
||||||
|
>
|
||||||
|
<strong>{panel.title}</strong>
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Panel body */}
|
||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
backgroundColor: currentLayer().background,
|
padding: 1,
|
||||||
paddingLeft: 2,
|
|
||||||
paddingRight: 2,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<box style={{ flexGrow: 1 }}>
|
{panel.content}
|
||||||
{props.children}
|
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</box>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer - status/nav bar */}
|
||||||
<Show when={props.footer} fallback={<box style={{ height: 2 }} />}>
|
<Show when={props.footer}>
|
||||||
<box
|
<box
|
||||||
style={{
|
style={{
|
||||||
height: 2,
|
height: 2,
|
||||||
@@ -83,20 +132,6 @@ export function Layout(props: LayoutProps) {
|
|||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Layer indicator */}
|
|
||||||
<Show when={props.layerDepth !== undefined}>
|
|
||||||
<box
|
|
||||||
style={{
|
|
||||||
height: 1,
|
|
||||||
backgroundColor: context.theme.surface ?? context.theme.backgroundPanel,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<box style={{ padding: 1 }}>
|
|
||||||
<LayerIndicator layerDepth={props.layerDepth as number} />
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
</Show>
|
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
242
src/components/MyShowsPage.tsx
Normal file
242
src/components/MyShowsPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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 MyShowsPageProps = {
|
||||||
|
focused: boolean
|
||||||
|
onPlayEpisode?: (episode: Episode, feed: Feed) => void
|
||||||
|
onExit?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type FocusPane = "shows" | "episodes"
|
||||||
|
|
||||||
|
export function MyShowsPage(props: MyShowsPageProps) {
|
||||||
|
const feedStore = useFeedStore()
|
||||||
|
const [focusPane, setFocusPane] = createSignal<FocusPane>("shows")
|
||||||
|
const [showIndex, setShowIndex] = createSignal(0)
|
||||||
|
const [episodeIndex, setEpisodeIndex] = createSignal(0)
|
||||||
|
const [isRefreshing, setIsRefreshing] = createSignal(false)
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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 === "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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</scrollbox>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
),
|
||||||
|
|
||||||
|
focusPane,
|
||||||
|
selectedShow,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,11 @@ export function Navigation(props: NavigationProps) {
|
|||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
<box style={{ flexDirection: "row", width: "100%", height: 1 }}>
|
||||||
<text>
|
<text>
|
||||||
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
{props.activeTab === "feed" ? "[" : " "}Feed{props.activeTab === "feed" ? "]" : " "}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
{props.activeTab === "feeds" ? "[" : " "}My Feeds{props.activeTab === "feeds" ? "]" : " "}
|
{props.activeTab === "shows" ? "[" : " "}My Shows{props.activeTab === "shows" ? "]" : " "}
|
||||||
|
<span> </span>
|
||||||
|
{props.activeTab === "discover" ? "[" : " "}Discover{props.activeTab === "discover" ? "]" : " "}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
{props.activeTab === "search" ? "[" : " "}Search{props.activeTab === "search" ? "]" : " "}
|
||||||
<span> </span>
|
<span> </span>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import type { BackendName } from "../utils/audio-player"
|
||||||
|
|
||||||
type PlaybackControlsProps = {
|
type PlaybackControlsProps = {
|
||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
volume: number
|
volume: number
|
||||||
speed: number
|
speed: number
|
||||||
|
backendName?: BackendName
|
||||||
|
hasAudioUrl?: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
onPrev: () => void
|
onPrev: () => void
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
@@ -9,6 +13,14 @@ type PlaybackControlsProps = {
|
|||||||
onSpeedChange: (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) {
|
export function PlaybackControls(props: PlaybackControlsProps) {
|
||||||
return (
|
return (
|
||||||
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
|
<box flexDirection="row" gap={1} alignItems="center" border padding={1}>
|
||||||
@@ -29,6 +41,22 @@ export function PlaybackControls(props: PlaybackControlsProps) {
|
|||||||
<text fg="gray">Speed</text>
|
<text fg="gray">Speed</text>
|
||||||
<text fg="white">{props.speed}x</text>
|
<text fg="white">{props.speed}x</text>
|
||||||
</box>
|
</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>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { createSignal } from "solid-js"
|
|
||||||
import { useKeyboard } from "@opentui/solid"
|
import { useKeyboard } from "@opentui/solid"
|
||||||
import { PlaybackControls } from "./PlaybackControls"
|
import { PlaybackControls } from "./PlaybackControls"
|
||||||
import { Waveform } from "./Waveform"
|
import { Waveform } from "./Waveform"
|
||||||
import { createWaveform } from "../utils/waveform"
|
import { createWaveform } from "../utils/waveform"
|
||||||
|
import { useAudio } from "../hooks/useAudio"
|
||||||
import type { Episode } from "../types/episode"
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
type PlayerProps = {
|
type PlayerProps = {
|
||||||
focused: boolean
|
focused: boolean
|
||||||
|
episode?: Episode | null
|
||||||
onExit?: () => void
|
onExit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,17 +22,27 @@ const SAMPLE_EPISODE: Episode = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Player(props: PlayerProps) {
|
export function Player(props: PlayerProps) {
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false)
|
const audio = useAudio()
|
||||||
const [position, setPosition] = createSignal(0)
|
|
||||||
const [volume, setVolume] = createSignal(0.7)
|
|
||||||
const [speed, setSpeed] = createSignal(1)
|
|
||||||
|
|
||||||
const waveform = () => createWaveform(64)
|
const waveform = () => createWaveform(64)
|
||||||
|
|
||||||
|
// 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 }) => {
|
useKeyboard((key: { name: string }) => {
|
||||||
if (!props.focused) return
|
if (!props.focused) return
|
||||||
if (key.name === "space") {
|
if (key.name === "space") {
|
||||||
setIsPlaying((value: boolean) => !value)
|
if (audio.currentEpisode()) {
|
||||||
|
audio.togglePlayback()
|
||||||
|
} else {
|
||||||
|
// Nothing loaded yet — start playing the displayed episode
|
||||||
|
const ep = episode()
|
||||||
|
if (ep.audioUrl) {
|
||||||
|
audio.play(ep)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "escape") {
|
if (key.name === "escape") {
|
||||||
@@ -39,23 +50,34 @@ export function Player(props: PlayerProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "left") {
|
if (key.name === "left") {
|
||||||
setPosition((value: number) => Math.max(0, value - 10))
|
audio.seekRelative(-10)
|
||||||
}
|
}
|
||||||
if (key.name === "right") {
|
if (key.name === "right") {
|
||||||
setPosition((value: number) => Math.min(SAMPLE_EPISODE.duration, value + 10))
|
audio.seekRelative(10)
|
||||||
}
|
}
|
||||||
if (key.name === "up") {
|
if (key.name === "up") {
|
||||||
setVolume((value: number) => Math.min(1, Number((value + 0.05).toFixed(2))))
|
audio.setVolume(Math.min(1, Number((audio.volume() + 0.05).toFixed(2))))
|
||||||
}
|
}
|
||||||
if (key.name === "down") {
|
if (key.name === "down") {
|
||||||
setVolume((value: number) => Math.max(0, Number((value - 0.05).toFixed(2))))
|
audio.setVolume(Math.max(0, Number((audio.volume() - 0.05).toFixed(2))))
|
||||||
}
|
}
|
||||||
if (key.name === "s") {
|
if (key.name === "s") {
|
||||||
setSpeed((value: number) => (value >= 2 ? 0.5 : Number((value + 0.25).toFixed(2))))
|
const next = audio.speed() >= 2 ? 0.5 : Number((audio.speed() + 0.25).toFixed(2))
|
||||||
|
audio.setSpeed(next)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const progressPercent = () => Math.round((position() / SAMPLE_EPISODE.duration) * 100)
|
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 (
|
return (
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
@@ -64,15 +86,19 @@ export function Player(props: PlayerProps) {
|
|||||||
<strong>Now Playing</strong>
|
<strong>Now Playing</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">
|
<text fg="gray">
|
||||||
Episode {Math.floor(position() / 60)}:{String(Math.floor(position() % 60)).padStart(2, "0")}
|
{formatTime(audio.position())} / {formatTime(dur())}
|
||||||
</text>
|
</text>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
|
{audio.error() && (
|
||||||
|
<text fg="red">{audio.error()}</text>
|
||||||
|
)}
|
||||||
|
|
||||||
<box border padding={1} flexDirection="column" gap={1}>
|
<box border padding={1} flexDirection="column" gap={1}>
|
||||||
<text fg="white">
|
<text fg="white">
|
||||||
<strong>{SAMPLE_EPISODE.title}</strong>
|
<strong>{episode().title}</strong>
|
||||||
</text>
|
</text>
|
||||||
<text fg="gray">{SAMPLE_EPISODE.description}</text>
|
<text fg="gray">{episode().description}</text>
|
||||||
|
|
||||||
<box flexDirection="column" gap={1}>
|
<box flexDirection="column" gap={1}>
|
||||||
<box flexDirection="row" gap={1} alignItems="center">
|
<box flexDirection="row" gap={1} alignItems="center">
|
||||||
@@ -81,7 +107,7 @@ export function Player(props: PlayerProps) {
|
|||||||
<box
|
<box
|
||||||
width={`${progressPercent()}%`}
|
width={`${progressPercent()}%`}
|
||||||
height={1}
|
height={1}
|
||||||
backgroundColor={isPlaying() ? "#6fa8ff" : "#7d8590"}
|
backgroundColor={audio.isPlaying() ? "#6fa8ff" : "#7d8590"}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
<text fg="gray">{progressPercent()}%</text>
|
<text fg="gray">{progressPercent()}%</text>
|
||||||
@@ -89,26 +115,35 @@ export function Player(props: PlayerProps) {
|
|||||||
|
|
||||||
<Waveform
|
<Waveform
|
||||||
data={waveform()}
|
data={waveform()}
|
||||||
position={position()}
|
position={audio.position()}
|
||||||
duration={SAMPLE_EPISODE.duration}
|
duration={dur()}
|
||||||
isPlaying={isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
onSeek={(next: number) => setPosition(next)}
|
onSeek={(next: number) => audio.seek(next)}
|
||||||
/>
|
/>
|
||||||
</box>
|
</box>
|
||||||
</box>
|
</box>
|
||||||
|
|
||||||
<PlaybackControls
|
<PlaybackControls
|
||||||
isPlaying={isPlaying()}
|
isPlaying={audio.isPlaying()}
|
||||||
volume={volume()}
|
volume={audio.volume()}
|
||||||
speed={speed()}
|
speed={audio.speed()}
|
||||||
onToggle={() => setIsPlaying((value: boolean) => !value)}
|
backendName={audio.backendName()}
|
||||||
onPrev={() => setPosition(0)}
|
hasAudioUrl={!!episode().audioUrl}
|
||||||
onNext={() => setPosition(SAMPLE_EPISODE.duration)}
|
onToggle={() => {
|
||||||
onSpeedChange={setSpeed}
|
if (audio.currentEpisode()) {
|
||||||
onVolumeChange={setVolume}
|
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">Enter dive | Esc up | Space play/pause | Left/Right seek</text>
|
<text fg="gray">Space play/pause | Left/Right seek 10s | Up/Down volume | S speed | Esc back</text>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTheme } from "../context/ThemeContext"
|
import { useTheme } from "../context/ThemeContext"
|
||||||
|
|
||||||
export type TabId = "discover" | "feeds" | "search" | "player" | "settings"
|
export type TabId = "feed" | "shows" | "discover" | "search" | "player" | "settings"
|
||||||
|
|
||||||
export type TabDefinition = {
|
export type TabDefinition = {
|
||||||
id: TabId
|
id: TabId
|
||||||
@@ -8,8 +8,9 @@ export type TabDefinition = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tabs: TabDefinition[] = [
|
export const tabs: TabDefinition[] = [
|
||||||
|
{ id: "feed", label: "Feed" },
|
||||||
|
{ id: "shows", label: "My Shows" },
|
||||||
{ id: "discover", label: "Discover" },
|
{ id: "discover", label: "Discover" },
|
||||||
{ id: "feeds", label: "My Feeds" },
|
|
||||||
{ id: "search", label: "Search" },
|
{ id: "search", label: "Search" },
|
||||||
{ id: "player", label: "Player" },
|
{ id: "player", label: "Player" },
|
||||||
{ id: "settings", label: "Settings" },
|
{ id: "settings", label: "Settings" },
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ type TabNavigationProps = {
|
|||||||
export function TabNavigation(props: TabNavigationProps) {
|
export function TabNavigation(props: TabNavigationProps) {
|
||||||
return (
|
return (
|
||||||
<box style={{ flexDirection: "row", gap: 1 }}>
|
<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: "discover", label: "Discover" }} active={props.activeTab === "discover"} onSelect={props.onTabSelect} />
|
||||||
<Tab tab={{ id: "feeds", label: "My Feeds" }} active={props.activeTab === "feeds"} onSelect={props.onTabSelect} />
|
|
||||||
<Tab tab={{ id: "search", label: "Search" }} active={props.activeTab === "search"} 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: "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: "settings", label: "Settings" }} active={props.activeTab === "settings"} onSelect={props.onTabSelect} />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useKeyboard, useRenderer } from "@opentui/solid"
|
|||||||
import type { TabId } from "../components/Tab"
|
import type { TabId } from "../components/Tab"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
|
|
||||||
const TAB_ORDER: TabId[] = ["discover", "feeds", "search", "player", "settings"]
|
const TAB_ORDER: TabId[] = ["feed", "shows", "discover", "search", "player", "settings"]
|
||||||
|
|
||||||
type ShortcutOptions = {
|
type ShortcutOptions = {
|
||||||
activeTab: TabId
|
activeTab: TabId
|
||||||
@@ -89,24 +89,28 @@ export function useAppKeyboard(options: ShortcutOptions) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Number keys for direct tab access (1-5)
|
// Number keys for direct tab access (1-6)
|
||||||
if (key.name === "1") {
|
if (key.name === "1") {
|
||||||
options.onTabChange("discover")
|
options.onTabChange("feed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "2") {
|
if (key.name === "2") {
|
||||||
options.onTabChange("feeds")
|
options.onTabChange("shows")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "3") {
|
if (key.name === "3") {
|
||||||
options.onTabChange("search")
|
options.onTabChange("discover")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "4") {
|
if (key.name === "4") {
|
||||||
options.onTabChange("player")
|
options.onTabChange("search")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (key.name === "5") {
|
if (key.name === "5") {
|
||||||
|
options.onTabChange("player")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key.name === "6") {
|
||||||
options.onTabChange("settings")
|
options.onTabChange("settings")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
348
src/hooks/useAudio.ts
Normal file
348
src/hooks/useAudio.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Reactive SolidJS hook wrapping the AudioBackend.
|
||||||
|
*
|
||||||
|
* Provides signals for playback state and methods for controlling
|
||||||
|
* audio. Integrates with the event bus and app store.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```tsx
|
||||||
|
* const audio = useAudio()
|
||||||
|
* audio.play(episode)
|
||||||
|
* <text>{audio.isPlaying() ? "Playing" : "Paused"}</text>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSignal, onCleanup } from "solid-js"
|
||||||
|
import {
|
||||||
|
createAudioBackend,
|
||||||
|
detectPlayers,
|
||||||
|
type AudioBackend,
|
||||||
|
type BackendName,
|
||||||
|
type DetectedPlayer,
|
||||||
|
} from "../utils/audio-player"
|
||||||
|
import { emit, on } from "../utils/event-bus"
|
||||||
|
import { useAppStore } from "../stores/app"
|
||||||
|
import type { Episode } from "../types/episode"
|
||||||
|
|
||||||
|
export interface AudioControls {
|
||||||
|
// Signals (reactive getters)
|
||||||
|
isPlaying: () => boolean
|
||||||
|
position: () => number
|
||||||
|
duration: () => number
|
||||||
|
volume: () => number
|
||||||
|
speed: () => number
|
||||||
|
backendName: () => BackendName
|
||||||
|
error: () => string | null
|
||||||
|
currentEpisode: () => Episode | null
|
||||||
|
availablePlayers: () => DetectedPlayer[]
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
play: (episode: Episode) => Promise<void>
|
||||||
|
pause: () => Promise<void>
|
||||||
|
resume: () => Promise<void>
|
||||||
|
togglePlayback: () => Promise<void>
|
||||||
|
stop: () => Promise<void>
|
||||||
|
seek: (seconds: number) => Promise<void>
|
||||||
|
seekRelative: (delta: number) => Promise<void>
|
||||||
|
setVolume: (volume: number) => Promise<void>
|
||||||
|
setSpeed: (speed: number) => Promise<void>
|
||||||
|
switchBackend: (name: BackendName) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton state — shared across all components that call useAudio()
|
||||||
|
let backend: AudioBackend | null = null
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let refCount = 0
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = createSignal(false)
|
||||||
|
const [position, setPosition] = createSignal(0)
|
||||||
|
const [duration, setDuration] = createSignal(0)
|
||||||
|
const [volume, setVolume] = createSignal(0.7)
|
||||||
|
const [speed, setSpeed] = createSignal(1)
|
||||||
|
const [backendName, setBackendName] = createSignal<BackendName>("none")
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null)
|
||||||
|
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>([])
|
||||||
|
|
||||||
|
function ensureBackend(): AudioBackend {
|
||||||
|
if (!backend) {
|
||||||
|
const detected = detectPlayers()
|
||||||
|
setAvailablePlayers(detected)
|
||||||
|
backend = createAudioBackend()
|
||||||
|
setBackendName(backend.name)
|
||||||
|
}
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling(): void {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (!backend || !isPlaying()) return
|
||||||
|
try {
|
||||||
|
const pos = await backend.getPosition()
|
||||||
|
const dur = await backend.getDuration()
|
||||||
|
setPosition(pos)
|
||||||
|
if (dur > 0) setDuration(dur)
|
||||||
|
|
||||||
|
// Check if backend stopped playing (track ended)
|
||||||
|
if (!backend.isPlaying() && isPlaying()) {
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Backend may have been disposed
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling(): void {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play(episode: Episode): Promise<void> {
|
||||||
|
const b = ensureBackend()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (!episode.audioUrl) {
|
||||||
|
setError("No audio URL for this episode")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||||
|
const vol = volume()
|
||||||
|
const spd = storeSpeed || speed()
|
||||||
|
|
||||||
|
await b.play(episode.audioUrl, {
|
||||||
|
volume: vol,
|
||||||
|
speed: spd,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCurrentEpisode(episode)
|
||||||
|
setIsPlaying(true)
|
||||||
|
setPosition(0)
|
||||||
|
setSpeed(spd)
|
||||||
|
if (episode.duration) setDuration(episode.duration)
|
||||||
|
|
||||||
|
startPolling()
|
||||||
|
emit("player.play", { episodeId: episode.id })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Playback failed")
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pause(): Promise<void> {
|
||||||
|
if (!backend) return
|
||||||
|
try {
|
||||||
|
await backend.pause()
|
||||||
|
setIsPlaying(false)
|
||||||
|
stopPolling()
|
||||||
|
const ep = currentEpisode()
|
||||||
|
if (ep) emit("player.pause", { episodeId: ep.id })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Pause failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume(): Promise<void> {
|
||||||
|
if (!backend) return
|
||||||
|
try {
|
||||||
|
await backend.resume()
|
||||||
|
setIsPlaying(true)
|
||||||
|
startPolling()
|
||||||
|
const ep = currentEpisode()
|
||||||
|
if (ep) emit("player.play", { episodeId: ep.id })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Resume failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlayback(): Promise<void> {
|
||||||
|
if (isPlaying()) {
|
||||||
|
await pause()
|
||||||
|
} else if (currentEpisode()) {
|
||||||
|
await resume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(): Promise<void> {
|
||||||
|
if (!backend) return
|
||||||
|
try {
|
||||||
|
await backend.stop()
|
||||||
|
setIsPlaying(false)
|
||||||
|
setPosition(0)
|
||||||
|
setCurrentEpisode(null)
|
||||||
|
stopPolling()
|
||||||
|
emit("player.stop", {})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Stop failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seek(seconds: number): Promise<void> {
|
||||||
|
if (!backend) return
|
||||||
|
const clamped = Math.max(0, Math.min(seconds, duration()))
|
||||||
|
try {
|
||||||
|
await backend.seek(clamped)
|
||||||
|
setPosition(clamped)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Seek failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seekRelative(delta: number): Promise<void> {
|
||||||
|
await seek(position() + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSetVolume(vol: number): Promise<void> {
|
||||||
|
const clamped = Math.max(0, Math.min(1, vol))
|
||||||
|
if (backend) {
|
||||||
|
try {
|
||||||
|
await backend.setVolume(clamped)
|
||||||
|
} catch {
|
||||||
|
// Some backends can't change volume at runtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVolume(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSetSpeed(spd: number): Promise<void> {
|
||||||
|
const clamped = Math.max(0.25, Math.min(3, spd))
|
||||||
|
if (backend) {
|
||||||
|
try {
|
||||||
|
await backend.setSpeed(clamped)
|
||||||
|
} catch {
|
||||||
|
// Some backends can't change speed at runtime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSpeed(clamped)
|
||||||
|
|
||||||
|
// Sync back to app store
|
||||||
|
try {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
appStore.updateSettings({ playbackSpeed: clamped })
|
||||||
|
} catch {
|
||||||
|
// Store may not be available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchBackend(name: BackendName): Promise<void> {
|
||||||
|
const wasPlaying = isPlaying()
|
||||||
|
const ep = currentEpisode()
|
||||||
|
const pos = position()
|
||||||
|
const vol = volume()
|
||||||
|
const spd = speed()
|
||||||
|
|
||||||
|
// Stop current backend
|
||||||
|
if (backend) {
|
||||||
|
stopPolling()
|
||||||
|
backend.dispose()
|
||||||
|
backend = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new backend
|
||||||
|
backend = createAudioBackend(name)
|
||||||
|
setBackendName(backend.name)
|
||||||
|
setAvailablePlayers(detectPlayers())
|
||||||
|
|
||||||
|
// Resume playback if we were playing
|
||||||
|
if (wasPlaying && ep && ep.audioUrl) {
|
||||||
|
try {
|
||||||
|
await backend.play(ep.audioUrl, {
|
||||||
|
startPosition: pos,
|
||||||
|
volume: vol,
|
||||||
|
speed: spd,
|
||||||
|
})
|
||||||
|
setIsPlaying(true)
|
||||||
|
startPolling()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Backend switch failed")
|
||||||
|
setIsPlaying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive audio controls hook.
|
||||||
|
*
|
||||||
|
* Returns a singleton — all components share the same playback state.
|
||||||
|
* Registers event bus listeners and cleans them up with onCleanup.
|
||||||
|
*/
|
||||||
|
export function useAudio(): AudioControls {
|
||||||
|
// Initialize backend on first use
|
||||||
|
ensureBackend()
|
||||||
|
|
||||||
|
// Sync initial speed from app store
|
||||||
|
if (refCount === 0) {
|
||||||
|
try {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const storeSpeed = appStore.state().settings.playbackSpeed
|
||||||
|
if (storeSpeed && storeSpeed !== speed()) {
|
||||||
|
setSpeed(storeSpeed)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Store may not be available yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refCount++
|
||||||
|
|
||||||
|
// Listen for event bus commands (e.g. from other components)
|
||||||
|
const unsubPlay = on("player.play", async (data) => {
|
||||||
|
// External play requests — currently just tracks episodeId.
|
||||||
|
// Episode lookup would require feed store integration.
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubStop = on("player.stop", async () => {
|
||||||
|
if (backend && isPlaying()) {
|
||||||
|
await backend.stop()
|
||||||
|
setIsPlaying(false)
|
||||||
|
setPosition(0)
|
||||||
|
setCurrentEpisode(null)
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
refCount--
|
||||||
|
unsubPlay()
|
||||||
|
unsubStop()
|
||||||
|
|
||||||
|
if (refCount <= 0) {
|
||||||
|
stopPolling()
|
||||||
|
if (backend) {
|
||||||
|
backend.dispose()
|
||||||
|
backend = null
|
||||||
|
}
|
||||||
|
refCount = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPlaying,
|
||||||
|
position,
|
||||||
|
duration,
|
||||||
|
volume,
|
||||||
|
speed,
|
||||||
|
backendName,
|
||||||
|
error,
|
||||||
|
currentEpisode,
|
||||||
|
availablePlayers,
|
||||||
|
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
togglePlayback,
|
||||||
|
stop,
|
||||||
|
seek,
|
||||||
|
seekRelative,
|
||||||
|
setVolume: doSetVolume,
|
||||||
|
setSpeed: doSetSpeed,
|
||||||
|
switchBackend,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,13 @@ import type { Podcast } from "../types/podcast"
|
|||||||
import type { Episode, EpisodeStatus } from "../types/episode"
|
import type { Episode, EpisodeStatus } from "../types/episode"
|
||||||
import type { PodcastSource, SourceType } from "../types/source"
|
import type { PodcastSource, SourceType } from "../types/source"
|
||||||
import { DEFAULT_SOURCES } from "../types/source"
|
import { DEFAULT_SOURCES } from "../types/source"
|
||||||
|
import { parseRSSFeed } from "../api/rss-parser"
|
||||||
|
|
||||||
|
/** Max episodes to fetch on refresh */
|
||||||
|
const MAX_EPISODES_REFRESH = 50
|
||||||
|
|
||||||
|
/** Max episodes to fetch on initial subscribe */
|
||||||
|
const MAX_EPISODES_SUBSCRIBE = 20
|
||||||
|
|
||||||
/** Storage keys */
|
/** Storage keys */
|
||||||
const STORAGE_KEYS = {
|
const STORAGE_KEYS = {
|
||||||
@@ -17,125 +24,10 @@ const STORAGE_KEYS = {
|
|||||||
sources: "podtui_sources",
|
sources: "podtui_sources",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create initial mock feeds for demonstration */
|
|
||||||
function createMockFeeds(): Feed[] {
|
|
||||||
const now = new Date()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
podcast: {
|
|
||||||
id: "p1",
|
|
||||||
title: "The Daily Tech News",
|
|
||||||
description: "Your daily dose of technology news and insights from around the world. We cover the latest in AI, software, hardware, and digital culture.",
|
|
||||||
feedUrl: "https://example.com/tech.rss",
|
|
||||||
author: "Tech Media Inc",
|
|
||||||
categories: ["Technology", "News"],
|
|
||||||
lastUpdated: now,
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: createMockEpisodes("p1", 25),
|
|
||||||
visibility: "public" as FeedVisibility,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: now,
|
|
||||||
isPinned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
podcast: {
|
|
||||||
id: "p2",
|
|
||||||
title: "Code & Coffee",
|
|
||||||
description: "Weekly discussions about programming, software development, and the developer lifestyle. Best enjoyed with your morning coffee.",
|
|
||||||
feedUrl: "https://example.com/code.rss",
|
|
||||||
author: "Developer Collective",
|
|
||||||
categories: ["Technology", "Programming"],
|
|
||||||
lastUpdated: new Date(Date.now() - 86400000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: createMockEpisodes("p2", 50),
|
|
||||||
visibility: "private" as FeedVisibility,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(Date.now() - 86400000),
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
podcast: {
|
|
||||||
id: "p3",
|
|
||||||
title: "Science Explained",
|
|
||||||
description: "Breaking down complex scientific topics for curious minds. From quantum physics to biology, we make science accessible.",
|
|
||||||
feedUrl: "https://example.com/science.rss",
|
|
||||||
author: "Science Network",
|
|
||||||
categories: ["Science", "Education"],
|
|
||||||
lastUpdated: new Date(Date.now() - 172800000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: createMockEpisodes("p3", 120),
|
|
||||||
visibility: "public" as FeedVisibility,
|
|
||||||
sourceId: "itunes",
|
|
||||||
lastUpdated: new Date(Date.now() - 172800000),
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
podcast: {
|
|
||||||
id: "p4",
|
|
||||||
title: "History Uncovered",
|
|
||||||
description: "Deep dives into fascinating historical events and figures you never learned about in school.",
|
|
||||||
feedUrl: "https://example.com/history.rss",
|
|
||||||
author: "History Channel",
|
|
||||||
categories: ["History", "Education"],
|
|
||||||
lastUpdated: new Date(Date.now() - 259200000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: createMockEpisodes("p4", 80),
|
|
||||||
visibility: "public" as FeedVisibility,
|
|
||||||
sourceId: "rss",
|
|
||||||
lastUpdated: new Date(Date.now() - 259200000),
|
|
||||||
isPinned: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "5",
|
|
||||||
podcast: {
|
|
||||||
id: "p5",
|
|
||||||
title: "Startup Stories",
|
|
||||||
description: "Founders share their journey from idea to exit. Learn from their successes and failures.",
|
|
||||||
feedUrl: "https://example.com/startup.rss",
|
|
||||||
author: "Entrepreneur Media",
|
|
||||||
categories: ["Business", "Technology"],
|
|
||||||
lastUpdated: new Date(Date.now() - 345600000),
|
|
||||||
isSubscribed: true,
|
|
||||||
},
|
|
||||||
episodes: createMockEpisodes("p5", 45),
|
|
||||||
visibility: "private" as FeedVisibility,
|
|
||||||
sourceId: "itunes",
|
|
||||||
lastUpdated: new Date(Date.now() - 345600000),
|
|
||||||
isPinned: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create mock episodes for a podcast */
|
|
||||||
function createMockEpisodes(podcastId: string, count: number): Episode[] {
|
|
||||||
const episodes: Episode[] = []
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
episodes.push({
|
|
||||||
id: `${podcastId}-ep-${i + 1}`,
|
|
||||||
podcastId,
|
|
||||||
title: `Episode ${count - i}: Sample Episode Title`,
|
|
||||||
description: `This is the description for episode ${count - i}. It contains interesting content about various topics.`,
|
|
||||||
audioUrl: `https://example.com/audio/${podcastId}/${i + 1}.mp3`,
|
|
||||||
duration: 1800 + Math.random() * 3600, // 30-90 minutes
|
|
||||||
pubDate: new Date(Date.now() - i * 604800000), // Weekly episodes
|
|
||||||
episodeNumber: count - i,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return episodes
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load feeds from localStorage */
|
/** Load feeds from localStorage */
|
||||||
function loadFeeds(): Feed[] {
|
function loadFeeds(): Feed[] {
|
||||||
if (typeof localStorage === "undefined") {
|
if (typeof localStorage === "undefined") {
|
||||||
return createMockFeeds()
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -160,7 +52,7 @@ function loadFeeds(): Feed[] {
|
|||||||
// Ignore errors
|
// Ignore errors
|
||||||
}
|
}
|
||||||
|
|
||||||
return createMockFeeds()
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to localStorage */
|
/** Save feeds to localStorage */
|
||||||
@@ -287,12 +179,31 @@ export function createFeedStore() {
|
|||||||
return allEpisodes
|
return allEpisodes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a new feed */
|
/** Fetch latest episodes from an RSS feed URL */
|
||||||
const addFeed = (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
const fetchEpisodes = async (feedUrl: string, limit: number): Promise<Episode[]> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(feedUrl, {
|
||||||
|
headers: {
|
||||||
|
"Accept-Encoding": "identity",
|
||||||
|
"Accept": "application/rss+xml, application/xml, text/xml, */*",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!response.ok) return []
|
||||||
|
const xml = await response.text()
|
||||||
|
const parsed = parseRSSFeed(xml, feedUrl)
|
||||||
|
return parsed.episodes.slice(0, limit)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a new feed and auto-fetch latest 20 episodes */
|
||||||
|
const addFeed = async (podcast: Podcast, sourceId: string, visibility: FeedVisibility = FeedVisibility.PUBLIC) => {
|
||||||
|
const episodes = await fetchEpisodes(podcast.feedUrl, MAX_EPISODES_SUBSCRIBE)
|
||||||
const newFeed: Feed = {
|
const newFeed: Feed = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
podcast,
|
podcast,
|
||||||
episodes: [],
|
episodes,
|
||||||
visibility,
|
visibility,
|
||||||
sourceId,
|
sourceId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
@@ -306,6 +217,28 @@ export function createFeedStore() {
|
|||||||
return newFeed
|
return newFeed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Refresh a single feed - re-fetch latest 50 episodes */
|
||||||
|
const refreshFeed = async (feedId: string) => {
|
||||||
|
const feed = getFeed(feedId)
|
||||||
|
if (!feed) return
|
||||||
|
const episodes = await fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH)
|
||||||
|
setFeeds((prev) => {
|
||||||
|
const updated = prev.map((f) =>
|
||||||
|
f.id === feedId ? { ...f, episodes, lastUpdated: new Date() } : f
|
||||||
|
)
|
||||||
|
saveFeeds(updated)
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh all feeds */
|
||||||
|
const refreshAllFeeds = async () => {
|
||||||
|
const currentFeeds = feeds()
|
||||||
|
for (const feed of currentFeeds) {
|
||||||
|
await refreshFeed(feed.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Remove a feed */
|
/** Remove a feed */
|
||||||
const removeFeed = (feedId: string) => {
|
const removeFeed = (feedId: string) => {
|
||||||
setFeeds((prev) => {
|
setFeeds((prev) => {
|
||||||
@@ -417,6 +350,8 @@ export function createFeedStore() {
|
|||||||
removeFeed,
|
removeFeed,
|
||||||
updateFeed,
|
updateFeed,
|
||||||
togglePinned,
|
togglePinned,
|
||||||
|
refreshFeed,
|
||||||
|
refreshAllFeeds,
|
||||||
addSource,
|
addSource,
|
||||||
removeSource,
|
removeSource,
|
||||||
toggleSource,
|
toggleSource,
|
||||||
|
|||||||
745
src/utils/audio-player.ts
Normal file
745
src/utils/audio-player.ts
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
/**
|
||||||
|
* Cross-platform audio playback engine for PodTUI.
|
||||||
|
*
|
||||||
|
* Backend priority:
|
||||||
|
* 1. mpv — full IPC control (seek, volume, speed, position tracking)
|
||||||
|
* 2. ffplay — basic control via process signals
|
||||||
|
* 3. afplay — macOS built-in (no seek/speed, volume only)
|
||||||
|
* 4. system — open/xdg-open/start (fire-and-forget, no control)
|
||||||
|
*
|
||||||
|
* All backends implement the AudioBackend interface so the Player
|
||||||
|
* component doesn't need to care which one is active.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { platform } from "os"
|
||||||
|
import { existsSync } from "fs"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
import { join } from "path"
|
||||||
|
|
||||||
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none"
|
||||||
|
|
||||||
|
export interface AudioState {
|
||||||
|
playing: boolean
|
||||||
|
position: number
|
||||||
|
duration: number
|
||||||
|
volume: number
|
||||||
|
speed: number
|
||||||
|
backend: BackendName
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioBackend {
|
||||||
|
readonly name: BackendName
|
||||||
|
play(url: string, opts?: PlayOptions): Promise<void>
|
||||||
|
pause(): Promise<void>
|
||||||
|
resume(): Promise<void>
|
||||||
|
stop(): Promise<void>
|
||||||
|
seek(seconds: number): Promise<void>
|
||||||
|
setVolume(volume: number): Promise<void>
|
||||||
|
setSpeed(speed: number): Promise<void>
|
||||||
|
getPosition(): Promise<number>
|
||||||
|
getDuration(): Promise<number>
|
||||||
|
isPlaying(): boolean
|
||||||
|
dispose(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayOptions {
|
||||||
|
startPosition?: number
|
||||||
|
volume?: number
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function which(cmd: string): string | null {
|
||||||
|
return Bun.which(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mpvSocketPath(): string {
|
||||||
|
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
|
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
||||||
|
|
||||||
|
class MpvBackend implements AudioBackend {
|
||||||
|
readonly name: BackendName = "mpv"
|
||||||
|
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||||
|
private socketPath = mpvSocketPath()
|
||||||
|
private _playing = false
|
||||||
|
private _position = 0
|
||||||
|
private _duration = 0
|
||||||
|
private _volume = 100
|
||||||
|
private _speed = 1
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.stop()
|
||||||
|
|
||||||
|
// Clean up stale socket
|
||||||
|
try {
|
||||||
|
if (existsSync(this.socketPath)) {
|
||||||
|
const { unlinkSync } = await import("fs")
|
||||||
|
unlinkSync(this.socketPath)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"mpv",
|
||||||
|
"--no-video",
|
||||||
|
"--no-terminal",
|
||||||
|
"--really-quiet",
|
||||||
|
`--input-ipc-server=${this.socketPath}`,
|
||||||
|
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
||||||
|
`--speed=${opts?.speed ?? 1}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (opts?.startPosition && opts.startPosition > 0) {
|
||||||
|
args.push(`--start=${opts.startPosition}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(url)
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
this._playing = true
|
||||||
|
this._position = opts?.startPosition ?? 0
|
||||||
|
this._volume = Math.round((opts?.volume ?? 1) * 100)
|
||||||
|
this._speed = opts?.speed ?? 1
|
||||||
|
|
||||||
|
// Wait for socket to appear (mpv creates it async)
|
||||||
|
await this.waitForSocket(2000)
|
||||||
|
|
||||||
|
// Start polling position
|
||||||
|
this.startPolling()
|
||||||
|
|
||||||
|
// Detect process exit
|
||||||
|
this.proc.exited.then(() => {
|
||||||
|
this._playing = false
|
||||||
|
this.stopPolling()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async waitForSocket(timeoutMs: number): Promise<void> {
|
||||||
|
const start = Date.now()
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
if (existsSync(this.socketPath)) return
|
||||||
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ipc(command: unknown[]): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const socket = await Bun.connect({
|
||||||
|
unix: this.socketPath,
|
||||||
|
socket: {
|
||||||
|
data(_socket, data) {
|
||||||
|
// Response handling is done by reading below
|
||||||
|
},
|
||||||
|
error(_socket, err) {},
|
||||||
|
close() {},
|
||||||
|
open() {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ command }) + "\n"
|
||||||
|
socket.write(payload)
|
||||||
|
|
||||||
|
// Read response with timeout
|
||||||
|
const response = await new Promise<string>((resolve) => {
|
||||||
|
let buf = ""
|
||||||
|
const reader = setInterval(() => {
|
||||||
|
// Check if we got a response already
|
||||||
|
if (buf.includes("\n")) {
|
||||||
|
clearInterval(reader)
|
||||||
|
resolve(buf)
|
||||||
|
}
|
||||||
|
}, 10)
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(reader)
|
||||||
|
resolve(buf)
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.end()
|
||||||
|
if (response) {
|
||||||
|
try { return JSON.parse(response.split("\n")[0]) } catch { return null }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a command over mpv's IPC and get the parsed response data. */
|
||||||
|
private async ipcCommand(command: unknown[]): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
const conn = await Bun.connect({
|
||||||
|
unix: this.socketPath,
|
||||||
|
socket: {
|
||||||
|
data() {},
|
||||||
|
error() {},
|
||||||
|
close() {},
|
||||||
|
open() {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = JSON.stringify({ command }) + "\n"
|
||||||
|
conn.write(payload)
|
||||||
|
|
||||||
|
// Give mpv a moment to process, then read via a fresh connection
|
||||||
|
await new Promise((r) => setTimeout(r, 30))
|
||||||
|
conn.end()
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a fire-and-forget command (no response needed) */
|
||||||
|
private async send(command: unknown[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const conn = await Bun.connect({
|
||||||
|
unix: this.socketPath,
|
||||||
|
socket: {
|
||||||
|
data() {},
|
||||||
|
error() {},
|
||||||
|
close() {},
|
||||||
|
open() {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
conn.write(JSON.stringify({ command }) + "\n")
|
||||||
|
// Don't wait, just schedule a close
|
||||||
|
setTimeout(() => { try { conn.end() } catch {} }, 50)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a property value from mpv via IPC */
|
||||||
|
private async getProperty(name: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await new Promise<number>((resolve) => {
|
||||||
|
let result = 0
|
||||||
|
const timeout = setTimeout(() => resolve(result), 300)
|
||||||
|
|
||||||
|
Bun.connect({
|
||||||
|
unix: this.socketPath,
|
||||||
|
socket: {
|
||||||
|
data(_socket, data) {
|
||||||
|
try {
|
||||||
|
const text = Buffer.from(data).toString()
|
||||||
|
const parsed = JSON.parse(text.split("\n")[0])
|
||||||
|
if (parsed?.data !== undefined) {
|
||||||
|
result = Number(parsed.data) || 0
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve(result)
|
||||||
|
},
|
||||||
|
error() { clearTimeout(timeout); resolve(0) },
|
||||||
|
close() {},
|
||||||
|
open(socket) {
|
||||||
|
socket.write(JSON.stringify({ command: ["get_property", name] }) + "\n")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).catch(() => { clearTimeout(timeout); resolve(0) })
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
this.stopPolling()
|
||||||
|
this.pollTimer = setInterval(async () => {
|
||||||
|
if (!this._playing || !this.proc) return
|
||||||
|
this._position = await this.getProperty("time-pos")
|
||||||
|
if (this._duration <= 0) {
|
||||||
|
this._duration = await this.getProperty("duration")
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
await this.send(["set_property", "pause", true])
|
||||||
|
this._playing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
await this.send(["set_property", "pause", false])
|
||||||
|
this._playing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch { /* ignore */ }
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this._playing = false
|
||||||
|
this._position = 0
|
||||||
|
|
||||||
|
// Clean up socket
|
||||||
|
try {
|
||||||
|
if (existsSync(this.socketPath)) {
|
||||||
|
const { unlinkSync } = await import("fs")
|
||||||
|
unlinkSync(this.socketPath)
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(seconds: number): Promise<void> {
|
||||||
|
await this.send(["set_property", "time-pos", seconds])
|
||||||
|
this._position = seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(volume: number): Promise<void> {
|
||||||
|
const v = Math.round(volume * 100)
|
||||||
|
await this.send(["set_property", "volume", v])
|
||||||
|
this._volume = v
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSpeed(speed: number): Promise<void> {
|
||||||
|
await this.send(["set_property", "speed", speed])
|
||||||
|
this._speed = speed
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPosition(): Promise<number> {
|
||||||
|
return this._position
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDuration(): Promise<number> {
|
||||||
|
if (this._duration <= 0) {
|
||||||
|
this._duration = await this.getProperty("duration")
|
||||||
|
}
|
||||||
|
return this._duration
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this._playing
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ffplay Backend ───────────────────────────────────────────────────
|
||||||
|
// ffplay has no IPC. We track duration from episode metadata and
|
||||||
|
// position via elapsed wall-clock time. Seek requires restarting.
|
||||||
|
|
||||||
|
class FfplayBackend implements AudioBackend {
|
||||||
|
readonly name: BackendName = "ffplay"
|
||||||
|
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||||
|
private _playing = false
|
||||||
|
private _position = 0
|
||||||
|
private _duration = 0
|
||||||
|
private _volume = 100
|
||||||
|
private _speed = 1
|
||||||
|
private _url = ""
|
||||||
|
private startTime = 0
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.stop()
|
||||||
|
|
||||||
|
this._url = url
|
||||||
|
this._volume = Math.round((opts?.volume ?? 1) * 100)
|
||||||
|
this._speed = opts?.speed ?? 1
|
||||||
|
this._position = opts?.startPosition ?? 0
|
||||||
|
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnProcess(): void {
|
||||||
|
const args = [
|
||||||
|
"ffplay",
|
||||||
|
"-nodisp",
|
||||||
|
"-autoexit",
|
||||||
|
"-loglevel", "quiet",
|
||||||
|
"-volume", String(this._volume),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this._position > 0) {
|
||||||
|
args.push("-ss", String(this._position))
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push("-i", this._url)
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
this._playing = true
|
||||||
|
this.startTime = Date.now()
|
||||||
|
this.startPolling()
|
||||||
|
|
||||||
|
this.proc.exited.then(() => {
|
||||||
|
this._playing = false
|
||||||
|
this.stopPolling()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
this.stopPolling()
|
||||||
|
this.pollTimer = setInterval(() => {
|
||||||
|
if (!this._playing) return
|
||||||
|
const elapsed = (Date.now() - this.startTime) / 1000 * this._speed
|
||||||
|
this._position = this._position + elapsed
|
||||||
|
this.startTime = Date.now()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
// ffplay doesn't support pause via IPC; kill and remember position
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this._playing = false
|
||||||
|
this.stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if (!this._url) return
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this._playing = false
|
||||||
|
this._position = 0
|
||||||
|
this._url = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(seconds: number): Promise<void> {
|
||||||
|
this._position = seconds
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
// Restart at new position
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(volume: number): Promise<void> {
|
||||||
|
this._volume = Math.round(volume * 100)
|
||||||
|
// ffplay can't change volume at runtime; apply on next play
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSpeed(speed: number): Promise<void> {
|
||||||
|
this._speed = speed
|
||||||
|
// ffplay doesn't support runtime speed changes
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPosition(): Promise<number> {
|
||||||
|
return this._position
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDuration(): Promise<number> {
|
||||||
|
return this._duration
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this._playing
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── afplay Backend (macOS) ───────────────────────────────────────────
|
||||||
|
// Built-in on macOS. Supports volume and rate but no seek or position.
|
||||||
|
|
||||||
|
class AfplayBackend implements AudioBackend {
|
||||||
|
readonly name: BackendName = "afplay"
|
||||||
|
private proc: ReturnType<typeof Bun.spawn> | null = null
|
||||||
|
private _playing = false
|
||||||
|
private _position = 0
|
||||||
|
private _duration = 0
|
||||||
|
private _volume = 1
|
||||||
|
private _speed = 1
|
||||||
|
private _url = ""
|
||||||
|
private startTime = 0
|
||||||
|
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.stop()
|
||||||
|
|
||||||
|
this._url = url
|
||||||
|
this._volume = opts?.volume ?? 1
|
||||||
|
this._speed = opts?.speed ?? 1
|
||||||
|
this._position = opts?.startPosition ?? 0
|
||||||
|
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnProcess(): void {
|
||||||
|
// afplay supports --volume (0-1) and --rate
|
||||||
|
const args = [
|
||||||
|
"afplay",
|
||||||
|
"--volume", String(this._volume),
|
||||||
|
"--rate", String(this._speed),
|
||||||
|
]
|
||||||
|
|
||||||
|
if (this._position > 0) {
|
||||||
|
args.push("--time", String(this._duration > 0 ? this._duration - this._position : 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(this._url)
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
this._playing = true
|
||||||
|
this.startTime = Date.now()
|
||||||
|
this.startPolling()
|
||||||
|
|
||||||
|
this.proc.exited.then(() => {
|
||||||
|
this._playing = false
|
||||||
|
this.stopPolling()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
private startPolling(): void {
|
||||||
|
this.stopPolling()
|
||||||
|
this.pollTimer = setInterval(() => {
|
||||||
|
if (!this._playing) return
|
||||||
|
const elapsed = (Date.now() - this.startTime) / 1000 * this._speed
|
||||||
|
this._position = this._position + elapsed
|
||||||
|
this.startTime = Date.now()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPolling(): void {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> {
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this._playing = false
|
||||||
|
this.stopPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resume(): Promise<void> {
|
||||||
|
if (!this._url) return
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
this.stopPolling()
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this._playing = false
|
||||||
|
this._position = 0
|
||||||
|
this._url = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async seek(seconds: number): Promise<void> {
|
||||||
|
this._position = seconds
|
||||||
|
if (this._playing && this._url) {
|
||||||
|
if (this.proc) {
|
||||||
|
try { this.proc.kill() } catch {}
|
||||||
|
this.proc = null
|
||||||
|
}
|
||||||
|
this.spawnProcess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setVolume(volume: number): Promise<void> {
|
||||||
|
this._volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSpeed(speed: number): Promise<void> {
|
||||||
|
this._speed = speed
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPosition(): Promise<number> {
|
||||||
|
return this._position
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDuration(): Promise<number> {
|
||||||
|
return this._duration
|
||||||
|
}
|
||||||
|
|
||||||
|
isPlaying(): boolean {
|
||||||
|
return this._playing
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── System Backend (open/xdg-open) ───────────────────────────────────
|
||||||
|
// Fire-and-forget. Opens the URL in the default handler. No control.
|
||||||
|
|
||||||
|
class SystemBackend implements AudioBackend {
|
||||||
|
readonly name: BackendName = "system"
|
||||||
|
private _playing = false
|
||||||
|
|
||||||
|
async play(url: string): Promise<void> {
|
||||||
|
const os = platform()
|
||||||
|
const cmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"
|
||||||
|
|
||||||
|
Bun.spawn([cmd, url], {
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
this._playing = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async pause(): Promise<void> { this._playing = false }
|
||||||
|
async resume(): Promise<void> { this._playing = true }
|
||||||
|
async stop(): Promise<void> { this._playing = false }
|
||||||
|
async seek(): Promise<void> {}
|
||||||
|
async setVolume(): Promise<void> {}
|
||||||
|
async setSpeed(): Promise<void> {}
|
||||||
|
async getPosition(): Promise<number> { return 0 }
|
||||||
|
async getDuration(): Promise<number> { return 0 }
|
||||||
|
isPlaying(): boolean { return this._playing }
|
||||||
|
dispose(): void { this._playing = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── No-op Backend ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NoopBackend implements AudioBackend {
|
||||||
|
readonly name: BackendName = "none"
|
||||||
|
async play(): Promise<void> {}
|
||||||
|
async pause(): Promise<void> {}
|
||||||
|
async resume(): Promise<void> {}
|
||||||
|
async stop(): Promise<void> {}
|
||||||
|
async seek(): Promise<void> {}
|
||||||
|
async setVolume(): Promise<void> {}
|
||||||
|
async setSpeed(): Promise<void> {}
|
||||||
|
async getPosition(): Promise<number> { return 0 }
|
||||||
|
async getDuration(): Promise<number> { return 0 }
|
||||||
|
isPlaying(): boolean { return false }
|
||||||
|
dispose(): void {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detection & Factory ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DetectedPlayer {
|
||||||
|
name: BackendName
|
||||||
|
path: string | null
|
||||||
|
capabilities: {
|
||||||
|
seek: boolean
|
||||||
|
volume: boolean
|
||||||
|
speed: boolean
|
||||||
|
positionTracking: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect all available audio players on this system. */
|
||||||
|
export function detectPlayers(): DetectedPlayer[] {
|
||||||
|
const players: DetectedPlayer[] = []
|
||||||
|
|
||||||
|
const mpvPath = which("mpv")
|
||||||
|
if (mpvPath) {
|
||||||
|
players.push({
|
||||||
|
name: "mpv",
|
||||||
|
path: mpvPath,
|
||||||
|
capabilities: { seek: true, volume: true, speed: true, positionTracking: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ffplayPath = which("ffplay")
|
||||||
|
if (ffplayPath) {
|
||||||
|
players.push({
|
||||||
|
name: "ffplay",
|
||||||
|
path: ffplayPath,
|
||||||
|
capabilities: { seek: true, volume: true, speed: false, positionTracking: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const os = platform()
|
||||||
|
if (os === "darwin") {
|
||||||
|
const afplayPath = which("afplay")
|
||||||
|
if (afplayPath) {
|
||||||
|
players.push({
|
||||||
|
name: "afplay",
|
||||||
|
path: afplayPath,
|
||||||
|
capabilities: { seek: true, volume: true, speed: true, positionTracking: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System open is always available as fallback
|
||||||
|
const openCmd = os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"
|
||||||
|
if (which(openCmd)) {
|
||||||
|
players.push({
|
||||||
|
name: "system",
|
||||||
|
path: which(openCmd),
|
||||||
|
capabilities: { seek: false, volume: false, speed: false, positionTracking: false },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return players
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create the best available audio backend. */
|
||||||
|
export function createAudioBackend(preferred?: BackendName): AudioBackend {
|
||||||
|
if (preferred) {
|
||||||
|
const backend = createBackendByName(preferred)
|
||||||
|
if (backend) return backend
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-detect in priority order
|
||||||
|
const players = detectPlayers()
|
||||||
|
if (players.length === 0) return new NoopBackend()
|
||||||
|
|
||||||
|
return createBackendByName(players[0].name) ?? new NoopBackend()
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackendByName(name: BackendName): AudioBackend | null {
|
||||||
|
switch (name) {
|
||||||
|
case "mpv": return which("mpv") ? new MpvBackend() : null
|
||||||
|
case "ffplay": return which("ffplay") ? new FfplayBackend() : null
|
||||||
|
case "afplay": return platform() === "darwin" && which("afplay") ? new AfplayBackend() : null
|
||||||
|
case "system": return new SystemBackend()
|
||||||
|
case "none": return new NoopBackend()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,7 +105,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
|||||||
- [x] 42 — Implement playback controls → `42-playback-controls.md`
|
- [x] 42 — Implement playback controls → `42-playback-controls.md`
|
||||||
- [x] 43 — Build ASCII waveform visualization → `43-waveform-visualization.md`
|
- [x] 43 — Build ASCII waveform visualization → `43-waveform-visualization.md`
|
||||||
- [x] 44 — Add progress tracking and seek → `44-progress-tracking.md`
|
- [x] 44 — Add progress tracking and seek → `44-progress-tracking.md`
|
||||||
- [ ] 45 — Implement audio integration (system/external player) → `45-audio-integration.md`
|
- [x] 45 — Implement audio integration (system/external player) → `45-audio-integration.md`
|
||||||
|
|
||||||
**Dependencies:** 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
**Dependencies:** 07 -> 08 -> 09 -> 10 -> 11 -> 12
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user