From 163e0e46a8addf0310ae470dc1a4dd2b44fed3b1 Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:16:38 +0200 Subject: [PATCH] feat(player-bar): persistent shuffle mode (#1288) * feat(player-bar): persistent shuffle mode Shuffle reorders the queue itself -- the tracks after the current one -- and remembers the order it came from, so switching it off restores that order. The flag and the remembered order are persisted together: shuffle survives a restart, so the order it can be undone to has to survive with it. Keeping the list untouched and only *playing* in a hidden random order was rejected: "what plays next" is derived from list order in four places (manual next, gapless successor, chain preload, crossfade/AutoDJ plan), the engine is handed the next track ~30s ahead with no way to take it back, and the server play-queue and Orbit guests only ever see the list order -- a hidden permutation would be invisible to them and lost on any other client. The toggle lives with the other queue mutations (it pushes an undo snapshot and syncs to the server like they do); the pure helpers stay in their own module, free of store imports, so no new import cycle is introduced. Restoring is id-based and total: duplicate track ids each keep their copy, rows enqueued while shuffle was on go to the end (they had no original position), and no row is ever lost or duplicated. * i18n(player): shuffle on/off state and player bar item label in all 14 locales * docs(changelog): add entry and credit for PR #1288 --- CHANGELOG.md | 6 + src/config/settingsCredits.ts | 1 + .../playback/components/PlayerBar.tsx | 6 +- .../playerBar/PlayerTransportControls.tsx | 23 ++- .../store/playerBarLayoutStore.test.ts | 8 +- .../playback/store/playerBarLayoutStore.ts | 3 + src/features/playback/store/playerStore.ts | 7 + .../playback/store/playerStoreTypes.ts | 7 + .../playback/store/queueMutationActions.ts | 50 +++++++ .../playback/store/shuffleModeActions.test.ts | 136 ++++++++++++++++++ .../playback/store/shuffleModeActions.ts | 71 +++++++++ .../playback/store/shuffleModeStorage.ts | 55 +++++++ .../components/PlayerBarLayoutCustomizer.tsx | 4 +- src/locales/bg/player.ts | 2 + src/locales/bg/settings.ts | 1 + src/locales/de/player.ts | 2 + src/locales/de/settings.ts | 1 + src/locales/en/player.ts | 2 + src/locales/en/settings.ts | 1 + src/locales/es/player.ts | 2 + src/locales/es/settings.ts | 1 + src/locales/fr/player.ts | 2 + src/locales/fr/settings.ts | 1 + src/locales/hu/player.ts | 2 + src/locales/hu/settings.ts | 1 + src/locales/it/player.ts | 2 + src/locales/it/settings.ts | 1 + src/locales/ja/player.ts | 2 + src/locales/ja/settings.ts | 1 + src/locales/nb/player.ts | 2 + src/locales/nb/settings.ts | 1 + src/locales/nl/player.ts | 2 + src/locales/nl/settings.ts | 1 + src/locales/pl/player.ts | 2 + src/locales/pl/settings.ts | 1 + src/locales/ro/player.ts | 2 + src/locales/ro/settings.ts | 1 + src/locales/ru/player.ts | 2 + src/locales/ru/settings.ts | 1 + src/locales/zh/player.ts | 2 + src/locales/zh/settings.ts | 1 + 41 files changed, 413 insertions(+), 6 deletions(-) create mode 100644 src/features/playback/store/shuffleModeActions.test.ts create mode 100644 src/features/playback/store/shuffleModeActions.ts create mode 100644 src/features/playback/store/shuffleModeStorage.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6e3128..fa3326b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Settings → Personalisation → Player bar** now also hides the **stop button** and shows the **album name** under the artist (off by default; clicking it opens the album). The right-hand buttons — star rating, favorite, love, playback speed, equalizer, mini player — can be **dragged into any order** you like. * The section is no longer behind **Advanced**. +### Shuffle + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1288](https://github.com/Psychotoxical/psysonic/pull/1288)** + +* A **shuffle toggle** in the player bar, next to the transport controls. While on, the queue is shuffled from the current track onwards — the playing track stays put — and turning it off restores the original order. It survives a restart, and the shuffled order is what your other devices and Orbit guests see, so playback stays in step everywhere. Hide the button under **Settings → Personalisation → Player bar** if you don't want it. + ## Fixed ### Per-track covers when playing from a playlist diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index 1bb41076..dc3aceb2 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -203,6 +203,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Track lists — optional album cover thumbnails via standard cover pipeline; queue rows use playback scope (PR #1280)', 'Internet Radio — Web Audio EQ on HTML5 streams; presets apply without reconnect (PR #1284)', 'Player bar — hideable stop button, optional album line, drag-reorderable action buttons (request: mikmik on Psysonic Discord, PR #1287)', + 'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)', ], }, { diff --git a/src/features/playback/components/PlayerBar.tsx b/src/features/playback/components/PlayerBar.tsx index 845ff642..2bf2f0e6 100644 --- a/src/features/playback/components/PlayerBar.tsx +++ b/src/features/playback/components/PlayerBar.tsx @@ -51,7 +51,7 @@ export default function PlayerBar() { const { currentTrack, currentRadio, isPlaying, volume, togglePlay, next, previous, setVolume, - stop, toggleRepeat, repeatMode, toggleFullscreen, + stop, toggleRepeat, repeatMode, toggleShuffleMode, shuffleMode, toggleFullscreen, networkLoved, toggleNetworkLove, starredOverrides, userRatingOverrides, @@ -68,6 +68,8 @@ export default function PlayerBar() { stop: s.stop, toggleRepeat: s.toggleRepeat, repeatMode: s.repeatMode, + toggleShuffleMode: s.toggleShuffleMode, + shuffleMode: s.shuffleMode, toggleFullscreen: s.toggleFullscreen, networkLoved: s.networkLoved, toggleNetworkLove: s.toggleNetworkLove, @@ -217,6 +219,8 @@ export default function PlayerBar() { next={next} toggleRepeat={toggleRepeat} repeatMode={repeatMode} + toggleShuffleMode={toggleShuffleMode} + shuffleMode={shuffleMode} playPauseBind={playPauseBind} scheduleRemaining={scheduleRemaining} transportAnchorRef={transportAnchorRef} diff --git a/src/features/playback/components/playerBar/PlayerTransportControls.tsx b/src/features/playback/components/playerBar/PlayerTransportControls.tsx index 49c02496..d091f947 100644 --- a/src/features/playback/components/playerBar/PlayerTransportControls.tsx +++ b/src/features/playback/components/playerBar/PlayerTransportControls.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Blend, Moon, Pause, Play, Repeat, Repeat1, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react'; +import { Blend, Moon, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Square, Sunrise } from 'lucide-react'; import { audioPreviewStop, audioPreviewStopSilent } from '@/lib/api/audio'; import type { TFunction } from 'i18next'; import type { PlayerState } from '@/features/playback/store/playerStoreTypes'; @@ -23,6 +23,8 @@ interface Props { next: () => void; toggleRepeat: () => void; repeatMode: RepeatMode; + toggleShuffleMode: () => void; + shuffleMode: boolean; playPauseBind: PlayPauseBind; scheduleRemaining: ScheduleRemaining; transportAnchorRef: React.RefObject; @@ -32,6 +34,7 @@ interface Props { export function PlayerTransportControls({ isPlaying, isRadio, isPreviewing, stop, previous, next, toggleRepeat, repeatMode, + toggleShuffleMode, shuffleMode, playPauseBind, scheduleRemaining, transportAnchorRef, playSlotRef, t, }: Props) { const autodjPhase = useAutodjTransitionUi(s => s.phase); @@ -42,6 +45,9 @@ export function PlayerTransportControls({ const showStop = usePlayerBarLayoutStore( s => s.items.find(i => i.id === 'stop')?.visible !== false, ); + const showShuffle = usePlayerBarLayoutStore( + s => s.items.find(i => i.id === 'shuffle')?.visible !== false, + ); return (
@@ -62,6 +68,21 @@ export function PlayerTransportControls({ )} + {showShuffle && ( + + )}