mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(player-bar): configurable stop button, album line and reorderable actions (#1287)
* feat(player-bar): configurable stop button, album line and reorderable actions
Extends the existing player-bar layout store instead of adding a second one.
- 'stop' becomes a layout item, so the stop button can be hidden. It lives in a
'transport' zone: visibility only, no reordering -- the play button is a
centred special case and shuffling controls around it would fight the adaptive
small-window layout. Hiding it leaves no dead end, since the primary button
already acts as stop while previewing.
- Track info gains an optional album line under the artist, off by default and
suppressed for radio and previews, which have no album.
- The right-hand action buttons are drag-reorderable via the shared
useListReorderDnd primitive. Moves resolve by stable id against the full list,
so the zone filter that decides which rows render cannot desync the reorder.
- The section is no longer gated behind Advanced.
Rehydrate keeps older stored layouts working: items added later ('stop') are
appended with their default instead of silently disappearing.
* i18n(settings): player bar constructor strings in all 14 locales
* docs(changelog): add entry and credit for PR #1287
This commit is contained in:
@@ -142,6 +142,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* While a Navidrome public share queue is active, **Save Playlist** is hidden in the queue toolbar (share tracks cannot be saved to the server). **Load Playlist** stays available.
|
||||
* The queue **Share** button copies the original Navidrome `/share/{id}` page URL instead of a Psysonic magic-string queue payload.
|
||||
|
||||
### Player bar — build your own
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1287](https://github.com/Psychotoxical/psysonic/pull/1287)**
|
||||
|
||||
* **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**.
|
||||
|
||||
## Fixed
|
||||
|
||||
### Per-track covers when playing from a playlist
|
||||
|
||||
@@ -202,6 +202,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)',
|
||||
'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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,6 +73,11 @@ export function PlayerTrackInfo({
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
const trackInfoMode = usePlayerBarLayoutStore(s => s.trackInfoMode);
|
||||
// Radio has no album, and a preview shows the previewed track's own meta.
|
||||
const albumLine = trackInfoMode === 'titleAlbum' && !isRadio && !showPreviewMeta
|
||||
? currentTrack?.album
|
||||
: undefined;
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
|
||||
|
||||
@@ -177,6 +182,14 @@ export function PlayerTrackInfo({
|
||||
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||
/>
|
||||
)}
|
||||
{albumLine && (
|
||||
<MarqueeText
|
||||
text={albumLine}
|
||||
className="player-track-album"
|
||||
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||
/>
|
||||
)}
|
||||
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
|
||||
<StarRating
|
||||
value={userRatingOverrides[currentTrack.id] ?? currentTrack.userRating ?? 0}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { usePreviewStore } from '@/features/playback/store/previewStore';
|
||||
import PlaybackScheduleBadge from '@/features/playback/components/PlaybackScheduleBadge';
|
||||
import { usePlaybackDelayPress } from '@/features/playback/hooks/usePlaybackDelayPress';
|
||||
import { usePlaybackScheduleRemaining } from '@/features/playback/utils/playbackScheduleFormat';
|
||||
import { usePlayerBarLayoutStore } from '@/features/playback/store/playerBarLayoutStore';
|
||||
|
||||
type RepeatMode = PlayerState['repeatMode'];
|
||||
type PlayPauseBind = ReturnType<typeof usePlaybackDelayPress>['playPauseBind'];
|
||||
@@ -36,24 +37,31 @@ export function PlayerTransportControls({
|
||||
const autodjPhase = useAutodjTransitionUi(s => s.phase);
|
||||
const showAutodjTransition =
|
||||
isPlaying && !isPreviewing && scheduleRemaining == null && autodjPhase === 'mixing';
|
||||
// Hiding Stop leaves no dead end: while previewing, the primary button already
|
||||
// renders as a stop control and ends the preview.
|
||||
const showStop = usePlayerBarLayoutStore(
|
||||
s => s.items.find(i => i.id === 'stop')?.visible !== false,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="player-buttons" ref={transportAnchorRef}>
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => {
|
||||
if (isPreviewing) {
|
||||
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||
audioPreviewStopSilent().catch(() => {});
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
{showStop && (
|
||||
<button
|
||||
className="player-btn player-btn-sm"
|
||||
onClick={() => {
|
||||
if (isPreviewing) {
|
||||
usePreviewStore.setState({ previewingId: null, previewingTrack: null, elapsed: 0 });
|
||||
audioPreviewStopSilent().catch(() => {});
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
}}
|
||||
aria-label={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
data-tooltip={isPreviewing ? t('playlists.previewStop') : t('player.stop')}
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="player-btn"
|
||||
onClick={() => previous()}
|
||||
|
||||
@@ -1,35 +1,107 @@
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import {
|
||||
DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
PLAYER_BAR_LAYOUT_ZONES,
|
||||
usePlayerBarLayoutStore,
|
||||
type PlayerBarLayoutItemConfig,
|
||||
} from '@/features/playback/store/playerBarLayoutStore';
|
||||
|
||||
type State = ReturnType<typeof usePlayerBarLayoutStore.getState>;
|
||||
|
||||
/**
|
||||
* Drives the persist middleware's rehydrate hook against a stored snapshot.
|
||||
* That hook is the risky path: a layout persisted by an older version knows
|
||||
* nothing about items added later, and a corrupted entry must not be able to
|
||||
* drop a button or leave the track-info picker on an unrenderable value.
|
||||
*/
|
||||
function rehydrate(stored: Partial<Record<'items' | 'trackInfoMode', unknown>>): State {
|
||||
const state = { ...usePlayerBarLayoutStore.getState(), ...stored } as State;
|
||||
usePlayerBarLayoutStore.persist.getOptions().onRehydrateStorage?.(state)?.(state, undefined);
|
||||
return state;
|
||||
}
|
||||
|
||||
const ids = (items: PlayerBarLayoutItemConfig[]) => items.map(i => i.id);
|
||||
|
||||
describe('playerBarLayoutStore', () => {
|
||||
beforeEach(() => {
|
||||
usePlayerBarLayoutStore.getState().reset();
|
||||
});
|
||||
|
||||
it('starts with all six items visible in declared order', () => {
|
||||
it('starts with every item visible in declared order', () => {
|
||||
const items = usePlayerBarLayoutStore.getState().items;
|
||||
expect(items.map(i => i.id)).toEqual([
|
||||
'starRating', 'favorite', 'lastfmLove', 'playbackRate', 'equalizer', 'miniPlayer',
|
||||
expect(ids(items)).toEqual([
|
||||
'stop', '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', () => {
|
||||
expect(PLAYER_BAR_LAYOUT_ZONES.stop).toBe('transport');
|
||||
expect(ids(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS)
|
||||
.filter(id => id !== 'stop')
|
||||
.every(id => PLAYER_BAR_LAYOUT_ZONES[id] === 'actions')).toBe(true);
|
||||
});
|
||||
|
||||
it('toggleItem flips the matching id without disturbing the others', () => {
|
||||
usePlayerBarLayoutStore.getState().toggleItem('equalizer');
|
||||
const items = usePlayerBarLayoutStore.getState().items;
|
||||
expect(items.find(i => i.id === 'equalizer')?.visible).toBe(false);
|
||||
expect(items.find(i => i.id === 'starRating')?.visible).toBe(true);
|
||||
expect(items.find(i => i.id === 'miniPlayer')?.visible).toBe(true);
|
||||
expect(items.filter(i => i.id !== 'equalizer').every(i => i.visible)).toBe(true);
|
||||
});
|
||||
|
||||
it('reset restores defaults after toggles', () => {
|
||||
const { toggleItem, reset } = usePlayerBarLayoutStore.getState();
|
||||
it('reset restores items and the track-info mode together', () => {
|
||||
const { toggleItem, setTrackInfoMode, reset } = usePlayerBarLayoutStore.getState();
|
||||
toggleItem('favorite');
|
||||
toggleItem('lastfmLove');
|
||||
toggleItem('stop');
|
||||
setTrackInfoMode('titleAlbum');
|
||||
reset();
|
||||
expect(usePlayerBarLayoutStore.getState().items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS);
|
||||
const state = usePlayerBarLayoutStore.getState();
|
||||
expect(state.items).toEqual(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS);
|
||||
expect(state.trackInfoMode).toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
});
|
||||
|
||||
describe('rehydrate', () => {
|
||||
it('adds items the stored layout predates, without losing the stored choices', () => {
|
||||
// A layout persisted before 'stop' existed: it must come back, visible.
|
||||
const state = rehydrate({
|
||||
items: [
|
||||
{ id: 'starRating', visible: true },
|
||||
{ id: 'equalizer', visible: false },
|
||||
],
|
||||
});
|
||||
expect(ids(state.items)).toContain('stop');
|
||||
expect(state.items.find(i => i.id === 'stop')?.visible).toBe(true);
|
||||
expect(state.items.find(i => i.id === 'equalizer')?.visible).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the stored order rather than forcing the default one', () => {
|
||||
const state = rehydrate({
|
||||
items: [
|
||||
{ id: 'miniPlayer', visible: true },
|
||||
{ id: 'equalizer', visible: true },
|
||||
],
|
||||
});
|
||||
expect(ids(state.items).slice(0, 2)).toEqual(['miniPlayer', 'equalizer']);
|
||||
});
|
||||
|
||||
it('drops unknown and malformed entries', () => {
|
||||
const state = rehydrate({
|
||||
items: [{ id: 'ghostButton', visible: true }, null, { visible: true }],
|
||||
});
|
||||
expect(ids(state.items).sort()).toEqual(ids(DEFAULT_PLAYER_BAR_LAYOUT_ITEMS).sort());
|
||||
});
|
||||
|
||||
it('falls back to the default track-info mode on an unknown or missing value', () => {
|
||||
expect(rehydrate({ trackInfoMode: 'everything' }).trackInfoMode)
|
||||
.toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
expect(rehydrate({ trackInfoMode: undefined }).trackInfoMode)
|
||||
.toBe(DEFAULT_PLAYER_BAR_TRACK_INFO_MODE);
|
||||
});
|
||||
|
||||
it('keeps a valid stored track-info mode', () => {
|
||||
expect(rehydrate({ trackInfoMode: 'titleAlbum' }).trackInfoMode).toBe('titleAlbum');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type PlayerBarLayoutItemId =
|
||||
| 'stop'
|
||||
| 'starRating'
|
||||
| 'favorite'
|
||||
// 'lastfmLove' is the enrichment-primary love button. The id is kept (not
|
||||
@@ -12,12 +13,40 @@ export type PlayerBarLayoutItemId =
|
||||
| 'equalizer'
|
||||
| 'miniPlayer';
|
||||
|
||||
/**
|
||||
* Which cluster of the bar an item lives in.
|
||||
*
|
||||
* `transport` items sit among the fixed playback controls, so only their
|
||||
* visibility is configurable — their position is dictated by the transport row
|
||||
* (the play button is a centred, special-cased element; letting users shuffle
|
||||
* controls around it buys nothing and breaks the adaptive small-window layout).
|
||||
* `actions` items are a plain right-hand row, so they are both toggleable and
|
||||
* reorderable.
|
||||
*/
|
||||
export type PlayerBarLayoutZone = 'transport' | 'actions';
|
||||
|
||||
export const PLAYER_BAR_LAYOUT_ZONES: Record<PlayerBarLayoutItemId, PlayerBarLayoutZone> = {
|
||||
stop: 'transport',
|
||||
starRating: 'actions',
|
||||
favorite: 'actions',
|
||||
lastfmLove: 'actions',
|
||||
playbackRate: 'actions',
|
||||
equalizer: 'actions',
|
||||
miniPlayer: 'actions',
|
||||
};
|
||||
|
||||
/** What the track-info block shows under the title. */
|
||||
export type PlayerBarTrackInfoMode = 'title' | 'titleAlbum';
|
||||
|
||||
export const PLAYER_BAR_TRACK_INFO_MODES: PlayerBarTrackInfoMode[] = ['title', 'titleAlbum'];
|
||||
|
||||
export interface PlayerBarLayoutItemConfig {
|
||||
id: PlayerBarLayoutItemId;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
|
||||
{ id: 'stop', visible: true },
|
||||
{ id: 'starRating', visible: true },
|
||||
{ id: 'favorite', visible: true },
|
||||
{ id: 'lastfmLove', visible: true },
|
||||
@@ -26,10 +55,14 @@ export const DEFAULT_PLAYER_BAR_LAYOUT_ITEMS: PlayerBarLayoutItemConfig[] = [
|
||||
{ id: 'miniPlayer', visible: true },
|
||||
];
|
||||
|
||||
export const DEFAULT_PLAYER_BAR_TRACK_INFO_MODE: PlayerBarTrackInfoMode = 'title';
|
||||
|
||||
interface PlayerBarLayoutStore {
|
||||
items: PlayerBarLayoutItemConfig[];
|
||||
trackInfoMode: PlayerBarTrackInfoMode;
|
||||
setItems: (items: PlayerBarLayoutItemConfig[]) => void;
|
||||
toggleItem: (id: PlayerBarLayoutItemId) => void;
|
||||
setTrackInfoMode: (mode: PlayerBarTrackInfoMode) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@@ -37,6 +70,7 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
trackInfoMode: DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
|
||||
@@ -44,7 +78,12 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
items: s.items.map(it => it.id === id ? { ...it, visible: !it.visible } : it),
|
||||
})),
|
||||
|
||||
reset: () => set({ items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS }),
|
||||
setTrackInfoMode: (trackInfoMode) => set({ trackInfoMode }),
|
||||
|
||||
reset: () => set({
|
||||
items: DEFAULT_PLAYER_BAR_LAYOUT_ITEMS,
|
||||
trackInfoMode: DEFAULT_PLAYER_BAR_TRACK_INFO_MODE,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_player_bar_layout',
|
||||
@@ -55,8 +94,14 @@ export const usePlayerBarLayoutStore = create<PlayerBarLayoutStore>()(
|
||||
.filter((i): i is PlayerBarLayoutItemConfig =>
|
||||
i != null && typeof i.id === 'string' && knownIds.has(i.id as PlayerBarLayoutItemId));
|
||||
const seen = new Set(safe.map(i => i.id));
|
||||
// Items added in a later version (e.g. 'stop') are absent from an older
|
||||
// stored layout — append them with their default so the button does not
|
||||
// silently vanish for existing users.
|
||||
const missing = DEFAULT_PLAYER_BAR_LAYOUT_ITEMS.filter(i => !seen.has(i.id));
|
||||
state.items = missing.length > 0 ? [...safe, ...missing] : safe;
|
||||
if (!PLAYER_BAR_TRACK_INFO_MODES.includes(state.trackInfoMode)) {
|
||||
state.trackInfoMode = DEFAULT_PLAYER_BAR_TRACK_INFO_MODE;
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -181,7 +181,6 @@ export function PersonalisationTab() {
|
||||
<SettingsSubSection
|
||||
title={t('settings.playerBarTitle')}
|
||||
icon={<Disc3 size={16} />}
|
||||
advanced
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import React from 'react';
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gauge, Heart, PictureInPicture2, SlidersVertical, Star } from 'lucide-react';
|
||||
import { Gauge, GripVertical, Heart, PictureInPicture2, SlidersVertical, Square, Star } from 'lucide-react';
|
||||
import LastfmIcon from '@/ui/LastfmIcon';
|
||||
import {
|
||||
usePlayerBarLayoutStore,
|
||||
PLAYER_BAR_LAYOUT_ZONES,
|
||||
type PlayerBarLayoutItemConfig,
|
||||
type PlayerBarLayoutItemId,
|
||||
type PlayerBarTrackInfoMode,
|
||||
} from '@/features/playback/store/playerBarLayoutStore';
|
||||
import { useListReorderDnd } from '@/lib/hooks/useListReorderDnd';
|
||||
import { applyListReorderById, type ListReorderDropTarget } from '@/lib/util/listReorder';
|
||||
import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle';
|
||||
import { SettingsSegmented } from '@/features/settings/components/SettingsSegmented';
|
||||
|
||||
const PLAYER_BAR_LAYOUT_LABEL_KEYS: Record<PlayerBarLayoutItemId, string> = {
|
||||
stop: 'settings.playerBarStop',
|
||||
starRating: 'settings.playerBarStarRating',
|
||||
favorite: 'settings.playerBarFavorite',
|
||||
lastfmLove: 'settings.playerBarLastfmLove',
|
||||
@@ -17,6 +25,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 }} />,
|
||||
starRating: <Star size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
favorite: <Heart size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
lastfmLove: (
|
||||
@@ -29,26 +38,90 @@ const PLAYER_BAR_LAYOUT_ICONS: Record<PlayerBarLayoutItemId, React.ReactNode> =
|
||||
miniPlayer: <PictureInPicture2 size={16} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />,
|
||||
};
|
||||
|
||||
const REORDER_TYPE = 'player_bar_layout_reorder';
|
||||
|
||||
export function PlayerBarLayoutCustomizer() {
|
||||
const { t } = useTranslation();
|
||||
const items = usePlayerBarLayoutStore(s => s.items);
|
||||
const setItems = usePlayerBarLayoutStore(s => s.setItems);
|
||||
const toggleItem = usePlayerBarLayoutStore(s => s.toggleItem);
|
||||
const trackInfoMode = usePlayerBarLayoutStore(s => s.trackInfoMode);
|
||||
const setTrackInfoMode = usePlayerBarLayoutStore(s => s.setTrackInfoMode);
|
||||
|
||||
const itemsRef = useRef(items);
|
||||
// React Compiler refs rule: ref kept in sync with the latest value for use in handlers; not render data.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
itemsRef.current = items;
|
||||
|
||||
const transportItems = items.filter(i => PLAYER_BAR_LAYOUT_ZONES[i.id] === 'transport');
|
||||
const actionItems = items.filter(i => PLAYER_BAR_LAYOUT_ZONES[i.id] === 'actions');
|
||||
|
||||
// Reorder resolves by stable id against the FULL list, so the zone filter that
|
||||
// decides which rows are shown can never share an index space with the move
|
||||
// (the #1164 class of bug).
|
||||
const apply = useCallback((draggedId: string, target: ListReorderDropTarget) => {
|
||||
const next = applyListReorderById(itemsRef.current, draggedId, target);
|
||||
if (next) setItems(next);
|
||||
}, [setItems]);
|
||||
|
||||
const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: REORDER_TYPE, apply });
|
||||
|
||||
const row = (it: PlayerBarLayoutItemConfig, draggable: boolean) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
const edge = draggable && isDragging ? dropEdge(it.id) : null;
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
data-reorder-id={draggable ? it.id : undefined}
|
||||
className="sidebar-customizer-row"
|
||||
style={{
|
||||
borderTop: edge === 'before' ? '2px solid var(--accent)' : undefined,
|
||||
borderBottom: edge === 'after' ? '2px solid var(--accent)' : undefined,
|
||||
}}
|
||||
>
|
||||
{draggable ? (
|
||||
<ReorderGripHandle id={it.id} type={REORDER_TYPE} label={label} />
|
||||
) : (
|
||||
// Transport items keep their fixed position, so they have no grip. The
|
||||
// spacer carries the same icon so it occupies the same width — an empty
|
||||
// span collapses and pulls the row out of line with the ones below.
|
||||
<span className="sidebar-customizer-grip" style={{ visibility: 'hidden' }} aria-hidden>
|
||||
<GripVertical size={16} />
|
||||
</span>
|
||||
)}
|
||||
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{items.map((it) => {
|
||||
const label = t(PLAYER_BAR_LAYOUT_LABEL_KEYS[it.id]);
|
||||
return (
|
||||
<div key={it.id} className="sidebar-customizer-row">
|
||||
{PLAYER_BAR_LAYOUT_ICONS[it.id]}
|
||||
<span style={{ flex: 1, fontSize: 14, opacity: it.visible ? 1 : 0.45 }}>{label}</span>
|
||||
<label className="toggle-switch" aria-label={label}>
|
||||
<input type="checkbox" checked={it.visible} onChange={() => toggleItem(it.id)} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="settings-group-title">{t('settings.playerBarTransportGroup')}</div>
|
||||
{transportItems.map(it => row(it, false))}
|
||||
|
||||
<div className="settings-group-title" style={{ marginTop: '0.75rem' }}>
|
||||
{t('settings.playerBarActionsGroup')}
|
||||
</div>
|
||||
<div ref={setContainer} onMouseMove={onMouseMove}>
|
||||
{actionItems.map(it => row(it, true))}
|
||||
</div>
|
||||
|
||||
<div className="settings-group-title" style={{ marginTop: '0.75rem' }}>
|
||||
{t('settings.playerBarTrackInfo')}
|
||||
</div>
|
||||
<SettingsSegmented<PlayerBarTrackInfoMode>
|
||||
options={[
|
||||
{ id: 'title', label: t('settings.playerBarTrackInfoTitle') },
|
||||
{ id: 'titleAlbum', label: t('settings.playerBarTrackInfoTitleAlbum') },
|
||||
]}
|
||||
value={trackInfoMode}
|
||||
onChange={setTrackInfoMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +542,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Скорост на възпроизвеждане',
|
||||
playerBarEqualizer: 'Еквалайзер',
|
||||
playerBarMiniPlayer: 'Мини плейър',
|
||||
playerBarStop: 'Бутон за спиране',
|
||||
playerBarTransportGroup: 'Управление на възпроизвеждането',
|
||||
playerBarActionsGroup: 'Действия',
|
||||
playerBarTrackInfo: 'Информация за песента',
|
||||
playerBarTrackInfoTitle: 'Само заглавие',
|
||||
playerBarTrackInfoTitleAlbum: 'Заглавие + албум',
|
||||
playbackRateTitle: 'Скорост на възпроизвеждане',
|
||||
playbackRateEnabled: 'Включи скоростта на възпроизвеждане',
|
||||
playbackRateEnabledDesc: 'Променя скоростта глобално за всички песни. Не се прилага за радио или предварителни прослушвания.',
|
||||
|
||||
@@ -498,6 +498,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Wiedergabegeschwindigkeit',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini-Player',
|
||||
playerBarStop: 'Stopp-Schaltfläche',
|
||||
playerBarTransportGroup: 'Wiedergabesteuerung',
|
||||
playerBarActionsGroup: 'Aktionen',
|
||||
playerBarTrackInfo: 'Titelinfo',
|
||||
playerBarTrackInfoTitle: 'Nur Titel',
|
||||
playerBarTrackInfoTitleAlbum: 'Titel + Album',
|
||||
playbackRateTitle: 'Wiedergabegeschwindigkeit',
|
||||
playbackRateEnabled: 'Wiedergabegeschwindigkeit aktivieren',
|
||||
playbackRateEnabledDesc: 'Globale Geschwindigkeit für alle Titel. Gilt nicht für Radio und Vorschau.',
|
||||
|
||||
@@ -542,6 +542,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Playback speed',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playerBarStop: 'Stop button',
|
||||
playerBarTransportGroup: 'Transport',
|
||||
playerBarActionsGroup: 'Actions',
|
||||
playerBarTrackInfo: 'Track info',
|
||||
playerBarTrackInfoTitle: 'Title only',
|
||||
playerBarTrackInfoTitleAlbum: 'Title + album',
|
||||
playbackRateTitle: 'Playback speed',
|
||||
playbackRateEnabled: 'Enable playback speed',
|
||||
playbackRateEnabledDesc: 'Change speed globally for all tracks. Not applied to radio or previews.',
|
||||
|
||||
@@ -497,6 +497,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Velocidad de reproducción',
|
||||
playerBarEqualizer: 'Ecualizador',
|
||||
playerBarMiniPlayer: 'Mini reproductor',
|
||||
playerBarStop: 'Botón de parada',
|
||||
playerBarTransportGroup: 'Transporte',
|
||||
playerBarActionsGroup: 'Acciones',
|
||||
playerBarTrackInfo: 'Información de la pista',
|
||||
playerBarTrackInfoTitle: 'Solo el título',
|
||||
playerBarTrackInfoTitleAlbum: 'Título + álbum',
|
||||
playbackRateTitle: 'Velocidad de reproducción',
|
||||
playbackRateEnabled: 'Activar velocidad de reproducción',
|
||||
playbackRateEnabledDesc: 'Velocidad global para todas las pistas. No se aplica a la radio ni a las vistas previas.',
|
||||
|
||||
@@ -485,6 +485,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Vitesse de lecture',
|
||||
playerBarEqualizer: 'Égaliseur',
|
||||
playerBarMiniPlayer: 'Mini-lecteur',
|
||||
playerBarStop: 'Bouton d’arrêt',
|
||||
playerBarTransportGroup: 'Transport',
|
||||
playerBarActionsGroup: 'Actions',
|
||||
playerBarTrackInfo: 'Infos du morceau',
|
||||
playerBarTrackInfoTitle: 'Titre uniquement',
|
||||
playerBarTrackInfoTitleAlbum: 'Titre + album',
|
||||
playbackRateTitle: 'Vitesse de lecture',
|
||||
playbackRateEnabled: 'Activer la vitesse de lecture',
|
||||
playbackRateEnabledDesc: 'Vitesse globale pour toutes les pistes. Non appliquée à la radio ni aux aperçus.',
|
||||
|
||||
@@ -542,6 +542,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Lejátszási sebesség',
|
||||
playerBarEqualizer: 'Hangszínszabályzó',
|
||||
playerBarMiniPlayer: 'Mini lejátszó',
|
||||
playerBarStop: 'Leállítás gomb',
|
||||
playerBarTransportGroup: 'Lejátszásvezérlők',
|
||||
playerBarActionsGroup: 'Műveletek',
|
||||
playerBarTrackInfo: 'Száminformáció',
|
||||
playerBarTrackInfoTitle: 'Csak a cím',
|
||||
playerBarTrackInfoTitleAlbum: 'Cím + album',
|
||||
playbackRateTitle: 'Lejátszási sebesség',
|
||||
playbackRateEnabled: 'Lejátszási sebesség engedélyezése',
|
||||
playbackRateEnabledDesc: 'A sebesség globális módosítása minden számhoz. Nem érvényes a rádióra vagy az előnézetekre.',
|
||||
|
||||
@@ -543,6 +543,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Velocità di riproduzione',
|
||||
playerBarEqualizer: 'Equalizzatore',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playerBarStop: 'Pulsante di stop',
|
||||
playerBarTransportGroup: 'Trasporto',
|
||||
playerBarActionsGroup: 'Azioni',
|
||||
playerBarTrackInfo: 'Info brano',
|
||||
playerBarTrackInfoTitle: 'Solo titolo',
|
||||
playerBarTrackInfoTitleAlbum: 'Titolo + album',
|
||||
playbackRateTitle: 'Velocità di riproduzione',
|
||||
playbackRateEnabled: 'Attiva velocità di riproduzione',
|
||||
playbackRateEnabledDesc: 'Cambia la velocità globalmente per tutti i brani. Non si applica alla radio o alle anteprime.',
|
||||
|
||||
@@ -536,6 +536,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: '再生速度',
|
||||
playerBarEqualizer: 'イコライザー',
|
||||
playerBarMiniPlayer: 'ミニプレイヤー',
|
||||
playerBarStop: '停止ボタン',
|
||||
playerBarTransportGroup: '再生コントロール',
|
||||
playerBarActionsGroup: 'アクション',
|
||||
playerBarTrackInfo: '曲情報',
|
||||
playerBarTrackInfoTitle: 'タイトルのみ',
|
||||
playerBarTrackInfoTitleAlbum: 'タイトル + アルバム',
|
||||
playbackRateTitle: '再生速度',
|
||||
playbackRateEnabled: '再生速度を有効化',
|
||||
playbackRateEnabledDesc: 'すべてのトラックに対してグローバルに速度を変更します。ラジオやプレビューには適用されません。',
|
||||
|
||||
@@ -484,6 +484,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Avspillingshastighet',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Miniavspiller',
|
||||
playerBarStop: 'Stopp-knapp',
|
||||
playerBarTransportGroup: 'Avspillingskontroller',
|
||||
playerBarActionsGroup: 'Handlinger',
|
||||
playerBarTrackInfo: 'Sporinfo',
|
||||
playerBarTrackInfoTitle: 'Bare tittel',
|
||||
playerBarTrackInfoTitleAlbum: 'Tittel + album',
|
||||
playbackRateTitle: 'Avspillingshastighet',
|
||||
playbackRateEnabled: 'Aktiver avspillingshastighet',
|
||||
playbackRateEnabledDesc: 'Global hastighet for alle spor. Gjelder ikke radio og forhåndsvisninger.',
|
||||
|
||||
@@ -485,6 +485,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Afspeelsnelheid',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Mini-speler',
|
||||
playerBarStop: 'Stopknop',
|
||||
playerBarTransportGroup: 'Transport',
|
||||
playerBarActionsGroup: 'Acties',
|
||||
playerBarTrackInfo: 'Trackinfo',
|
||||
playerBarTrackInfoTitle: 'Alleen titel',
|
||||
playerBarTrackInfoTitleAlbum: 'Titel + album',
|
||||
playbackRateTitle: 'Afspeelsnelheid',
|
||||
playbackRateEnabled: 'Afspeelsnelheid inschakelen',
|
||||
playbackRateEnabledDesc: 'Globale snelheid voor alle nummers. Niet van toepassing op radio en previews.',
|
||||
|
||||
@@ -542,6 +542,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Prędkość odtwarzania',
|
||||
playerBarEqualizer: 'Equalizer',
|
||||
playerBarMiniPlayer: 'Miniodtwarzacz',
|
||||
playerBarStop: 'Przycisk zatrzymania',
|
||||
playerBarTransportGroup: 'Sterowanie odtwarzaniem',
|
||||
playerBarActionsGroup: 'Akcje',
|
||||
playerBarTrackInfo: 'Informacje o utworze',
|
||||
playerBarTrackInfoTitle: 'Tylko tytuł',
|
||||
playerBarTrackInfoTitleAlbum: 'Tytuł + album',
|
||||
playbackRateTitle: 'Prędkość odtwarzania',
|
||||
playbackRateEnabled: 'Włącz zmianę prędkości odtwarzania',
|
||||
playbackRateEnabledDesc: 'Zmienia prędkość globalnie, dla wszystkich utworów. Nie dotyczy radia ani podglądu utworów.',
|
||||
|
||||
@@ -500,6 +500,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Viteză redare',
|
||||
playerBarEqualizer: 'Egalizator',
|
||||
playerBarMiniPlayer: 'Mini player',
|
||||
playerBarStop: 'Buton de oprire',
|
||||
playerBarTransportGroup: 'Control redare',
|
||||
playerBarActionsGroup: 'Acțiuni',
|
||||
playerBarTrackInfo: 'Informații piesă',
|
||||
playerBarTrackInfoTitle: 'Doar titlul',
|
||||
playerBarTrackInfoTitleAlbum: 'Titlu + album',
|
||||
playbackRateTitle: 'Viteză redare',
|
||||
playbackRateEnabled: 'Activează viteza de redare',
|
||||
playbackRateEnabledDesc: 'Viteză globală pentru toate piesele. Nu se aplică la radio și previzualizări.',
|
||||
|
||||
@@ -557,6 +557,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: 'Скорость',
|
||||
playerBarEqualizer: 'Эквалайзер',
|
||||
playerBarMiniPlayer: 'Мини-плеер',
|
||||
playerBarStop: 'Кнопка «Стоп»',
|
||||
playerBarTransportGroup: 'Управление воспроизведением',
|
||||
playerBarActionsGroup: 'Действия',
|
||||
playerBarTrackInfo: 'Информация о треке',
|
||||
playerBarTrackInfoTitle: 'Только название',
|
||||
playerBarTrackInfoTitleAlbum: 'Название + альбом',
|
||||
playbackRateTitle: 'Скорость воспроизведения',
|
||||
playbackRateEnabled: 'Включить изменение скорости',
|
||||
playbackRateEnabledDesc: 'Глобальная скорость для всех треков. Не применяется к радио и превью.',
|
||||
|
||||
@@ -484,6 +484,12 @@ export const settings = {
|
||||
playerBarPlaybackRate: '播放速度',
|
||||
playerBarEqualizer: '均衡器',
|
||||
playerBarMiniPlayer: '迷你播放器',
|
||||
playerBarStop: '停止按钮',
|
||||
playerBarTransportGroup: '播放控制',
|
||||
playerBarActionsGroup: '操作',
|
||||
playerBarTrackInfo: '曲目信息',
|
||||
playerBarTrackInfoTitle: '仅标题',
|
||||
playerBarTrackInfoTitleAlbum: '标题 + 专辑',
|
||||
playbackRateTitle: '播放速度',
|
||||
playbackRateEnabled: '启用播放速度',
|
||||
playbackRateEnabledDesc: '全局调整所有曲目的播放速度。不适用于电台和预览。',
|
||||
|
||||
@@ -186,6 +186,15 @@ html[data-platform="windows"] .player-bar.floating {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Optional third line (Settings → Personalisation → Player bar). Sits below the
|
||||
artist and is quieter than it, so the title stays the anchor of the block. */
|
||||
.player-track-album {
|
||||
font-size: 11px;
|
||||
color: var(--player-artist);
|
||||
opacity: 0.75;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.player-track-rating {
|
||||
margin-top: 4px;
|
||||
width: auto;
|
||||
|
||||
Reference in New Issue
Block a user