Files
amqtrain/src/lib/components/GlobalPlayer.svelte

946 lines
25 KiB
Svelte

<script module lang="ts">
import type { Track } from "$lib/player/types";
export type GlobalPlayerNowPlaying = Track | null;
export type QueueDisplayItem = {
track: Track;
queueIndex: number;
isCurrent: boolean;
};
export type TrackLabel = {
primary: string;
secondary: string | null;
};
</script>
<script lang="ts">
import { browser } from "$app/environment";
import { onDestroy, onMount } from "svelte";
import {
jumpToTrack,
next,
nowPlayingLabel,
prev,
removeTrack,
schedulePersistNow,
setUiOpen,
setVolume,
subscribe,
toggleShuffle,
toggleUiOpen,
toggleWrap,
type PlayerSnapshot,
} from "$lib/player/player.svelte";
import { createMediaSessionBindings } from "$lib/player/media-session";
import {
ChevronsUpDown,
ListX,
Pause as PauseIcon,
PanelRightClose,
PanelRightOpen,
Play as PlayIcon,
Repeat,
Shuffle,
SkipBack,
SkipForward,
Volume2,
X,
} from "@lucide/svelte";
let audioEl: HTMLAudioElement | null = null;
// Best-effort preload of the upcoming track's URL
let nextPreloadHref = $state<string | null>(null);
// Dedicated preloader element to warm the connection / decode pipeline a bit
let preloadEl = $state<HTMLAudioElement | null>(null);
let isPlaying = $state(false);
let currentTime = $state(0);
let duration = $state(0);
// local UI derived from viewport; not persisted
let isMobile = $state(false);
// IMPORTANT: Do not read `window` during SSR. Start with a safe snapshot and let
// the player module + subscription populate the real values on mount.
let snap = $state<PlayerSnapshot>({
queue: [],
currentIndex: null,
currentTrack: null,
shuffleEnabled: false,
wrapEnabled: false,
order: [],
history: [],
cursor: 0,
volume: 1,
uiOpen: false,
});
let unsubscribe: (() => void) | null = null;
// Track breakpoint transitions so we can auto open/close on crossing:
// - desktop -> mobile: auto close
// - mobile -> desktop: auto open
let prevIsMobile: boolean | null = $state(null);
function updateIsMobile() {
const nextIsMobile =
window.matchMedia?.("(max-width: 1023px)")?.matches ?? false;
// Update local derived flag
isMobile = nextIsMobile;
// On transitions, sync player UI open state
if (prevIsMobile !== null && prevIsMobile !== nextIsMobile) {
setUiOpen(!nextIsMobile);
}
prevIsMobile = nextIsMobile;
}
// Media Session bindings
const media = createMediaSessionBindings({
play: () => void audioEl?.play(),
pause: () => void audioEl?.pause(),
next: () => {
next();
void syncAndAutoplay();
},
prev: () => {
prev(currentTime);
void syncAndAutoplay();
},
seekTo: (t) => {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, t);
},
seekBy: (d) => {
if (!audioEl) return;
audioEl.currentTime = Math.max(0, audioEl.currentTime + d);
},
});
// Scrubber is now driven by `bind:currentTime` on the <audio> element and
// `bind:value={currentTime}` on the sliders, so we don't need explicit handlers.
function syncAudioToCurrentTrack() {
if (!audioEl) return;
const track = snap.currentTrack;
if (!track) return;
const desired = track.src;
const desiredAbs = new URL(desired, window.location.href).href;
if (audioEl.currentSrc !== desiredAbs) {
audioEl.src = desired;
audioEl.load();
audioEl.currentTime = 0;
}
audioEl.volume = snap.volume;
}
function computeNextTrackToPreload(): Track | null {
// Prefer to preload the "linear next" item. For shuffle mode, we can still
// best-effort preload the first item in the upcoming order.
if (snap.queue.length === 0) return null;
if (snap.shuffleEnabled) {
const nextIdx = snap.order[0];
if (typeof nextIdx === "number") return snap.queue[nextIdx] ?? null;
return null;
}
if (snap.currentIndex == null) return snap.queue[0] ?? null;
const nextIdx = snap.currentIndex + 1;
return nextIdx >= 0 && nextIdx < snap.queue.length
? snap.queue[nextIdx]
: null;
}
function updatePreloadTargets() {
const nextTrack = computeNextTrackToPreload();
nextPreloadHref = nextTrack?.src ?? null;
// Also warm via an offscreen <audio> element. This may or may not help depending
// on browser caching policies, but it's safe and often reduces first-buffer delay.
if (!preloadEl) return;
if (nextTrack?.src) {
preloadEl.src = nextTrack.src;
preloadEl.load();
} else {
preloadEl.removeAttribute("src");
preloadEl.load();
}
}
async function waitForEvent(el: HTMLMediaElement, eventName: string) {
await new Promise<void>((resolve) => {
const onEvent = () => {
el.removeEventListener(eventName, onEvent);
resolve();
};
el.addEventListener(eventName, onEvent, { once: true });
});
}
async function syncAndAutoplay() {
const el = audioEl;
if (!el) return;
// Capture current src BEFORE syncing so we can detect changes reliably.
const beforeSrc = el.currentSrc;
syncAudioToCurrentTrack();
const afterSrc = el.currentSrc;
if (afterSrc !== beforeSrc) {
if (el.readyState < 1) {
await waitForEvent(el, "loadedmetadata");
}
if (el.readyState < 3) {
await waitForEvent(el, "canplay");
}
} else {
await Promise.resolve();
}
try {
await el.play();
} catch {
// Autoplay may be blocked; bail out quietly.
return;
}
if (el.paused) {
if (el.readyState < 3) {
await waitForEvent(el, "canplay");
}
await Promise.race([waitForEvent(el, "playing"), Promise.resolve()]);
try {
await el.play();
} catch {
// ignore
}
}
}
function onAudioPlay() {
isPlaying = true;
media.setPlaybackState("playing");
}
function onAudioPause() {
isPlaying = false;
media.setPlaybackState("paused");
}
function onAudioTimeUpdate() {
// `currentTime` is synced via `bind:currentTime` on <audio>.
// Keep Media Session position state updated whenever we get timeupdate.
if (!audioEl) return;
media.updatePositionState({
duration,
position: currentTime,
playbackRate: audioEl.playbackRate || 1,
});
}
function onAudioLoadedMetadata() {
// `duration` is synced via `bind:duration` on <audio>.
if (!audioEl) return;
media.updatePositionState({
duration,
position: currentTime,
playbackRate: audioEl.playbackRate || 1,
});
}
function onAudioEnded() {
next();
void syncAndAutoplay();
}
function formatTime(seconds: number) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const s = Math.floor(seconds);
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
}
function typeNumberLabel(t: Track) {
const type = t.type;
const n = Number(t.number ?? 0);
let typeLabel: string | null = null;
if (typeof type === "number") {
if (type === 1) typeLabel = "OP";
else if (type === 2) typeLabel = "ED";
else if (type === 3) typeLabel = "INS";
else typeLabel = `T${type}`;
}
if (!typeLabel) return null;
return `${typeLabel}${n ? String(n) : ""}`;
}
function animeLabel(t: Track) {
return ((t.animeName ?? t.album ?? "").trim() || "Unknown anime").trim();
}
function trackLabel(t: Track): TrackLabel {
const title = (t.title ?? "").trim() || "Unknown title";
const artist = (t.artist ?? "").trim() || "Unknown Artist";
const primary = `${title}${artist}`;
const anime = (t.animeName ?? t.album ?? "").trim();
const typeNo = typeNumberLabel(t);
const secondary = [anime, typeNo].filter(Boolean).join(" • ") || null;
return { primary, secondary };
}
function computeQueueDisplay(): QueueDisplayItem[] {
const q = snap.queue;
if (q.length === 0) return [];
const current = snap.currentIndex;
// Non-shuffle: display stable queue order.
if (!snap.shuffleEnabled) {
return q.map((track, queueIndex) => ({
track,
queueIndex,
isCurrent: current === queueIndex,
}));
}
// Shuffle: display play order (history up to cursor + future order).
// This prevents the UI's "current position" from appearing to jump around.
//
// Invariants we try to preserve:
// - Past (history[0..cursor]) stays in the same order.
// - Current item is history[cursor] when available, otherwise currentIndex.
// - Future comes from `order` (already excludes visited indices).
const out: QueueDisplayItem[] = [];
const seen = new Set<number>();
const pushIndex = (queueIndex: number) => {
if (!Number.isInteger(queueIndex)) return;
if (queueIndex < 0 || queueIndex >= q.length) return;
if (seen.has(queueIndex)) return;
seen.add(queueIndex);
out.push({
track: q[queueIndex]!,
queueIndex,
isCurrent: current === queueIndex,
});
};
// Past + current
const hist = snap.history;
const cursor = snap.cursor;
for (let i = 0; i < hist.length; i += 1) {
// Only show "past" up through current cursor; the rest would be "future via history"
// and is confusing to duplicate (future is represented by `order`).
if (i > cursor) break;
pushIndex(hist[i]!);
}
// If history is empty or cursor doesn't match currentIndex, ensure currentIndex is shown.
if (current != null) pushIndex(current);
// Future
for (const i of snap.order) pushIndex(i);
// Any remaining items (not yet visited nor scheduled) go at the end (rare but possible after edits).
for (let i = 0; i < q.length; i += 1) pushIndex(i);
return out;
}
const queueDisplay = $derived(computeQueueDisplay());
const canPrev = $derived(
snap.queue.length > 0 &&
snap.currentIndex != null &&
(snap.wrapEnabled || snap.currentIndex > 0 || snap.shuffleEnabled),
);
const canNext = $derived(
snap.queue.length > 0 &&
(snap.wrapEnabled ||
(snap.currentIndex != null &&
snap.currentIndex < snap.queue.length - 1) ||
snap.shuffleEnabled),
);
const uiVisible = $derived(isMobile ? snap.uiOpen : true);
$effect(() => {
media.setTrack(snap.currentTrack);
schedulePersistNow();
// Keep preload targets updated as the queue/traversal state changes
updatePreloadTargets();
if (!audioEl) return;
audioEl.volume = snap.volume;
syncAudioToCurrentTrack();
});
onMount(() => {
updateIsMobile();
if (browser) {
window.addEventListener("resize", updateIsMobile);
}
// Create the offscreen preloader audio element in the browser
if (browser) {
preloadEl = new Audio();
preloadEl.preload = "auto";
preloadEl.muted = true;
}
// Subscribe to player changes instead of polling
unsubscribe = subscribe((s) => {
snap = s;
});
// Listen for play requests dispatched from anywhere (e.g. SongEntry buttons).
// This ensures "Play" behaves like Next/Prev: it will reliably sync the new
// source and start playback.
if (browser) {
const onAutoplayRequest = () => {
void syncAndAutoplay();
};
window.addEventListener("amqtrain:player:autoplay", onAutoplayRequest);
return () => {
window.removeEventListener(
"amqtrain:player:autoplay",
onAutoplayRequest,
);
};
}
media.setPlaybackState("paused");
});
onDestroy(() => {
if (browser) {
window.removeEventListener("resize", updateIsMobile);
}
if (unsubscribe) unsubscribe();
media.destroy();
});
</script>
<svelte:head>
<!-- Preload the next track (best-effort). We set this href reactively below. -->
{#if nextPreloadHref}
<link rel="preload" as="audio" href={nextPreloadHref} />
{/if}
</svelte:head>
{#if isMobile}
<!-- Mobile: mini bar + expandable drawer -->
<div
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur shadow-2xl"
>
<div class="mx-auto flex max-w-4xl items-center gap-2 px-3 py-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Close player" : "Open player"}
title={snap.uiOpen ? "Close player" : "Open player"}
>
{#if snap.uiOpen}
<X class="h-4 w-4" />
{:else}
<ChevronsUpDown class="h-4 w-4" />
{/if}
</button>
<div
class="min-w-0 flex-1"
role="button"
tabindex="0"
aria-label={snap.uiOpen ? "Close player" : "Open player"}
onclick={(e) => {
const t = e.target as HTMLElement | null;
// Only toggle when tapping the track info area. Avoid toggling from
// the button zones (above/below/side) by not attaching handlers to the
// whole bar.
if (t?.closest("button,input,label,a")) return;
toggleUiOpen();
}}
onkeydown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const t = e.target as HTMLElement | null;
if (t?.closest("button,input,label,a")) return;
e.preventDefault();
toggleUiOpen();
}}
>
{#if snap.currentTrack}
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{#if typeNumberLabel(snap.currentTrack)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(snap.currentTrack)}
</span>
{/if}
<span class="truncate">
{(
snap.currentTrack.animeName ??
snap.currentTrack.album ??
""
).trim()}
</span>
</div>
<div class="mt-1 text-foreground/80">
{(snap.currentTrack.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
</span>
</div>
{:else}
<div class="truncate text-sm font-medium">{nowPlayingLabel()}</div>
{/if}
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<label class="mt-2 block">
<span class="sr-only">Seek</span>
<input
class="w-full"
type="range"
min="0"
max={Math.max(0, duration)}
step="0.1"
bind:value={currentTime}
disabled={!snap.currentTrack || duration <= 0}
/>
</label>
</div>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canPrev}
aria-label="Previous"
title="Previous"
onclick={() => {
prev(currentTime);
void syncAndAutoplay();
}}
>
<SkipBack class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{#if isPlaying}
<PauseIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canNext}
aria-label="Next"
title="Next"
onclick={() => {
next();
void syncAndAutoplay();
}}
>
<SkipForward class="h-4 w-4" />
</button>
</div>
{#if snap.uiOpen}
<div class="max-h-[65dvh] overflow-y-auto border-t px-3 py-3">
<div class="mx-auto max-w-4xl space-y-3">
<div class="flex flex-wrap items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleShuffle()}
aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
>
<Shuffle class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground" aria-hidden="true">
<Volume2 class="h-4 w-4" />
</span>
<span class="sr-only">Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={snap.volume}
oninput={(e) =>
setVolume(
Number((e.currentTarget as HTMLInputElement).value),
)}
/>
</label>
</div>
<div class="space-y-1">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
{#if snap.queue.length === 0}
<p class="text-sm text-muted-foreground">Queue is empty.</p>
{:else}
<ul class="overflow-auto rounded border">
{#each queueDisplay as item (item.track.id)}
<li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
>
<button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline"
type="button"
onclick={() => {
jumpToTrack(item.track.id);
void syncAndAutoplay();
}}
>
{#if item.isCurrent}
<span class="text-muted-foreground"></span>
{/if}
<div class="min-w-0 flex-1">
<div
class="flex flex-wrap items-baseline gap-x-2 gap-y-1"
>
{#if typeNumberLabel(item.track)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(item.track)}
</span>
{/if}
<span class="truncate text-sm"
>{animeLabel(item.track)}</span
>
</div>
<div class="mt-1 text-foreground/80">
{(item.track.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(item.track.artist ?? "").trim() ||
"Unknown Artist"}
</span>
</div>
</div>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => removeTrack(item.track.id)}
aria-label="Remove from queue"
title="Remove from queue"
>
<ListX class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>
{/if}
</div>
{:else}
<!-- Desktop: sticky, in-flow sidebar (sticks vertically, flows horizontally in the layout column) -->
<aside
class="sticky top-4 h-[calc(100dvh-2rem)] overflow-hidden bg-background flex flex-col"
>
<div class="flex items-center gap-2 border-b px-3 py-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">Player</div>
{#if snap.currentTrack}
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
{#if typeNumberLabel(snap.currentTrack)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(snap.currentTrack)}
</span>
{/if}
<span class="truncate">
{(
snap.currentTrack.animeName ??
snap.currentTrack.album ??
""
).trim()}
</span>
</div>
<div class="mt-1 text-foreground/80">
{(snap.currentTrack.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(snap.currentTrack.artist ?? "").trim() || "Unknown Artist"}
</span>
</div>
{:else}
<div class="truncate text-xs text-muted-foreground">
{nowPlayingLabel()}
</div>
{/if}
</div>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleUiOpen()}
aria-label={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
title={snap.uiOpen ? "Hide player sidebar" : "Show player sidebar"}
>
{#if snap.uiOpen}
<PanelRightClose class="h-4 w-4" />
{:else}
<PanelRightOpen class="h-4 w-4" />
{/if}
</button>
</div>
{#if snap.uiOpen}
<div class="flex min-h-0 flex-col">
<div class="space-y-2 px-3 py-3">
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-muted-foreground">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleShuffle()}
aria-label={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
title={snap.shuffleEnabled ? "Shuffle on" : "Shuffle off"}
>
<Shuffle class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => toggleWrap()}
aria-label={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
title={snap.wrapEnabled ? "Wrap on" : "Wrap off"}
>
<Repeat class="h-4 w-4" />
</button>
</div>
</div>
<label class="block">
<span class="sr-only">Seek</span>
<input
class="w-full"
type="range"
min="0"
max={Math.max(0, duration)}
step="0.1"
bind:value={currentTime}
disabled={!snap.currentTrack || duration <= 0}
/>
</label>
<div class="flex items-center gap-2">
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canPrev}
aria-label="Previous"
title="Previous"
onclick={() => {
prev(currentTime);
void syncAndAutoplay();
}}
>
<SkipBack class="h-4 w-4" />
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
aria-label={isPlaying ? "Pause" : "Play"}
title={isPlaying ? "Pause" : "Play"}
onclick={() => {
if (!audioEl) return;
if (audioEl.paused) void audioEl.play();
else audioEl.pause();
}}
disabled={!snap.currentTrack}
>
{#if isPlaying}
<PauseIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
disabled={!canNext}
aria-label="Next"
title="Next"
onclick={() => {
next();
void syncAndAutoplay();
}}
>
<SkipForward class="h-4 w-4" />
</button>
<label class="ml-auto flex items-center gap-2 text-sm">
<span class="text-muted-foreground" aria-hidden="true">
<Volume2 class="h-4 w-4" />
</span>
<span class="sr-only">Volume</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={snap.volume}
oninput={(e) =>
setVolume(
Number((e.currentTarget as HTMLInputElement).value),
)}
/>
</label>
</div>
<div class="border-t pt-2">
<div class="text-sm font-semibold">Queue ({snap.queue.length})</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-3 pb-3">
{#if snap.queue.length === 0}
<p class="text-sm text-muted-foreground">Queue is empty.</p>
{:else}
<ul class="rounded border">
{#each queueDisplay as item (item.track.id)}
<li
class="flex items-center gap-2 border-b px-2 py-2 last:border-b-0"
>
<button
class="min-w-0 flex-1 truncate text-left text-sm hover:underline"
type="button"
onclick={() => {
jumpToTrack(item.track.id);
void syncAndAutoplay();
}}
>
{#if item.isCurrent}
<span class="text-muted-foreground"></span>
{/if}
<div class="min-w-0 flex-1">
<div
class="flex flex-wrap items-baseline gap-x-2 gap-y-1"
>
{#if typeNumberLabel(item.track)}
<span
class="rounded bg-muted px-2 py-0.5 text-sm text-muted-foreground"
>
{typeNumberLabel(item.track)}
</span>
{/if}
<span class="truncate text-sm"
>{animeLabel(item.track)}</span
>
</div>
<div class="mt-1 text-foreground/80">
{(item.track.title ?? "").trim() || "Unknown title"}
<span class="text-sm text-muted-foreground">
{(item.track.artist ?? "").trim() ||
"Unknown Artist"}
</span>
</div>
</div>
</button>
<button
class="inline-flex h-8 w-8 items-center justify-center rounded border"
type="button"
onclick={() => removeTrack(item.track.id)}
aria-label="Remove from queue"
title="Remove from queue"
>
<ListX class="h-4 w-4" />
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}
</aside>
{/if}
<!-- Single global audio element (hidden but functional) -->
<audio
bind:this={audioEl}
class="hidden"
preload="metadata"
bind:currentTime
bind:duration
onplay={onAudioPlay}
onpause={onAudioPause}
ontimeupdate={onAudioTimeUpdate}
onloadedmetadata={onAudioLoadedMetadata}
onended={onAudioEnded}
></audio>