meh
This commit is contained in:
93
src/components/MergedWaveform.tsx
Normal file
93
src/components/MergedWaveform.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user