This commit is contained in:
2026-02-05 23:43:19 -05:00
parent 168e6d5a61
commit 42a1ddf458
12 changed files with 746 additions and 44 deletions

View File

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

View File

@@ -4,7 +4,7 @@
* Right panel: episodes for the selected show
*/
import { createSignal, For, Show, createMemo } from "solid-js"
import { createSignal, For, Show, createMemo, createEffect } from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useFeedStore } from "../stores/feed"
import { format } from "date-fns"
@@ -26,6 +26,9 @@ export function MyShowsPage(props: MyShowsPageProps) {
const [episodeIndex, setEpisodeIndex] = createSignal(0)
const [isRefreshing, setIsRefreshing] = createSignal(false)
/** Threshold: load more when within this many items of the end */
const LOAD_MORE_THRESHOLD = 5
const shows = () => feedStore.getFilteredFeeds()
const selectedShow = createMemo(() => {
@@ -42,6 +45,19 @@ export function MyShowsPage(props: MyShowsPageProps) {
)
})
// Detect when user navigates near the bottom and load more episodes
createEffect(() => {
const idx = episodeIndex()
const eps = episodes()
const show = selectedShow()
if (!show || eps.length === 0) return
const nearBottom = idx >= eps.length - LOAD_MORE_THRESHOLD
if (nearBottom && feedStore.hasMoreEpisodes(show.id) && !feedStore.isLoadingMore()) {
feedStore.loadMoreEpisodes(show.id)
}
})
const formatDate = (date: Date): string => {
return format(date, "MMM d, yyyy")
}
@@ -231,6 +247,16 @@ export function MyShowsPage(props: MyShowsPageProps) {
</box>
)}
</For>
<Show when={feedStore.isLoadingMore()}>
<box paddingLeft={2} paddingTop={1}>
<text fg="yellow">Loading more episodes...</text>
</box>
</Show>
<Show when={!feedStore.isLoadingMore() && selectedShow() && feedStore.hasMoreEpisodes(selectedShow()!.id)}>
<box paddingLeft={2} paddingTop={1}>
<text fg="gray">Scroll down for more episodes</text>
</box>
</Show>
</scrollbox>
</Show>
</Show>

View File

@@ -1,7 +1,6 @@
import { useKeyboard } from "@opentui/solid"
import { PlaybackControls } from "./PlaybackControls"
import { Waveform } from "./Waveform"
import { createWaveform } from "../utils/waveform"
import { MergedWaveform } from "./MergedWaveform"
import { useAudio } from "../hooks/useAudio"
import type { Episode } from "../types/episode"
@@ -24,8 +23,6 @@ const SAMPLE_EPISODE: Episode = {
export function Player(props: PlayerProps) {
const audio = useAudio()
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
@@ -86,7 +83,7 @@ export function Player(props: PlayerProps) {
<strong>Now Playing</strong>
</text>
<text fg="gray">
{formatTime(audio.position())} / {formatTime(dur())}
{formatTime(audio.position())} / {formatTime(dur())} ({progressPercent()}%)
</text>
</box>
@@ -100,27 +97,13 @@ export function Player(props: PlayerProps) {
</text>
<text fg="gray">{episode().description}</text>
<box flexDirection="column" gap={1}>
<box flexDirection="row" gap={1} alignItems="center">
<text fg="gray">Progress:</text>
<box flexGrow={1} height={1} backgroundColor="#2a2f3a">
<box
width={`${progressPercent()}%`}
height={1}
backgroundColor={audio.isPlaying() ? "#6fa8ff" : "#7d8590"}
/>
</box>
<text fg="gray">{progressPercent()}%</text>
</box>
<Waveform
data={waveform()}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)}
/>
</box>
<MergedWaveform
audioUrl={episode().audioUrl}
position={audio.position()}
duration={dur()}
isPlaying={audio.isPlaying()}
onSeek={(next: number) => audio.seek(next)}
/>
</box>
<PlaybackControls