Skip to content
Merged
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
34 changes: 28 additions & 6 deletions components/player/Player.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
<section v-show="!nowPlayingOpen" class="radio-player-dock fixed inset-x-0 bottom-0 z-[60] border-t border-[var(--radio-border)] bg-[var(--radio-surface-elevated)] shadow-[0_-12px_40px_-28px_rgba(15,23,42,0.4)]" :class="{ 'hidden': playerMini }" :aria-label="$t('player.title')">
<div class="radio-player-inner mx-auto max-w-[1920px] px-3 sm:px-6">
<div v-if="playerStatusVisible" class="sr-only" aria-live="polite">
<template v-if="streamError">
<span v-if="!online">{{ $t('text.offline') }}</span>
<template v-else-if="streamError">
<span class="truncate text-rose-500">{{ $t('alert.stream_error') }}</span>
<UButton size="xs" variant="link" class="shrink-0 p-0 text-[10px]" @click="retryCurrentSource">{{ $t('player.retry') }}</UButton>
</template>
<span v-else-if="!online">{{ $t('text.offline') }}</span>
<span v-else-if="waiting" class="inline-flex items-center gap-1.5"><span class="h-1 w-1 animate-pulse rounded-full bg-rose-500"></span>{{ $t('player.connecting') }}</span>
<span v-else-if="!hasPlayableSource">{{ $t('player.choose_station') }}</span>
<span v-if="sleepRemaining" class="shrink-0 tabular-nums">· {{ $t('player.sleep_remaining', { minutes: sleepRemaining }) }}</span>
Expand Down Expand Up @@ -259,8 +259,8 @@ const favoriteActive = computed(() => listening.library.favorites.some((item: {
const toggleFavorite = () => { if (playback.station) listening.toggleFavorite(playback.station); };
const { t } = useI18n();
const nowPlayingOpen = ref(false);
const playerStatus = computed(() => t(streamError.value ? 'alert.stream_error' : !online.value ? 'text.offline' : waiting.value ? 'player.connecting' : playing.value ? 'player.on_air' : 'redesign.paused'));
const liveStateLabel = computed(() => t(streamError.value ? 'alert.stream_error' : !online.value ? 'text.offline' : waiting.value ? 'player.connecting' : playing.value ? 'player.on_air' : 'player.ready'));
const playerStatus = computed(() => t(!online.value ? 'text.offline' : streamError.value ? 'alert.stream_error' : waiting.value ? 'player.connecting' : playing.value ? 'player.on_air' : 'redesign.paused'));
const liveStateLabel = computed(() => t(!online.value ? 'text.offline' : streamError.value ? 'alert.stream_error' : waiting.value ? 'player.connecting' : playing.value ? 'player.on_air' : 'player.ready'));
const liveStateClass = computed(() => {
if (streamError.value) return "bg-red-500/10 text-red-700 dark:text-red-300";
if (!online.value || !hasPlayableSource.value) return "bg-[var(--radio-surface-soft)] text-[var(--radio-text-muted)]";
Expand Down Expand Up @@ -314,6 +314,7 @@ const activeSourceIndex = ref(0);
const pendingAutoPlay = ref(false);
const userPaused = ref(true);
const attemptCount = ref(0);
const AUDIO_PREFERENCES_KEY = "radio:audio-preferences:v1";
const volume = ref(1);
const muted = ref(false);
// iOS browsers reserve playback volume for the hardware/system controls.
Expand Down Expand Up @@ -1093,12 +1094,18 @@ const onMediaPlaying = (): void => {

const onMediaWaiting = (): void => {
stopListeningDuration();
playing.value = false;
if (!userPaused.value) {
waiting.value = true;
startFallbackProbe();
}
};

// stalled describes network delivery; buffered audio may still be playing.
const onMediaStalled = (): void => {
if ((mediaElement.value?.readyState ?? 0) < 3) onMediaWaiting();
};

const onMediaPause = (): void => {
stopListeningDuration();
playing.value = false;
Expand Down Expand Up @@ -1129,6 +1136,9 @@ const onVolumeChange = (): void => {
if (!media) return;
volume.value = media.volume;
muted.value = media.muted;
try {
localStorage.setItem(AUDIO_PREFERENCES_KEY, JSON.stringify({ volume: media.volume, muted: media.muted }));
} catch { /* Storage restrictions must not interrupt audio. */ }
};

const bindMediaEvents = (): void => {
Expand All @@ -1138,7 +1148,7 @@ const bindMediaEvents = (): void => {
media.addEventListener("canplay", onMediaCanPlay);
media.addEventListener("playing", onMediaPlaying);
media.addEventListener("waiting", onMediaWaiting);
media.addEventListener("stalled", onMediaWaiting);
media.addEventListener("stalled", onMediaStalled);
media.addEventListener("pause", onMediaPause);
media.addEventListener("ended", onMediaEnded);
media.addEventListener("error", onMediaError);
Expand All @@ -1152,7 +1162,7 @@ const unbindMediaEvents = (): void => {
media.removeEventListener("canplay", onMediaCanPlay);
media.removeEventListener("playing", onMediaPlaying);
media.removeEventListener("waiting", onMediaWaiting);
media.removeEventListener("stalled", onMediaWaiting);
media.removeEventListener("stalled", onMediaStalled);
media.removeEventListener("pause", onMediaPause);
media.removeEventListener("ended", onMediaEnded);
media.removeEventListener("error", onMediaError);
Expand Down Expand Up @@ -1256,6 +1266,18 @@ onMounted(() => {
window.addEventListener("resize", updateViewport);
online.value = navigator.onLine;

// Restore before binding volumechange so partial restoration cannot overwrite preferences.
const savedMedia = mediaElement.value;
if (savedMedia) {
try {
const saved = JSON.parse(localStorage.getItem(AUDIO_PREFERENCES_KEY) || 'null');
if (saved && typeof saved.muted === 'boolean') savedMedia.muted = saved.muted;
if (!systemVolumeOnly.value && typeof saved?.volume === 'number'
&& Number.isFinite(saved.volume) && saved.volume >= 0 && saved.volume <= 1) {
savedMedia.volume = saved.volume;
}
} catch { /* Ignore malformed or unavailable preferences. */ }
}
bindMediaEvents();
registerMediaSession();
updateMediaSession();
Expand Down
13 changes: 12 additions & 1 deletion composables/useStationRealtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,23 @@ export function useStationRealtime() {
const scheduleIsReference = computed(() => Boolean(referenceSchedule.value));
const timestampUrl = (url: string) => /[?&](?:time|timestamp)=$/i.test(url) ? `${url}${Math.floor(Date.now() / 1000)}` : url;

const lyricCache = new Map<string, string[]>();
const fetchLyric = async (id: string, signal: AbortSignal): Promise<string[]> => {
if (!import.meta.client) return [];
if (lyricCache.has(id)) return lyricCache.get(id)!;
if (!config.public.apiZingGetLyric) return [t('panel.no_lyric')];
try {
const result = await $fetch<{ err: number; data?: { sentences?: { words: { data: string }[] }[] } }>(
`${config.public.apiZingGetLyric}${id}&time=${Math.floor(Date.now() / 1000)}`, { signal, timeout: 10_000 },
);
if (result.err === 0 && result.data?.sentences?.length) return result.data.sentences.map(s => s.words.map(w => w.data).join(' '));
if (result.err === 0 && result.data?.sentences?.length) {
const lines = result.data.sentences.map(s => s.words.map(w => w.data).join(' '));
if (!signal.aborted) {
if (lyricCache.size >= 20) lyricCache.delete(lyricCache.keys().next().value!);
lyricCache.set(id, lines);
}
return lines;
}
} catch (error) {
if (signal.aborted) throw error;
}
Expand Down Expand Up @@ -63,6 +72,8 @@ export function useStationRealtime() {

const polling = createStationPolling({
load: fetchSchedule,
// Keep metadata fresh for background playback and visible schedule browsing.
shouldPoll: () => navigator.onLine && (!document.hidden || playback.playing || playback.waiting),
onData: (value: RealtimeData | undefined) => {
if (!value) return;
program.value = value.program;
Expand Down
6 changes: 6 additions & 0 deletions docs/PROJECT_CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,3 +639,9 @@ branch commit before generating or publishing files. The next current run
performs the normal checks. An active upload/deploy finishes before the next
workflow starts. Previously dispatched runs retain their old concurrency
configuration until completion.

## Playback reliability follow-up (2026-10-18)

- `waiting` clears actual-playing state; only the media `playing` event restores it. Network `stalled` must not clear playing when readyState still has future buffered data.
- Volume/mute preferences use `radio:audio-preferences:v1`, restored before volume listeners, with guarded storage and range validation. iOS system volume is never assigned programmatically. Preferences never authorize autoplay.
- Metadata polling skips offline and hidden idle sessions, but continues for visible schedules and background playback. Successful lyrics are cached by media ID (20 entries); failed requests remain retryable.
35 changes: 35 additions & 0 deletions tests/player-buffering-state.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const source = readFileSync(new URL('../components/player/Player.vue', import.meta.url), 'utf8');
function handler(name, context) {
const body = source.match(new RegExp(`const ${name} = \\(\\): void => \\{([\\s\\S]*?)\\n\\};`))?.[1];
assert.ok(body, `missing ${name}`);
return new Function(...Object.keys(context), body).bind(null, ...Object.values(context));
}
test('waiting clears actual-playing state and starts bounded recovery only with play intent', () => {
let stops = 0, probes = 0;
const playing = { value: true }, waiting = { value: false }, userPaused = { value: false };
const run = handler('onMediaWaiting', { playing, waiting, userPaused,
stopListeningDuration: () => stops++, startFallbackProbe: () => probes++ });
run();
assert.equal(playing.value, false);
assert.equal(waiting.value, true);
assert.equal(stops, 1);
assert.equal(probes, 1);
userPaused.value = true;
waiting.value = false;
run();
assert.equal(waiting.value, false);
assert.equal(probes, 1);
});
test('network stall preserves buffered playback and only recovers when data runs out', () => {
let waits = 0;
const mediaElement = { value: { readyState: 4 } };
const run = handler('onMediaStalled', { mediaElement, onMediaWaiting: () => waits++ });
run();
assert.equal(waits, 0);
mediaElement.value.readyState = 2;
run();
assert.equal(waits, 1);
});
19 changes: 19 additions & 0 deletions tests/station-polling.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,22 @@ test('catalog activation preserves explicit proxy sources and ignores invalid le
['https://example/live.m3u8', false], ['https://proxy/https://example/backup.mp3', true],
]);
});

test('suspended polling makes no request and resumes without losing its station', async () => {
let allowed = false;
let callback;
const calls = [];
const polling = createStationPolling({
shouldPoll: () => allowed,
load: async url => { calls.push(url); return url; },
onData: () => {}, onLoading: () => {},
setTimer: fn => { callback = fn; return 1; }, clearTimer: () => {},
});
polling.start('current-station');
await tick();
assert.deepEqual(calls, []);
allowed = true;
await callback();
assert.deepEqual(calls, ['current-station']);
polling.stop();
});
3 changes: 2 additions & 1 deletion utils/station-polling.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// A polling lifetime follows the selected station, independent of the route.
// Abort saves work; the generation check also protects against providers that
// complete a request even after it has been aborted.
export function createStationPolling({ load, onData, onError, onLoading, interval = 60_000, setTimer = setTimeout, clearTimer = clearTimeout }) {
export function createStationPolling({ load, onData, onError, onLoading, interval = 60_000, shouldPoll = () => true, setTimer = setTimeout, clearTimer = clearTimeout }) {
let generation = 0;
let timer;
let controller;
Expand All @@ -27,6 +27,7 @@ export function createStationPolling({ load, onData, onError, onLoading, interva

const poll = async () => {
try {
if (!shouldPoll()) return;
const data = await load(url, signal);
if (version !== generation || signal.aborted) return;
onData(data);
Expand Down
Loading