mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
99c78d8567
commit
d7abf9be3b
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -3,6 +3,8 @@ export const radio = {
|
||||
empty: 'Радиостанции не настроены.',
|
||||
addStation: 'Добавить станцию',
|
||||
editStation: 'Изменить',
|
||||
playStation: 'Воспроизвести станцию',
|
||||
stopStation: 'Остановить станцию',
|
||||
deleteStation: 'Удалить станцию',
|
||||
confirmDelete: 'Нажмите ещё раз для подтверждения',
|
||||
stationName: 'Название станции…',
|
||||
|
||||
@@ -3,6 +3,8 @@ export const radio = {
|
||||
empty: '未配置任何电台。',
|
||||
addStation: '添加电台',
|
||||
editStation: '编辑',
|
||||
playStation: '播放电台',
|
||||
stopStation: '停止电台',
|
||||
deleteStation: '删除电台',
|
||||
confirmDelete: '再次点击确认',
|
||||
stationName: '电台名称…',
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user