feat(queue): move shuffle/loop controls into queue drawer, scoped to queue
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
if (loopRef.current) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
void audio.play();
|
||||||
|
} else {
|
||||||
dispatch(nextTrack());
|
dispatch(nextTrack());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
audio.addEventListener('pause', () => {
|
audio.addEventListener('pause', () => {
|
||||||
dispatch(pause());
|
dispatch(pause());
|
||||||
|
|||||||
@@ -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}}',
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user