fix(radio): card polish and paused streams stop auto-resuming (#786)

* refactor(playback): extract fadeOut helper

Used by playAlbum.ts today; about to be reused for the radio delete
fade-out so it lives in its own module instead of being copy-pasted.

* fix(player): disable Repeat button while a radio stream is active

Matches the Prev / Next buttons that already gate on isRadio — repeat
has no meaning for a live stream, and the live tooltip plus accent
colour suggested it was interactive.

* fix(radio): fade out before deleting the playing station

Mirrors the 700 ms fade-out playAlbum uses on track changes — beats
the abrupt cut when the deleted station is the one currently on air.

* fix(radio): add Play/Stop tooltip to the cover-overlay button

Cast / X icon now describes itself, matching the favourite and delete
buttons that already had tooltips.

* fix(radio): use Square stop icon on the active card

The play-overlay and delete buttons both used X, making it ambiguous
which click stopped the stream and which deleted the station. Stop
gets a filled Square; delete keeps its X.

* fix(radio): cancel auto-reconnect when the user pauses a stream

Closes #779.

Root cause: a 'stalled' event during a paused stream still scheduled a
4 s reconnect timer that called play(), and the timer was not cancelled
on pauseRadio(). On macOS WKWebView the underlying TCP socket droops
roughly a minute after pause, the browser fires 'stalled', and the
stream resumes against the user's intent. The store's isPlaying flag
stays false because nothing on the reconnect path syncs it, so the
play/pause button stops matching reality.

Fix: skip the schedule when radioAudio.paused, re-check inside the
timer callback (covers a pause within the 4 s window), and clear any
pending timer in pauseRadio().

* docs: changelog for radio card polish + paused-stream fix (#786)
This commit is contained in:
Frank Stellmacher
2026-05-18 22:10:04 +02:00
committed by GitHub
parent 99c78d8567
commit d7abf9be3b
17 changed files with 121 additions and 21 deletions
+18
View File
@@ -70,6 +70,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Radio — paused streams stay paused
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by drelabre on GitHub, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)**
* Pausing a radio stream no longer auto-resumes after about a minute on macOS.
### Radio — card control polish
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#786](https://github.com/Psychotoxical/psysonic/pull/786)**
* Repeat is disabled while a radio stream plays.
* Deleting the playing station fades out instead of cutting hard.
* Play / Stop tooltip on the cover-overlay button; stop uses a Square icon.
## [1.46.0] - 2026-05-18
> **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages.
+8 -3
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Cast, Globe, Heart, Trash2, X } from 'lucide-react';
import { Cast, Globe, Heart, Square, Trash2, X } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { buildCoverArtUrl, coverArtCacheKey } from '../../api/subsonicStreamUrl';
import type { InternetRadioStation } from '../../api/subsonicTypes';
@@ -104,8 +104,13 @@ export default function RadioCard({
)}
<div className="album-card-play-overlay">
<button className="album-card-details-btn" onClick={onPlay}>
{isActive && isPlaying ? <X size={15} /> : <Cast size={14} />}
<button
className="album-card-details-btn"
onClick={onPlay}
data-tooltip={isActive && isPlaying ? t('radio.stopStation') : t('radio.playStation')}
data-tooltip-pos="bottom"
>
{isActive && isPlaying ? <Square size={13} fill="currentColor" /> : <Cast size={14} />}
</button>
</div>
@@ -110,7 +110,10 @@ export function PlayerTransportControls({
onClick={toggleRepeat}
aria-label={t('player.repeat')}
data-tooltip={`${t('player.repeat')}: ${repeatMode === 'off' ? t('player.repeatOff') : repeatMode === 'all' ? t('player.repeatAll') : t('player.repeatOne')}`}
style={{ color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
disabled={isRadio}
style={isRadio
? { opacity: 0.3, pointerEvents: 'none' }
: { color: repeatMode !== 'off' ? 'var(--accent)' : undefined }}
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Keine Radiosender konfiguriert.',
addStation: 'Sender hinzufügen',
editStation: 'Bearbeiten',
playStation: 'Sender abspielen',
stopStation: 'Sender stoppen',
deleteStation: 'Sender löschen',
confirmDelete: 'Zum Bestätigen erneut klicken',
stationName: 'Sendername…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'No radio stations configured.',
addStation: 'Add Station',
editStation: 'Edit',
playStation: 'Play station',
stopStation: 'Stop station',
deleteStation: 'Delete station',
confirmDelete: 'Click again to confirm',
stationName: 'Station name…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'No hay estaciones de radio configuradas.',
addStation: 'Agregar Estación',
editStation: 'Editar',
playStation: 'Reproducir estación',
stopStation: 'Detener estación',
deleteStation: 'Eliminar estación',
confirmDelete: 'Click de nuevo para confirmar',
stationName: 'Nombre de estación…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Aucune station radio configurée.',
addStation: 'Ajouter une station',
editStation: 'Modifier',
playStation: 'Lire la station',
stopStation: 'Arrêter la station',
deleteStation: 'Supprimer la station',
confirmDelete: 'Cliquer à nouveau pour confirmer',
stationName: 'Nom de la station…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Ingen radiostasjoner konfigurert.',
addStation: 'Legg til radio stasjon',
editStation: 'Rediger',
playStation: 'Spill stasjon',
stopStation: 'Stopp stasjon',
deleteStation: 'Slett stasjon',
confirmDelete: 'Klikk igjen for å bekrefte',
stationName: 'Stasjonsnavn…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Geen radiostations geconfigureerd.',
addStation: 'Station toevoegen',
editStation: 'Bewerken',
playStation: 'Station afspelen',
stopStation: 'Station stoppen',
deleteStation: 'Station verwijderen',
confirmDelete: 'Klik opnieuw om te bevestigen',
stationName: 'Stationsnaam…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Nicio stație radio configurată.',
addStation: 'Adaugă Stație',
editStation: 'Editează',
playStation: 'Redă stația',
stopStation: 'Oprește stația',
deleteStation: 'Șterge stație',
confirmDelete: 'Apasă din nou pentru a confirma',
stationName: 'Numele stației…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Радиостанции не настроены.',
addStation: 'Добавить станцию',
editStation: 'Изменить',
playStation: 'Воспроизвести станцию',
stopStation: 'Остановить станцию',
deleteStation: 'Удалить станцию',
confirmDelete: 'Нажмите ещё раз для подтверждения',
stationName: 'Название станции…',
+2
View File
@@ -3,6 +3,8 @@ export const radio = {
empty: '未配置任何电台。',
addStation: '添加电台',
editStation: '编辑',
playStation: '播放电台',
stopStation: '停止电台',
deleteStation: '删除电台',
confirmDelete: '再次点击确认',
stationName: '电台名称…',
+9 -1
View File
@@ -5,6 +5,8 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react'
import { Cast, Plus, Trash2, X, Globe, Camera, Loader2, Search, Heart, Check } from 'lucide-react';
import { useDragSource, useDragDrop } from '../contexts/DragDropContext';
import { usePlayerStore } from '../store/playerStore';
import { setRadioVolume } from '../store/radioPlayer';
import { fadeOut } from '../utils/playback/fadeOut';
import CachedImage from '../components/CachedImage';
import { invalidateCoverArt } from '../utils/imageCache';
import CustomSelect from '../components/CustomSelect';
@@ -190,7 +192,13 @@ export default function InternetRadio() {
setDeleteConfirmId(s.id);
return;
}
if (currentRadio?.id === s.id) stop();
if (currentRadio?.id === s.id) {
if (isPlaying) {
const vol = usePlayerStore.getState().volume;
await fadeOut(setRadioVolume, vol, 700);
}
stop();
}
try {
await deleteInternetRadioStation(s.id);
setStations(prev => prev.filter(st => st.id !== s.id));
+39
View File
@@ -38,6 +38,7 @@ const audio = _radioAudioForTest();
let pauseSpy: ReturnType<typeof vi.spyOn>;
let playSpy: ReturnType<typeof vi.spyOn>;
let loadSpy: ReturnType<typeof vi.spyOn>;
let pausedSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
@@ -48,6 +49,10 @@ beforeEach(() => {
pauseSpy = vi.spyOn(audio, 'pause').mockImplementation(() => {});
playSpy = vi.spyOn(audio, 'play').mockResolvedValue(undefined as never);
loadSpy = vi.spyOn(audio, 'load').mockImplementation(() => {});
// jsdom defaults audio.paused to true; the reconnect path assumes the
// stream was actively playing, so default the getter to false and let
// individual tests override it where they need to assert pause behaviour.
pausedSpy = vi.spyOn(audio, 'paused', 'get').mockReturnValue(false);
});
afterEach(() => {
@@ -55,6 +60,7 @@ afterEach(() => {
pauseSpy.mockRestore();
playSpy.mockRestore();
loadSpy.mockRestore();
pausedSpy.mockRestore();
vi.useRealTimers();
});
@@ -78,6 +84,14 @@ describe('playRadioStream', () => {
});
describe('pauseRadio / resumeRadio', () => {
it('pause cancels a pending reconnect timer (issue #779)', () => {
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
audio.dispatchEvent(new Event('stalled'));
pauseRadio();
vi.advanceTimersByTime(4000);
expect(loadSpy).not.toHaveBeenCalled();
});
it('pause delegates to audio.pause without touching src', async () => {
await playRadioStream('https://x/y', 0.5);
const before = audio.src;
@@ -197,6 +211,31 @@ describe('event listeners', () => {
vi.advanceTimersByTime(4000);
expect(loadSpy).not.toHaveBeenCalled();
});
it('"stalled" while paused does not schedule a reconnect (issue #779)', () => {
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
const pausedGet = vi.spyOn(audio, 'paused', 'get').mockReturnValue(true);
try {
audio.dispatchEvent(new Event('stalled'));
vi.advanceTimersByTime(4000);
expect(loadSpy).not.toHaveBeenCalled();
} finally {
pausedGet.mockRestore();
}
});
it('reconnect callback skips load+play if user paused during the 4 s wait (issue #779)', () => {
hoisted.playerStateGet.mockReturnValue({ currentRadio: { id: 'r1' } });
audio.dispatchEvent(new Event('stalled'));
const pausedGet = vi.spyOn(audio, 'paused', 'get').mockReturnValue(true);
try {
vi.advanceTimersByTime(4000);
expect(loadSpy).not.toHaveBeenCalled();
expect(playSpy).not.toHaveBeenCalled();
} finally {
pausedGet.mockRestore();
}
});
});
describe('clearRadioReconnectTimer', () => {
+5
View File
@@ -56,6 +56,7 @@ radioAudio.addEventListener('playing', () => {
// ensures we don't loop forever on a dead stream.
radioAudio.addEventListener('stalled', () => {
if (radioReconnectTimer) return; // already scheduled
if (radioAudio.paused) return; // user paused — reconnect would resume against intent
if (radioReconnectCount >= MAX_RADIO_RECONNECTS) {
radioReconnectCount = 0;
usePlayerStore.setState({ isPlaying: false, currentRadio: null });
@@ -65,6 +66,7 @@ radioAudio.addEventListener('stalled', () => {
radioReconnectTimer = setTimeout(() => {
radioReconnectTimer = null;
if (!usePlayerStore.getState().currentRadio) return;
if (radioAudio.paused) return; // user paused while we were waiting
radioReconnectCount++;
// Use load() + play() instead of src reassignment — more reliable on
// macOS WKWebView where setting src can fire a premature error event.
@@ -96,6 +98,9 @@ export function playRadioStream(streamUrl: string, volume: number): Promise<void
/** Soft pause — keeps the src loaded so resume can pick up cheaply. */
export function pauseRadio(): void {
// A reconnect timer may be pending from a previous 'stalled' event. Cancel
// it so it can't fire play() against the user's pause intent (issue #779).
clearRadioReconnectTimer();
radioAudio.pause();
}
+19
View File
@@ -0,0 +1,19 @@
export function fadeOut(
setVolume: (v: number) => void,
from: number,
durationMs: number,
): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
+1 -16
View File
@@ -2,22 +2,7 @@ import { getAlbum } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from './songToTrack';
import { useOrbitStore } from '../../store/orbitStore';
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
import { fadeOut } from './fadeOut';
export async function playAlbum(albumId: string): Promise<void> {
const albumData = await getAlbum(albumId);