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
This commit is contained in:
Psychotoxical
2026-07-13 22:16:38 +02:00
committed by GitHub
parent d0edd925e4
commit 163e0e46a8
41 changed files with 413 additions and 6 deletions
+6
View File
@@ -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
+1
View File
@@ -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)',
],
},
{
@@ -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}
@@ -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<HTMLDivElement | null>;
@@ -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 (
<div className="player-buttons" ref={transportAnchorRef}>
@@ -62,6 +68,21 @@ export function PlayerTransportControls({
<Square size={14} fill="currentColor" />
</button>
)}
{showShuffle && (
<button
className="player-btn player-btn-sm"
onClick={toggleShuffleMode}
aria-label={t('player.shuffle')}
aria-pressed={shuffleMode}
data-tooltip={`${t('player.shuffle')}: ${shuffleMode ? t('player.shuffleOn') : t('player.shuffleOff')}`}
disabled={isRadio}
style={isRadio
? { opacity: 0.3, pointerEvents: 'none' }
: { color: shuffleMode ? 'var(--accent)' : undefined }}
>
<Shuffle size={14} />
</button>
)}
<button
className="player-btn"
onClick={() => previous()}
@@ -31,16 +31,18 @@ describe('playerBarLayoutStore', () => {
it('starts with every item visible in declared order', () => {
const items = usePlayerBarLayoutStore.getState().items;
expect(ids(items)).toEqual([
'stop', 'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
'stop', 'shuffle', 'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
]);
expect(items.every(i => i.visible)).toBe(true);
expect(usePlayerBarLayoutStore.getState().trackInfoMode).toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
});
it('places stop in the transport zone and the rest in actions', () => {
it('puts the playback controls in the transport zone and the rest in actions', () => {
// Transport items sit among the fixed controls: visibility only, no reorder.
expect(PLAYER_BAR_LAYOUT_ZONES.stop).toBe('transport');
expect(PLAYER_BAR_LAYOUT_ZONES.shuffle).toBe('transport');
expect(ids(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS)
.filter(id => id !== 'stop')
.filter(id => id !== 'stop' && id !== 'shuffle')
.every(id => PLAYER_BAR_LAYOUT_ZONES[id] === 'actions')).toBe(true);
});
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
export type PlayerBarLayoutItemId =
| 'stop'
| 'shuffle'
| 'starRating'
| 'favorite'
// 'lastfmLove' is the enrichment-primary love button. The id is kept (not
@@ -27,6 +28,7 @@ export type PlayerBarLayoutZone = 'transport' | 'actions';
export const PLAYER_BAR_LAYOUT_ZONES: Record<PlayerBarLayoutItemId, PlayerBarLayoutZone> = {
stop: 'transport',
shuffle: 'transport',
starRating: 'actions',
favorite: 'actions',
lastfmLove: 'actions',
@@ -47,6 +49,7 @@ export interface PlayerBarLayoutItemConfig {
export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
{ id: 'stop', visible: true },
{ id: 'shuffle', visible: true },
{ id: 'starRating', visible: true },
{ id: 'favorite', visible: true },
{ id: 'lastfmLove', visible: true },
@@ -26,9 +26,15 @@ import { createScheduleActions } from '@/features/playback/store/scheduleActions
import { createTransportLightActions } from '@/features/playback/store/transportLightActions';
import { createUiStateActions } from '@/features/playback/store/uiStateActions';
import { createUndoRedoActions } from '@/features/playback/store/undoRedoActions';
import { setShuffleOriginalOrder } from '@/features/playback/store/shuffleModeActions';
import { readShuffleModeSnapshot } from '@/features/playback/store/shuffleModeStorage';
const initialPlayerPrefs = readInitialPlayerPrefs();
const initialNetworkLovedCache = readInitialNetworkLovedCache();
// Shuffle survives a restart, so the pre-shuffle order has to come back with it
// — otherwise turning shuffle off later could not restore the queue.
const initialShuffleMode = readShuffleModeSnapshot();
setShuffleOriginalOrder(initialShuffleMode.originalOrder);
let playerPersistWritesEnabled = false;
export const usePlayerStore = create<PlayerState>()(
@@ -74,6 +80,7 @@ export const usePlayerStore = create<PlayerState>()(
scheduledResumeAtMs: null,
scheduledResumeStartMs: null,
repeatMode: initialPlayerPrefs.repeatMode,
shuffleMode: initialShuffleMode.enabled,
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
songInfoModal: { isOpen: false, songId: null },
@@ -120,6 +120,13 @@ export interface PlayerState {
repeatMode: 'off' | 'all' | 'one';
toggleRepeat: () => void;
/**
* Persistent shuffle. Reorders the queue itself rather than keeping a hidden
* play order, so every consumer of "the next item in the list" — gapless
* chain, server play-queue, Orbit guests — stays correct.
*/
shuffleMode: boolean;
toggleShuffleMode: () => void;
reorderQueue: (startIndex: number, endIndex: number) => void;
removeTrack: (index: number) => void;
@@ -26,6 +26,13 @@ import {
ensureQueueServerPinned,
} from '@/features/playback/utils/playback/playbackServer';
import { clearTimelineSessionHistory } from '@/features/playback/store/timelineSessionHistory';
import {
getShuffleOriginalOrder,
restoreOriginalOrder,
setShuffleOriginalOrder,
shuffled,
} from '@/features/playback/store/shuffleModeActions';
import { persistShuffleModeSnapshot } from '@/features/playback/store/shuffleModeStorage';
type SetState = (
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
@@ -69,9 +76,52 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
| 'reorderQueue'
| 'shuffleQueue'
| 'shuffleUpcomingQueue'
| 'toggleShuffleMode'
| 'removeTrack'
> {
return {
/**
* Persistent shuffle: reorders the queue itself and remembers the order it
* came from, so switching it off restores that order. Rationale for
* reordering rather than keeping a hidden play order: see shuffleModeActions.
*/
toggleShuffleMode: () => {
const state = get();
const { currentTrack, queueIndex } = state;
const items = itemsOf(state);
const enabling = !state.shuffleMode;
// The flag flips even on an empty queue — the user is setting a mode, and
// it has to hold for whatever they play next.
if (items.length === 0) {
setShuffleOriginalOrder([]);
persistShuffleModeSnapshot({ enabled: enabling, originalOrder: [] });
set({ shuffleMode: enabling });
return;
}
pushQueueUndoFromGetter(get);
let result: QueueItemRef[];
if (enabling) {
setShuffleOriginalOrder(items.map(r => r.trackId));
// Everything up to and including the current track stays put: the playing
// track must not move, and already-played rows are history.
result = [...items.slice(0, queueIndex + 1), ...shuffled(items.slice(queueIndex + 1))];
} else {
result = restoreOriginalOrder(items, getShuffleOriginalOrder());
setShuffleOriginalOrder([]);
}
persistShuffleModeSnapshot({ enabled: enabling, originalOrder: getShuffleOriginalOrder() });
const newIndex = currentTrack
? Math.max(0, result.findIndex(r => r.trackId === currentTrack.id))
: 0;
set({ shuffleMode: enabling, queueItems: result, queueIndex: newIndex });
syncUserQueueMutationToServer(result, currentTrack, get().currentTime);
},
enqueue: (tracks, _orbitConfirmed = false, skipQueueUndo = false) => {
if (!_orbitConfirmed && tracks.length > 1) {
void orbitBulkGuard(tracks.length).then(ok => {
@@ -0,0 +1,136 @@
// Shuffle physically reorders the queue, so the risk is not "is it random" — it
// is whether turning it off puts the queue back. These tests pin the restore:
// the playing track never moves, duplicate track ids do not collapse, and rows
// that appeared while shuffle was on (enqueued, radio top-up) are kept rather
// than dropped on the floor.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { QueueItemRef } from '@/lib/media/trackTypes';
import { restoreOriginalOrder, setShuffleOriginalOrder } from './shuffleModeActions';
import { createQueueMutationActions } from './queueMutationActions';
vi.mock('@/features/playback/store/queueSync', () => ({
syncUserQueueMutationToServer: vi.fn(),
}));
vi.mock('@/features/playback/store/queueUndo', () => ({
pushQueueUndoFromGetter: vi.fn(),
}));
const ref = (trackId: string): QueueItemRef => ({ serverId: 's1', trackId });
const ids = (items: QueueItemRef[]) => items.map(i => i.trackId);
describe('restoreOriginalOrder', () => {
it('puts a shuffled queue back into its original order', () => {
const shuffledQueue = [ref('a'), ref('d'), ref('b'), ref('e'), ref('c')];
const restored = restoreOriginalOrder(shuffledQueue, ['a', 'b', 'c', 'd', 'e']);
expect(ids(restored)).toEqual(['a', 'b', 'c', 'd', 'e']);
});
it('appends rows that joined the queue while shuffle was on', () => {
// 'x' and 'y' were enqueued (or topped up by radio) after shuffle started,
// so the remembered order knows nothing about them.
const current = [ref('c'), ref('x'), ref('a'), ref('y'), ref('b')];
const restored = restoreOriginalOrder(current, ['a', 'b', 'c']);
expect(ids(restored)).toEqual(['a', 'b', 'c', 'x', 'y']);
});
it('drops ids from the remembered order that are no longer in the queue', () => {
// 'b' was removed by the user while shuffled.
const restored = restoreOriginalOrder([ref('c'), ref('a')], ['a', 'b', 'c']);
expect(ids(restored)).toEqual(['a', 'c']);
});
it('keeps every copy of a duplicated track', () => {
const current = [ref('b'), ref('a'), ref('a')];
const restored = restoreOriginalOrder(current, ['a', 'b', 'a']);
expect(ids(restored)).toEqual(['a', 'b', 'a']);
expect(restored).toHaveLength(3);
});
it('never loses or duplicates a row', () => {
const current = [ref('c'), ref('x'), ref('a'), ref('b')];
const restored = restoreOriginalOrder(current, ['a', 'b', 'c', 'ghost']);
expect(restored).toHaveLength(current.length);
expect(new Set(restored).size).toBe(current.length);
});
it('returns the queue unchanged when nothing was remembered', () => {
const current = [ref('a'), ref('b')];
expect(ids(restoreOriginalOrder(current, []))).toEqual(['a', 'b']);
});
});
describe('toggleShuffleMode', () => {
beforeEach(() => {
setShuffleOriginalOrder([]);
window.localStorage.clear();
});
/** Minimal player-state stub: only what the action reads and writes. */
function harness(queue: string[], queueIndex: number, shuffleMode = false) {
let state = {
queueItems: queue.map(ref),
queueIndex,
shuffleMode,
currentTrack: queue[queueIndex] ? { id: queue[queueIndex] } : null,
currentTime: 0,
};
const set = (partial: unknown) => {
const patch = typeof partial === 'function'
? (partial as (s: typeof state) => Partial<typeof state>)(state)
: partial as Partial<typeof state>;
state = { ...state, ...patch };
};
const get = () => state as never;
const { toggleShuffleMode } = createQueueMutationActions(set as never, get);
return { toggleShuffleMode, read: () => state };
}
it('keeps the playing track in place and shuffles only what is ahead', () => {
const random = vi.spyOn(Math, 'random').mockReturnValue(0); // deterministic
const h = harness(['a', 'b', 'c', 'd', 'e'], 1);
h.toggleShuffleMode();
const s = h.read();
expect(s.shuffleMode).toBe(true);
// Played rows and the current track are untouched...
expect(ids(s.queueItems).slice(0, 2)).toEqual(['a', 'b']);
// ...the index still points at the playing track...
expect(s.queueItems[s.queueIndex].trackId).toBe('b');
// ...and the rest is a permutation of what was ahead, nothing lost.
expect(ids(s.queueItems).slice(2).sort()).toEqual(['c', 'd', 'e']);
random.mockRestore();
});
it('restores the original order when switched off, index following the track', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.99);
const h = harness(['a', 'b', 'c', 'd', 'e'], 0);
h.toggleShuffleMode();
expect(h.read().shuffleMode).toBe(true);
h.toggleShuffleMode();
const s = h.read();
expect(s.shuffleMode).toBe(false);
expect(ids(s.queueItems)).toEqual(['a', 'b', 'c', 'd', 'e']);
expect(s.queueItems[s.queueIndex].trackId).toBe('a');
vi.restoreAllMocks();
});
it('flips the mode on an empty queue without touching it', () => {
const h = harness([], 0);
h.toggleShuffleMode();
expect(h.read().shuffleMode).toBe(true);
expect(h.read().queueItems).toEqual([]);
});
it('persists the flag and the original order so it survives a restart', () => {
const h = harness(['a', 'b', 'c'], 0);
h.toggleShuffleMode();
const stored = JSON.parse(window.localStorage.getItem('psysonic_shuffle_mode') ?? '{}');
expect(stored.enabled).toBe(true);
expect(stored.originalOrder).toEqual(['a', 'b', 'c']);
h.toggleShuffleMode();
expect(window.localStorage.getItem('psysonic_shuffle_mode')).toBeNull();
});
});
@@ -0,0 +1,71 @@
/**
* Persistent shuffle mode — pure helpers and the remembered pre-shuffle order.
*
* Turning shuffle on physically reorders the queue (the tracks after the current
* one) and remembers the order it came from; turning it off puts that order back.
* The toggle itself lives with the other queue mutations in
* `queueMutationActions` — it pushes an undo snapshot and syncs the queue to the
* server like every other mutation, and those modules reach back into the player
* store. This file stays free of that so it can be imported anywhere.
*
* The alternative design — leaving `queueItems` alone and only *playing* in a
* hidden random order — was rejected deliberately: "what plays next" is derived
* from the list order in four places (manual next, the gapless successor, the
* chain preload, the crossfade/AutoDJ plan), the engine is handed the next track
* ~30 s ahead with no way to take it back, and the server play-queue (and Orbit
* guests) only ever see the list order.
*/
import type { QueueItemRef } from '@/lib/media/trackTypes';
/** Module-level, like the other non-render queue state: the UI never reads it. */
let originalOrder: string[] = [];
export function getShuffleOriginalOrder(): string[] {
return originalOrder;
}
/**
* Sets the remembered order. Called when shuffle is switched on, cleared when it
* is switched off, and seeded from storage on boot — shuffle survives a restart,
* so the order it can be undone to has to survive with it.
*/
export function setShuffleOriginalOrder(order: string[]): void {
originalOrder = order;
}
export function shuffled<T>(items: T[]): T[] {
const out = [...items];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
/**
* Reorders `items` back into `order`, matching by track id.
*
* Ids can repeat (the same track may sit in the queue twice), so each id consumes
* one ref at a time rather than filtering. Refs the remembered order does not
* know about — enqueued, radio-topped-up or auto-added while shuffle was on —
* keep their relative order and go to the end: they were never part of the
* original list, so there is no position to restore them to.
*/
export function restoreOriginalOrder(items: QueueItemRef[], order: string[]): QueueItemRef[] {
const pools = new Map<string, QueueItemRef[]>();
for (const ref of items) {
const pool = pools.get(ref.trackId);
if (pool) pool.push(ref);
else pools.set(ref.trackId, [ref]);
}
const restored: QueueItemRef[] = [];
for (const trackId of order) {
const ref = pools.get(trackId)?.shift();
if (ref) restored.push(ref);
}
const restoredSet = new Set(restored);
return [...restored, ...items.filter(ref => !restoredSet.has(ref))];
}
@@ -0,0 +1,55 @@
/**
* Persisted shuffle-mode state.
*
* Kept out of the main `psysonic-player` blob (which already carries the whole
* queue and can hit the localStorage quota) — same reasoning as
* `playerPrefsStorage` / `queueVisibilityStorage`.
*
* Shuffle physically reorders `queueItems`, so "next track" stays "the next one
* in the list" for the gapless chain, the server play-queue and Orbit guests.
* The price is that turning shuffle off has to put the queue back — hence the
* original order, remembered as track ids and persisted alongside the flag so it
* survives a restart while shuffle is still on.
*/
const STORAGE_KEY = 'psysonic_shuffle_mode';
export interface ShuffleModeSnapshot {
enabled: boolean;
/** Track ids in their pre-shuffle order; empty when shuffle is off. */
originalOrder: string[];
}
const EMPTY: ShuffleModeSnapshot = { enabled: false, originalOrder: [] };
export function readShuffleModeSnapshot(): ShuffleModeSnapshot {
if (typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<ShuffleModeSnapshot>;
const originalOrder = Array.isArray(parsed.originalOrder)
? parsed.originalOrder.filter((id): id is string => typeof id === 'string')
: [];
const enabled = parsed.enabled === true;
// A flag without an order cannot be un-shuffled, and an order without the
// flag is dead weight — treat either half alone as "off".
if (!enabled || originalOrder.length === 0) return EMPTY;
return { enabled, originalOrder };
} catch {
return EMPTY;
}
}
export function persistShuffleModeSnapshot(snapshot: ShuffleModeSnapshot): void {
if (typeof window === 'undefined') return;
try {
if (!snapshot.enabled) {
window.localStorage.removeItem(STORAGE_KEY);
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
// best-effort — the in-memory order still works for this session
}
}
@@ -1,6 +1,6 @@
import React, { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Gauge, GripVertical, Heart, PictureInPicture2, SlidersVertical, Square, Star } from 'lucide-react';
import { Gauge, GripVertical, Heart, PictureInPicture2, Shuffle, SlidersVertical, Square, Star } from 'lucide-react';
import LastfmIcon from '@/ui/LastfmIcon';
import {
usePlayerBarLayoutStore,
@@ -16,6 +16,7 @@ import { SettingsSegmented } from '@/features/settings/components/SettingsSegmen
const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
stop: 'settings.playerBarStop',
shuffle: 'settings.playerBarShuffle',
starRating: 'settings.playerBarStarRating',
favorite: 'settings.playerBarFavorite',
lastfmLove: 'settings.playerBarLastfmLove',
@@ -26,6 +27,7 @@ const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> = {
stop: <Square size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
shuffle: <Shuffle size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
starRating: <Star size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
favorite: <Heart size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
lastfmLove: (
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Повторение',
repeatOff: 'Изключено',
repeatAll: 'Всички',
shuffleOn: 'Вкл.',
shuffleOff: 'Изкл.',
repeatOne: 'Една',
progress: 'Прогрес на песента',
volume: 'Сила на звука',
+1
View File
@@ -543,6 +543,7 @@ export const settings = {
playerBarEqualizer: 'Еквалайзер',
playerBarMiniPlayer: 'Мини плейър',
playerBarStop: 'Бутон за спиране',
playerBarShuffle: 'Разбъркано възпроизвеждане',
playerBarTransportGroup: 'Управление на възпроизвеждането',
playerBarActionsGroup: 'Действия',
playerBarTrackInfo: 'Информация за песента',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Wiederholen',
repeatOff: 'Aus',
repeatAll: 'Alle',
shuffleOn: 'An',
shuffleOff: 'Aus',
repeatOne: 'Einen',
progress: 'Songfortschritt',
volume: 'Lautstärke',
+1
View File
@@ -499,6 +499,7 @@ export const settings = {
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini-Player',
playerBarStop: 'Stopp-Schaltfläche',
playerBarShuffle: 'Zufallswiedergabe',
playerBarTransportGroup: 'Wiedergabesteuerung',
playerBarActionsGroup: 'Aktionen',
playerBarTrackInfo: 'Titelinfo',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Repeat',
repeatOff: 'Off',
repeatAll: 'All',
shuffleOn: 'On',
shuffleOff: 'Off',
repeatOne: 'One',
progress: 'Song Progress',
volume: 'Volume',
+1
View File
@@ -543,6 +543,7 @@ export const settings = {
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini player',
playerBarStop: 'Stop button',
playerBarShuffle: 'Shuffle',
playerBarTransportGroup: 'Transport',
playerBarActionsGroup: 'Actions',
playerBarTrackInfo: 'Track info',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Repetir',
repeatOff: 'Apagado',
repeatAll: 'Todo',
shuffleOn: 'Activada',
shuffleOff: 'Desactivada',
repeatOne: 'Una',
progress: 'Progreso de Canción',
volume: 'Volumen',
+1
View File
@@ -498,6 +498,7 @@ export const settings = {
playerBarEqualizer: 'Ecualizador',
playerBarMiniPlayer: 'Mini reproductor',
playerBarStop: 'Botón de parada',
playerBarShuffle: 'Reproducción aleatoria',
playerBarTransportGroup: 'Transporte',
playerBarActionsGroup: 'Acciones',
playerBarTrackInfo: 'Información de la pista',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Répéter',
repeatOff: 'Désactivé',
repeatAll: 'Tout',
shuffleOn: 'Activée',
shuffleOff: 'Désactivée',
repeatOne: 'Un',
progress: 'Progression',
volume: 'Volume',
+1
View File
@@ -486,6 +486,7 @@ export const settings = {
playerBarEqualizer: 'Égaliseur',
playerBarMiniPlayer: 'Mini-lecteur',
playerBarStop: 'Bouton darrêt',
playerBarShuffle: 'Lecture aléatoire',
playerBarTransportGroup: 'Transport',
playerBarActionsGroup: 'Actions',
playerBarTrackInfo: 'Infos du morceau',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Ismétlés',
repeatOff: 'Ki',
repeatAll: 'Mind',
shuffleOn: 'Be',
shuffleOff: 'Ki',
repeatOne: 'Egy',
progress: 'A dal előrehaladása',
volume: 'Hangerő',
+1
View File
@@ -543,6 +543,7 @@ export const settings = {
playerBarEqualizer: 'Hangszínszabályzó',
playerBarMiniPlayer: 'Mini lejátszó',
playerBarStop: 'Leállítás gomb',
playerBarShuffle: 'Véletlenszerű lejátszás',
playerBarTransportGroup: 'Lejátszásvezérlők',
playerBarActionsGroup: 'Műveletek',
playerBarTrackInfo: 'Száminformáció',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Ripeti',
repeatOff: 'Disattivata',
repeatAll: 'Tutti',
shuffleOn: 'Attiva',
shuffleOff: 'Disattivata',
repeatOne: 'Uno',
progress: 'Avanzamento brano',
volume: 'Volume',
+1
View File
@@ -544,6 +544,7 @@ export const settings = {
playerBarEqualizer: 'Equalizzatore',
playerBarMiniPlayer: 'Mini player',
playerBarStop: 'Pulsante di stop',
playerBarShuffle: 'Riproduzione casuale',
playerBarTransportGroup: 'Trasporto',
playerBarActionsGroup: 'Azioni',
playerBarTrackInfo: 'Info brano',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'リピート',
repeatOff: 'オフ',
repeatAll: 'すべて',
shuffleOn: 'オン',
shuffleOff: 'オフ',
repeatOne: '1 曲',
progress: '曲の進行',
volume: '音量',
+1
View File
@@ -537,6 +537,7 @@ export const settings = {
playerBarEqualizer: 'イコライザー',
playerBarMiniPlayer: 'ミニプレイヤー',
playerBarStop: '停止ボタン',
playerBarShuffle: 'シャッフル',
playerBarTransportGroup: '再生コントロール',
playerBarActionsGroup: 'アクション',
playerBarTrackInfo: '曲情報',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Gjenta',
repeatOff: 'Av',
repeatAll: 'Alle',
shuffleOn: 'På',
shuffleOff: 'Av',
repeatOne: 'Én',
progress: 'Sangfremdrift',
volume: 'Volum',
+1
View File
@@ -485,6 +485,7 @@ export const settings = {
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Miniavspiller',
playerBarStop: 'Stopp-knapp',
playerBarShuffle: 'Tilfeldig rekkefølge',
playerBarTransportGroup: 'Avspillingskontroller',
playerBarActionsGroup: 'Handlinger',
playerBarTrackInfo: 'Sporinfo',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Herhalen',
repeatOff: 'Uit',
repeatAll: 'Alles',
shuffleOn: 'Aan',
shuffleOff: 'Uit',
repeatOne: 'Één',
progress: 'Nummervoortgang',
volume: 'Volume',
+1
View File
@@ -486,6 +486,7 @@ export const settings = {
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Mini-speler',
playerBarStop: 'Stopknop',
playerBarShuffle: 'Willekeurig afspelen',
playerBarTransportGroup: 'Transport',
playerBarActionsGroup: 'Acties',
playerBarTrackInfo: 'Trackinfo',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Powtarzaj',
repeatOff: 'Wyłącz',
repeatAll: 'Wszystko',
shuffleOn: 'Wł.',
shuffleOff: 'Wył.',
repeatOne: 'Jeden',
progress: 'Postęp utworu',
volume: 'Głośność',
+1
View File
@@ -543,6 +543,7 @@ export const settings = {
playerBarEqualizer: 'Equalizer',
playerBarMiniPlayer: 'Miniodtwarzacz',
playerBarStop: 'Przycisk zatrzymania',
playerBarShuffle: 'Odtwarzanie losowe',
playerBarTransportGroup: 'Sterowanie odtwarzaniem',
playerBarActionsGroup: 'Akcje',
playerBarTrackInfo: 'Informacje o utworze',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Repetă',
repeatOff: 'Oprit',
repeatAll: 'Tot',
shuffleOn: 'Activată',
shuffleOff: 'Dezactivată',
repeatOne: 'Unul',
progress: 'Progresul Piesei',
volume: 'Volum',
+1
View File
@@ -501,6 +501,7 @@ export const settings = {
playerBarEqualizer: 'Egalizator',
playerBarMiniPlayer: 'Mini player',
playerBarStop: 'Buton de oprire',
playerBarShuffle: 'Redare aleatorie',
playerBarTransportGroup: 'Control redare',
playerBarActionsGroup: 'Acțiuni',
playerBarTrackInfo: 'Informații piesă',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: 'Повтор',
repeatOff: 'Выкл.',
repeatAll: 'Все',
shuffleOn: 'Вкл.',
shuffleOff: 'Выкл.',
repeatOne: 'Один',
progress: 'Прогресс',
volume: 'Громкость',
+1
View File
@@ -558,6 +558,7 @@ export const settings = {
playerBarEqualizer: 'Эквалайзер',
playerBarMiniPlayer: 'Мини-плеер',
playerBarStop: 'Кнопка «Стоп»',
playerBarShuffle: 'Случайный порядок',
playerBarTransportGroup: 'Управление воспроизведением',
playerBarActionsGroup: 'Действия',
playerBarTrackInfo: 'Информация о треке',
+2
View File
@@ -38,6 +38,8 @@ export const player = {
repeat: '重复',
repeatOff: '关闭',
repeatAll: '全部',
shuffleOn: '开',
shuffleOff: '关',
repeatOne: '单曲',
progress: '播放进度',
volume: '音量',
+1
View File
@@ -485,6 +485,7 @@ export const settings = {
playerBarEqualizer: '均衡器',
playerBarMiniPlayer: '迷你播放器',
playerBarStop: '停止按钮',
playerBarShuffle: '随机播放',
playerBarTransportGroup: '播放控制',
playerBarActionsGroup: '操作',
playerBarTrackInfo: '曲目信息',