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);