feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
Docker Build & Publish / push (push) Has been cancelled
Docker Build & Publish / build (push) Has been cancelled
Docker Build & Publish / Prune old image versions (push) Has been cancelled

This commit is contained in:
Senko-san
2026-06-13 18:17:21 +03:00
parent a8e060d1a8
commit 44c8d1870f
8 changed files with 65 additions and 60 deletions
@@ -8,8 +8,6 @@ import {
resume, resume,
toggleMute, toggleMute,
setVolume, setVolume,
toggleShuffle,
setRepeat,
toggleQueue, toggleQueue,
} from '../../store/slices/player'; } from '../../store/slices/player';
import { openTrackInfo } from '../../store/slices/ui'; import { openTrackInfo } from '../../store/slices/ui';
@@ -73,14 +71,6 @@ export function PersistentPlayer() {
<div className="pl-center"> <div className="pl-center">
<div className="pl-transport"> <div className="pl-transport">
<button
type="button"
className={`pl-tbtn${player.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('player.shuffle')}
>
<Icon name="shuffle" />
</button>
<button <button
type="button" type="button"
className="pl-tbtn" className="pl-tbtn"
@@ -107,24 +97,6 @@ export function PersistentPlayer() {
> >
<Icon name="skip-forward" fill /> <Icon name="skip-forward" fill />
</button> </button>
<button
type="button"
className={`pl-tbtn${player.repeat !== 'none' ? ' on' : ''}`}
onClick={() =>
dispatch(
setRepeat(
player.repeat === 'none'
? 'all'
: player.repeat === 'all'
? 'one'
: 'none',
),
)
}
title={t('player.repeat', { mode: player.repeat })}
>
<Icon name="repeat" />
</button>
</div> </div>
<div className="pl-seek"> <div className="pl-seek">
<span className="pl-time"> <span className="pl-time">
+18
View File
@@ -33,6 +33,8 @@ import {
removeFromQueue, removeFromQueue,
moveInQueue, moveInQueue,
clearQueue, clearQueue,
toggleShuffle,
toggleLoop,
type QueueEntry, type QueueEntry,
} from '../../store/slices/queue'; } from '../../store/slices/queue';
import { toggleQueue } from '../../store/slices/player'; import { toggleQueue } from '../../store/slices/player';
@@ -71,6 +73,22 @@ export function QueuePanel() {
<div className="row"> <div className="row">
<h3>{t('queue.title')}</h3> <h3>{t('queue.title')}</h3>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<button
type="button"
className={`iconbtn sm${queue.shuffle ? ' on' : ''}`}
onClick={() => dispatch(toggleShuffle())}
title={t('queue.shuffle')}
>
<Icon name="shuffle" />
</button>
<button
type="button"
className={`iconbtn sm${queue.loop ? ' on' : ''}`}
onClick={() => dispatch(toggleLoop())}
title={t('queue.loop')}
>
<Icon name="repeat" />
</button>
<button <button
type="button" type="button"
className="iconbtn sm" className="iconbtn sm"
+10 -1
View File
@@ -27,6 +27,10 @@ export function useAudioPlayer() {
const queue = useAppSelector((s) => s.queue); const queue = useAppSelector((s) => s.queue);
const accessToken = useAppSelector((s) => s.auth.accessToken); const accessToken = useAppSelector((s) => s.auth.accessToken);
const isSetup = useRef(false); const isSetup = useRef(false);
// `ended` is registered once below; read the latest loop flag through a ref
// so the listener doesn't need to be re-bound on every queue change.
const loopRef = useRef(queue.loop);
loopRef.current = queue.loop;
useEffect(() => { useEffect(() => {
if (isSetup.current) return; if (isSetup.current) return;
@@ -41,7 +45,12 @@ export function useAudioPlayer() {
dispatch(setDuration(audio.duration || 0)); dispatch(setDuration(audio.duration || 0));
}); });
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
dispatch(nextTrack()); if (loopRef.current) {
audio.currentTime = 0;
void audio.play();
} else {
dispatch(nextTrack());
}
}); });
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
dispatch(pause()); dispatch(pause());
+2 -2
View File
@@ -120,12 +120,10 @@ const en = {
}, },
player: { player: {
nothingPlaying: 'Nothing playing', nothingPlaying: 'Nothing playing',
shuffle: 'Shuffle',
previous: 'Previous', previous: 'Previous',
next: 'Next', next: 'Next',
pause: 'Pause', pause: 'Pause',
play: 'Play', play: 'Play',
repeat: 'Repeat: {{mode}}',
streaming: 'Streaming', streaming: 'Streaming',
local: 'Local', local: 'Local',
queue: 'Play queue', queue: 'Play queue',
@@ -134,6 +132,8 @@ const en = {
}, },
queue: { queue: {
title: 'Play queue', title: 'Play queue',
shuffle: 'Shuffle queue',
loop: 'Repeat current track',
clear: 'Clear queue', clear: 'Clear queue',
close: 'Close', close: 'Close',
from: 'From {{source}}', from: 'From {{source}}',
+2 -2
View File
@@ -122,12 +122,10 @@ const ru: Translations = {
}, },
player: { player: {
nothingPlaying: 'Ничего не играет', nothingPlaying: 'Ничего не играет',
shuffle: 'Перемешать',
previous: 'Назад', previous: 'Назад',
next: 'Вперёд', next: 'Вперёд',
pause: 'Пауза', pause: 'Пауза',
play: 'Воспроизвести', play: 'Воспроизвести',
repeat: 'Повтор: {{mode}}',
streaming: 'Стриминг', streaming: 'Стриминг',
local: 'Локально', local: 'Локально',
queue: 'Очередь', queue: 'Очередь',
@@ -136,6 +134,8 @@ const ru: Translations = {
}, },
queue: { queue: {
title: 'Очередь воспроизведения', title: 'Очередь воспроизведения',
shuffle: 'Перемешать очередь',
loop: 'Повторять текущий трек',
clear: 'Очистить очередь', clear: 'Очистить очередь',
close: 'Закрыть', close: 'Закрыть',
from: 'Из: {{source}}', from: 'Из: {{source}}',
+12 -12
View File
@@ -8,14 +8,8 @@
* Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`). * Server data (library/albums/…) is Tier 2 (see `rtkqPersist.ts`).
*/ */
import { instanceStorage } from '../config/instances'; import { instanceStorage } from '../config/instances';
import { import { queueInitialState, type QueueState } from './slices/queue';
queueInitialState, import { playerInitialState, type PlayerState } from './slices/player';
type QueueState,
} from './slices/queue';
import {
playerInitialState,
type PlayerState,
} from './slices/player';
import type { RootState } from './index'; import type { RootState } from './index';
const QUEUE_KEY = 'queue'; const QUEUE_KEY = 'queue';
@@ -26,11 +20,17 @@ const PLAYER_KEY = 'player';
// transient UI, so they are intentionally left out. // transient UI, so they are intentionally left out.
type PersistedQueue = Pick< type PersistedQueue = Pick<
QueueState, QueueState,
'entries' | 'currentIndex' | 'source' | 'sourceId' | 'sourceName' | 'entries'
| 'currentIndex'
| 'source'
| 'sourceId'
| 'sourceName'
| 'shuffle'
| 'loop'
>; >;
type PersistedPlayer = Pick< type PersistedPlayer = Pick<
PlayerState, PlayerState,
'currentTrackId' | 'position' | 'volume' | 'muted' | 'repeat' | 'shuffle' 'currentTrackId' | 'position' | 'volume' | 'muted'
>; >;
function pickQueue(state: QueueState): PersistedQueue { function pickQueue(state: QueueState): PersistedQueue {
@@ -40,6 +40,8 @@ function pickQueue(state: QueueState): PersistedQueue {
source: state.source, source: state.source,
sourceId: state.sourceId, sourceId: state.sourceId,
sourceName: state.sourceName, sourceName: state.sourceName,
shuffle: state.shuffle,
loop: state.loop,
}; };
} }
@@ -49,8 +51,6 @@ function pickPlayer(state: PlayerState): PersistedPlayer {
position: state.position, position: state.position,
volume: state.volume, volume: state.volume,
muted: state.muted, muted: state.muted,
repeat: state.repeat,
shuffle: state.shuffle,
}; };
} }
-14
View File
@@ -1,7 +1,5 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
export type RepeatMode = 'none' | 'one' | 'all';
export interface PlayerState { export interface PlayerState {
currentTrackId: string | null; currentTrackId: string | null;
isPlaying: boolean; isPlaying: boolean;
@@ -9,8 +7,6 @@ export interface PlayerState {
duration: number; duration: number;
volume: number; volume: number;
muted: boolean; muted: boolean;
repeat: RepeatMode;
shuffle: boolean;
isQueueOpen: boolean; isQueueOpen: boolean;
} }
@@ -21,8 +17,6 @@ export const playerInitialState: PlayerState = {
duration: 0, duration: 0,
volume: 0.78, volume: 0.78,
muted: false, muted: false,
repeat: 'none',
shuffle: false,
isQueueOpen: false, isQueueOpen: false,
}; };
@@ -58,12 +52,6 @@ export const playerSlice = createSlice({
toggleMute(state) { toggleMute(state) {
state.muted = !state.muted; state.muted = !state.muted;
}, },
setRepeat(state, action: PayloadAction<RepeatMode>) {
state.repeat = action.payload;
},
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleQueue(state) { toggleQueue(state) {
state.isQueueOpen = !state.isQueueOpen; state.isQueueOpen = !state.isQueueOpen;
}, },
@@ -79,8 +67,6 @@ export const {
setDuration, setDuration,
setVolume, setVolume,
toggleMute, toggleMute,
setRepeat,
toggleShuffle,
toggleQueue, toggleQueue,
} = playerSlice.actions; } = playerSlice.actions;
export default playerSlice.reducer; export default playerSlice.reducer;
+21 -1
View File
@@ -23,6 +23,8 @@ export interface QueueState {
source: QueueSource; source: QueueSource;
sourceId: string | null; sourceId: string | null;
sourceName: string | null; sourceName: string | null;
shuffle: boolean;
loop: boolean;
} }
export const queueInitialState: QueueState = { export const queueInitialState: QueueState = {
@@ -31,6 +33,8 @@ export const queueInitialState: QueueState = {
source: 'manual', source: 'manual',
sourceId: null, sourceId: null,
sourceName: null, sourceName: null,
shuffle: false,
loop: false,
}; };
export const queueSlice = createSlice({ export const queueSlice = createSlice({
@@ -82,7 +86,15 @@ export const queueSlice = createSlice({
state.currentIndex = action.payload; state.currentIndex = action.payload;
}, },
nextTrack(state) { nextTrack(state) {
if (state.currentIndex < state.entries.length - 1) state.currentIndex++; if (state.shuffle && state.entries.length > 1) {
let next = state.currentIndex;
while (next === state.currentIndex) {
next = Math.floor(Math.random() * state.entries.length);
}
state.currentIndex = next;
} else if (state.currentIndex < state.entries.length - 1) {
state.currentIndex++;
}
}, },
prevTrack(state) { prevTrack(state) {
if (state.currentIndex > 0) state.currentIndex--; if (state.currentIndex > 0) state.currentIndex--;
@@ -91,6 +103,12 @@ export const queueSlice = createSlice({
state.entries = []; state.entries = [];
state.currentIndex = -1; state.currentIndex = -1;
}, },
toggleShuffle(state) {
state.shuffle = !state.shuffle;
},
toggleLoop(state) {
state.loop = !state.loop;
},
}, },
}); });
@@ -105,5 +123,7 @@ export const {
nextTrack, nextTrack,
prevTrack, prevTrack,
clearQueue, clearQueue,
toggleShuffle,
toggleLoop,
} = queueSlice.actions; } = queueSlice.actions;
export default queueSlice.reducer; export default queueSlice.reducer;