From d7abf9be3bee24f4bc927970d5d923608d57e196 Mon Sep 17 00:00:00 2001
From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com>
Date: Mon, 18 May 2026 22:10:04 +0200
Subject: [PATCH] fix(radio): card polish and paused streams stop auto-resuming
(#786)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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)
---
CHANGELOG.md | 18 +++++++++
src/components/internetRadio/RadioCard.tsx | 11 ++++--
.../playerBar/PlayerTransportControls.tsx | 5 ++-
src/locales/de/radio.ts | 2 +
src/locales/en/radio.ts | 2 +
src/locales/es/radio.ts | 2 +
src/locales/fr/radio.ts | 2 +
src/locales/nb/radio.ts | 2 +
src/locales/nl/radio.ts | 2 +
src/locales/ro/radio.ts | 2 +
src/locales/ru/radio.ts | 2 +
src/locales/zh/radio.ts | 2 +
src/pages/InternetRadio.tsx | 10 ++++-
src/store/radioPlayer.test.ts | 39 +++++++++++++++++++
src/store/radioPlayer.ts | 5 +++
src/utils/playback/fadeOut.ts | 19 +++++++++
src/utils/playback/playAlbum.ts | 17 +-------
17 files changed, 121 insertions(+), 21 deletions(-)
create mode 100644 src/utils/playback/fadeOut.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b2751a5f..65b18836 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/src/components/internetRadio/RadioCard.tsx b/src/components/internetRadio/RadioCard.tsx
index dff70b7a..23d155f0 100644
--- a/src/components/internetRadio/RadioCard.tsx
+++ b/src/components/internetRadio/RadioCard.tsx
@@ -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({
)}
-
diff --git a/src/components/playerBar/PlayerTransportControls.tsx b/src/components/playerBar/PlayerTransportControls.tsx
index a9ffb803..cf351028 100644
--- a/src/components/playerBar/PlayerTransportControls.tsx
+++ b/src/components/playerBar/PlayerTransportControls.tsx
@@ -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' ? : }
diff --git a/src/locales/de/radio.ts b/src/locales/de/radio.ts
index 57bb39df..a545ba10 100644
--- a/src/locales/de/radio.ts
+++ b/src/locales/de/radio.ts
@@ -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…',
diff --git a/src/locales/en/radio.ts b/src/locales/en/radio.ts
index 20154aee..4ec061e1 100644
--- a/src/locales/en/radio.ts
+++ b/src/locales/en/radio.ts
@@ -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…',
diff --git a/src/locales/es/radio.ts b/src/locales/es/radio.ts
index 33f67133..d3143e29 100644
--- a/src/locales/es/radio.ts
+++ b/src/locales/es/radio.ts
@@ -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…',
diff --git a/src/locales/fr/radio.ts b/src/locales/fr/radio.ts
index 3c6c68dc..5e8ac1d2 100644
--- a/src/locales/fr/radio.ts
+++ b/src/locales/fr/radio.ts
@@ -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…',
diff --git a/src/locales/nb/radio.ts b/src/locales/nb/radio.ts
index 6d4874be..ffa7feb2 100644
--- a/src/locales/nb/radio.ts
+++ b/src/locales/nb/radio.ts
@@ -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…',
diff --git a/src/locales/nl/radio.ts b/src/locales/nl/radio.ts
index 7329097f..5578709d 100644
--- a/src/locales/nl/radio.ts
+++ b/src/locales/nl/radio.ts
@@ -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…',
diff --git a/src/locales/ro/radio.ts b/src/locales/ro/radio.ts
index 45be79c8..00dca673 100644
--- a/src/locales/ro/radio.ts
+++ b/src/locales/ro/radio.ts
@@ -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…',
diff --git a/src/locales/ru/radio.ts b/src/locales/ru/radio.ts
index a2e32e8e..6b0724d6 100644
--- a/src/locales/ru/radio.ts
+++ b/src/locales/ru/radio.ts
@@ -3,6 +3,8 @@ export const radio = {
empty: 'Радиостанции не настроены.',
addStation: 'Добавить станцию',
editStation: 'Изменить',
+ playStation: 'Воспроизвести станцию',
+ stopStation: 'Остановить станцию',
deleteStation: 'Удалить станцию',
confirmDelete: 'Нажмите ещё раз для подтверждения',
stationName: 'Название станции…',
diff --git a/src/locales/zh/radio.ts b/src/locales/zh/radio.ts
index ba23805c..325a5b16 100644
--- a/src/locales/zh/radio.ts
+++ b/src/locales/zh/radio.ts
@@ -3,6 +3,8 @@ export const radio = {
empty: '未配置任何电台。',
addStation: '添加电台',
editStation: '编辑',
+ playStation: '播放电台',
+ stopStation: '停止电台',
deleteStation: '删除电台',
confirmDelete: '再次点击确认',
stationName: '电台名称…',
diff --git a/src/pages/InternetRadio.tsx b/src/pages/InternetRadio.tsx
index 6c2e9bb6..3314690c 100644
--- a/src/pages/InternetRadio.tsx
+++ b/src/pages/InternetRadio.tsx
@@ -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));
diff --git a/src/store/radioPlayer.test.ts b/src/store/radioPlayer.test.ts
index b00221ab..d48b73cb 100644
--- a/src/store/radioPlayer.test.ts
+++ b/src/store/radioPlayer.test.ts
@@ -38,6 +38,7 @@ const audio = _radioAudioForTest();
let pauseSpy: ReturnType;
let playSpy: ReturnType;
let loadSpy: ReturnType;
+let pausedSpy: ReturnType;
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', () => {
diff --git a/src/store/radioPlayer.ts b/src/store/radioPlayer.ts
index 406f146e..dfb8e75c 100644
--- a/src/store/radioPlayer.ts
+++ b/src/store/radioPlayer.ts
@@ -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,
+ from: number,
+ durationMs: number,
+): Promise {
+ 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);
+ });
+}
diff --git a/src/utils/playback/playAlbum.ts b/src/utils/playback/playAlbum.ts
index 6c579632..d455c3a2 100644
--- a/src/utils/playback/playAlbum.ts
+++ b/src/utils/playback/playAlbum.ts
@@ -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 {
- 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 {
const albumData = await getAlbum(albumId);