Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@solid-primitives/i18n": "^2.2.1",
"@solidjs/router": "^0.15.3",
"clsx": "^2.1.1",
"hls.js": "^1.6.15",
"install": "^0.13.0",
"solid-js": "^1.9.9"
},
Expand Down
8 changes: 8 additions & 0 deletions ui/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions ui/src/classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { DaisyUIColor } from './types'

export const rangeColorMap: Record<DaisyUIColor, string> = {
primary: 'range-primary',
secondary: 'range-secondary',
neutral: 'range-neutral',
accent: 'range-accent',
info: 'range-info',
success: 'range-success',
warning: 'range-warning',
error: 'range-error',
}
32 changes: 11 additions & 21 deletions ui/src/components/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,15 @@
import clsx from 'clsx'
import { JSXElement, Show } from 'solid-js'
import { DaisyUISize, DaisyUIColor } from '../types'

type DaisyUIButtonColor =
| 'neutral'
| 'primary'
| 'secondary'
| 'accent'
| 'info'
| 'success'
| 'warning'
| 'error'

type DaisyUIButtonStyle =
type DaisyUIButtonVariant =
| 'outline'
| 'dash'
| 'soft'
| 'ghost'
| 'link'
| 'outline'

type DaisyUIButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
| 'circle'

