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