/**
* Renders a button component with an optional loading state.
Expand All @@ -33,17 +23,17 @@ export function Button(props: {
isLoading?: boolean
disabled?: boolean
type?: 'submit' | 'button' | 'reset'
color?: DaisyUIButtonColor
size?: DaisyUIButtonSize
style?: DaisyUIButtonStyle
color?: DaisyUIColor
size?: DaisyUISize
variant?: DaisyUIButtonVariant
class?: string
}): JSXElement {
return (
<button
class={clsx(
'btn',
props.color ? `btn-${props.color}` : undefined,
props.style ? `btn-${props.style}` : undefined,
props.variant ? `btn-${props.variant}` : undefined,
props.size ? `btn-${props.size}` : undefined,
(props.isLoading || props.disabled) && 'btn-disabled',
props.class
Expand Down Expand Up @@ -74,16 +64,16 @@ export function IconButton(props: {
onClick?: () => void
isLoading?: boolean
disabled?: boolean
color?: DaisyUIButtonColor
size?: DaisyUIButtonSize
style?: DaisyUIButtonStyle
color?: DaisyUIColor
size?: DaisyUISize
variant?: DaisyUIButtonVariant
class?: string
}): JSXElement {
return (
<button
class={clsx(
'btn',
props.style ? `btn-${props.style}` : 'btn-ghost',
props.variant ? `btn-${props.variant}` : 'btn-ghost',
props.size ? `btn-${props.size}` : 'btn-sm',
props.isLoading && 'btn-disabled',
props.class
Expand Down
253 changes: 253 additions & 0 deletions ui/src/components/media/AudioPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import {
JSXElement,
createSignal,
onMount,
onCleanup,
For,
Show,
} from 'solid-js'
import { clsx } from 'clsx'
import { IconButton } from '../../components/Button'
import { DaisyUIColor } from '../../types'
import { rangeColorMap } from '../../classes'

type AudioPlayerProps = {
src: string
color?: DaisyUIColor
minimal?: boolean
showRate?: boolean
withShortcuts?: boolean
}

export function AudioPlayer(props: AudioPlayerProps): JSXElement {
let audioEl!: HTMLAudioElement

const [ready, setReady] = createSignal(false)
const [playing, setPlaying] = createSignal(false)
const [dur, setDur] = createSignal(0)
const [t, setT] = createSignal(0)
const [rate, setRate] = createSignal(1)

const getFiniteDuration = () => {
const d = audioEl?.duration
if (Number.isFinite(d) && d! > 0) return d!

// Fallback: use seekable range end (often available on iOS PWA)
const s = audioEl?.seekable
if (s && s.length > 0) {
return s.end(s.length - 1)
}

return 0
}

const refreshDuration = () => {
const d = getFiniteDuration()
if (d > 0) setDur(d)
}

const formatSeconds = (s: number) => {
if (!Number.isFinite(s)) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
.toString()
.padStart(2, '0')
return `${m}:${sec}`
}

const toggle = async () => {
if (!ready()) return

if (playing()) {
audioEl.pause()
return
}

await audioEl.play()
}

const seek = (val: number) => {
if (!ready()) return
const d = dur() || 0
const clamped = Math.max(0, Math.min(d, val))
audioEl.currentTime = clamped
setT(clamped)
}

onMount(() => {
const onLoaded = () => {
refreshDuration()
setReady(true)
}
const onDuration = () => refreshDuration()
const onTime = () => {
setT(audioEl.currentTime || 0)
refreshDuration()
}
const onPlay = () => setPlaying(true)
const onPause = () => setPlaying(false)
const onRate = () => setRate(audioEl.playbackRate || 1)
const onKey = (e: KeyboardEvent) => {
if (!props.withShortcuts) return

if (e.code === 'Space') {
e.preventDefault()
toggle()
}
if (e.code === 'ArrowRight') {
e.preventDefault()
seek(t() + 5)
}
if (e.code === 'ArrowLeft') {
e.preventDefault()
seek(t() - 5)
}
}

audioEl.addEventListener('loadedmetadata', onLoaded)
audioEl.addEventListener('durationchange', onDuration)
audioEl.addEventListener('loadeddata', onDuration)
audioEl.addEventListener('canplay', onDuration)
audioEl.addEventListener('timeupdate', onTime)
audioEl.addEventListener('play', onPlay)
audioEl.addEventListener('pause', onPause)
audioEl.addEventListener('ratechange', onRate)
window.addEventListener('keydown', onKey)

onCleanup(() => {
audioEl.removeEventListener('loadedmetadata', onLoaded)
audioEl.removeEventListener('durationchange', onDuration)
audioEl.removeEventListener('loadeddata', onDuration)
audioEl.removeEventListener('canplay', onDuration)
audioEl.removeEventListener('timeupdate', onTime)
audioEl.removeEventListener('play', onPlay)
audioEl.removeEventListener('pause', onPause)
audioEl.removeEventListener('ratechange', onRate)
window.removeEventListener('keydown', onKey)
})
})

const safeDur = () => dur() || 0
const remaining = () => Math.max(safeDur() - t(), 0)

return (
<div class="w-full">
<audio
ref={audioEl!}
src={props.src}
preload={'metadata'}
class="hidden"
/>

<div class="flex items-center gap-3">
<Show when={props.minimal}>
<IconButton
icon={playing() ? 'fa-solid fa-pause' : 'fa-solid fa-play'}
color={props.color ?? 'primary'}
variant="ghost"
onClick={toggle}
disabled={!ready()}
class="w-8"
/>
</Show>

<div class="flex flex-col w-full">
<input
type="range"
min="0"
max={safeDur()}
step="0.01"
value={t()}
onInput={(e) =>
seek(Number((e.currentTarget as HTMLInputElement).value))
}
class={clsx(
'range',
rangeColorMap[props.color ?? 'primary'],
'mt-1 w-full [--range-thumb-size:12px]'
)}
/>

<div class="flex justify-between text-xs opacity-70 mt-1">
<span>{formatSeconds(t())}</span>
<span>-{formatSeconds(remaining())}</span>
</div>
</div>

<Show when={props.minimal && props.showRate}>
<div class="text-xs opacity-70">
<RateDropdown
rate={rate()}
setRate={(r) => {
audioEl.playbackRate = r
setRate(r)
}}
/>
</div>
</Show>
</div>

<Show when={!props.minimal}>
<div class="relative flex items-center justify-center gap-8 mt-4">
<IconButton
icon="fa-solid fa-arrow-rotate-left"
size="lg"
onClick={() => seek(t() - 10)}
/>

<IconButton
icon={playing() ? 'fa-solid fa-pause' : 'fa-solid fa-play'}
color={props.color ?? 'primary'}
variant="circle"
onClick={toggle}
disabled={!ready()}
size="lg"
/>

<IconButton
icon="fa-solid fa-arrow-rotate-right"
size="lg"
onClick={() => seek(t() + 10)}
/>

<Show when={props.showRate}>
<div class="absolute right-0 text-xs opacity-70">
<RateDropdown
rate={rate()}
setRate={(r) => {
audioEl.playbackRate = r
setRate(r)
}}
/>
</div>
</Show>
</div>
</Show>
</div>
)
}

function RateDropdown(props: {
rate: number
setRate: (r: number) => void
}): JSXElement {
return (
<div class="dropdown dropdown-end">
<div tabindex={0} role="button" class="btn btn-ghost btn-xs">
{props.rate}×
</div>
<ul
tabindex={0}
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-20 text-xs"
>
<For each={[0.5, 0.75, 1, 1.25, 1.5, 2]}>
{(r) => (
<li>
<a onClick={() => props.setRate(r)}>x{r}</a>
</li>
)}
</For>
</ul>
</div>
)
}
Loading