feat(queue): adapt toolbar for Navidrome public share queues (#1279)

This commit is contained in:
cucadmuh
2026-07-12 23:22:36 +03:00
committed by GitHub
parent 6e448dcc3c
commit ad5d88d105
26 changed files with 237 additions and 12 deletions
+7
View File
@@ -111,6 +111,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Changed
### Queue toolbar — Navidrome public share sessions
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1279](https://github.com/Psychotoxical/psysonic/pull/1279)**
* 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.
### Equalizer — per-device profiles follow the active system default
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1274](https://github.com/Psychotoxical/psysonic/pull/1274)**, suggested by [@JustBuddy](https://github.com/JustBuddy)
+1
View File
@@ -197,6 +197,7 @@ const CONTRIBUTOR_ENTRIES = [
'Servers — connect and run fully behind a custom-header gate (Cloudflare Access / Pangolin): native connect probe + REST proxy so browse/playback/covers pass, add-form failure reasons, live LAN/public badge on server switch, and LAN reclaim from a sticky public endpoint (PR #1273)',
'Per-device EQ — when System Default is selected, profiles follow the active OS default output and switch on external output changes (PR #1274)',
'Navidrome public share links — paste/search preview modal, anonymous queue playback, idle server queue-restore guard (PR #1275)',
'Queue toolbar — Navidrome public share: hide Save Playlist, copy original share URL (PR #1279)',
'Windows startup hang after #1274 — boot barrel split, stable Wasapi device IDs, legacy EQ key match (PR #1277)',
'Windows MSI bundle on dev/RC versions — numeric WiX mapping; album easter-egg import chunk (PR #1278)',
],
@@ -64,6 +64,7 @@ import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
import { getCachedTrack, seedQueueResolver, mergeDirectShareUrls } from '@/features/playback/store/queueTrackResolver';
import { tracksArePublicShareQueue } from '@/lib/share/navidromePublicSharePlayback';
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
@@ -260,6 +261,7 @@ export function runPlayTrack(
// every subscriber on each track change at scale.
const replacing = scopedQueue !== undefined;
const srcLen = replacing ? scopedQueue.length : state.queueItems.length;
const dropSharePageUrl = replacing && scopedQueue && !tracksArePublicShareQueue(scopedQueue);
if (replacing && shouldBindQueueServerForPlay(state.queueItems, scopedQueue, scopedQueue)) {
bindQueueServerForTracks(scopedQueue);
}
@@ -428,6 +430,7 @@ export function runPlayTrack(
set({
currentRadio: null,
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
...(dropSharePageUrl ? { navidromePublicSharePageUrl: null } : {}),
queueIndex: idx >= 0 ? idx : 0,
});
} else {
@@ -439,6 +442,7 @@ export function runPlayTrack(
...deriveNormalizationSnapshot(trackForPlay, playNormWindow, normIdx),
// Only a replace rewrites the queue; navigation keeps the canonical refs.
...(replacing ? { queueItems: toQueueItemRefs(queueSid, scopedQueue) } : {}),
...(dropSharePageUrl ? { navidromePublicSharePageUrl: null } : {}),
queueIndex: idx >= 0 ? idx : 0,
progress: initialProgress,
buffered: 0,
@@ -54,6 +54,7 @@ export const usePlayerStore = create<PlayerState>()(
// through the resolver. `currentTrack` stays a full resolved singleton.
queueItems: [],
queueServerId: null,
navidromePublicSharePageUrl: null,
queueIndex: 0,
isPlaying: false,
isPlaybackBuffering: false,
@@ -24,6 +24,8 @@ export interface PlayerState {
enginePreloadedTrackId: string | null;
/** Saved server for stream/hot-cache/offline resolution while this queue plays. */
queueServerId: string | null;
/** Navidrome public share page URL for the live queue session (not persisted). */
navidromePublicSharePageUrl: string | null;
queueIndex: number;
/** F5 (transient): full ordered track-id list + index persisted alongside the
* windowed `queue`. On startup, when the library index is ready, the whole
@@ -231,7 +231,16 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
setCurrentRadioArtistId(null);
clearTimelineSessionHistory();
clearQueueServerForPlayback();
set({ queueItems: [], queueIndex: 0, currentTrack: null, isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
set({
queueItems: [],
queueIndex: 0,
currentTrack: null,
navidromePublicSharePageUrl: null,
isPlaying: false,
progress: 0,
buffered: 0,
currentTime: 0,
});
syncUserQueueMutationToServer([], null, 0);
},
@@ -35,6 +35,7 @@ import { useQueueAutoScroll } from '@/features/queue/hooks/useQueueAutoScroll';
import { useTimelineBootstrapOnMode, useTimelineHistoryResolver, useTimelinePlayHistory } from '@/features/playback/hooks/useTimelinePlayHistory';
import { buildTimelineDisplayRows } from '@/features/playback/utils/buildTimelineDisplayRows';
import { activeServerQueueTrackIds } from '@/features/playback/utils/playback/trackServerScope';
import { isActivePublicShareQueue } from '@/lib/share/navidromePublicSharePlayback';
export default function QueuePanel() {
const orbitRole = useOrbitStore(s => s.role);
@@ -79,6 +80,9 @@ function QueuePanelHostOrSolo() {
// Track from the resolver. List, header, toolbar and id/length reads (save /
// share / playlist) all read off the refs.
const queueItems = usePlayerStore(s => s.queueItems);
const queueServerId = usePlayerStore(s => s.queueServerId);
const navidromePublicSharePageUrl = usePlayerStore(s => s.navidromePublicSharePageUrl);
const publicShareQueueActive = isActivePublicShareQueue(queueServerId, queueItems);
const queueIndex = usePlayerStore(s => s.queueIndex);
const currentTrack = usePlayerStore(s => s.currentTrack);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
@@ -182,6 +186,7 @@ function QueuePanelHostOrSolo() {
const [loadModalOpen, setLoadModalOpen] = useState(false);
const handleSave = async () => {
if (publicShareQueueActive) return;
const exportTrackIds = activeServerQueueTrackIds(queueItems);
if (exportTrackIds.length === 0) return;
if (activePlaylist) {
@@ -209,6 +214,17 @@ function QueuePanelHostOrSolo() {
};
const handleCopyQueueShare = async () => {
if (publicShareQueueActive) {
const pageUrl = navidromePublicSharePageUrl?.trim();
if (!pageUrl) {
showToast(t('queue.shareNavidromePublicMissing'), 4000, 'error');
return;
}
const ok = await copyTextToClipboard(pageUrl);
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
return;
}
const ids = activeServerQueueTrackIds(queueItems);
if (ids.length === 0) {
showToast(t('queue.shareQueueEmpty'), 3000, 'info');
@@ -344,6 +360,7 @@ function QueuePanelHostOrSolo() {
handleLoad={handleLoad}
handleCopyQueueShare={handleCopyQueueShare}
handleClear={handleClear}
publicShareQueueActive={publicShareQueueActive}
gaplessEnabled={gaplessEnabled}
crossfadeEnabled={crossfadeEnabled}
crossfadeTrimSilence={crossfadeTrimSilence}
+15 -11
View File
@@ -21,6 +21,7 @@ interface Props {
handleLoad: () => void;
handleCopyQueueShare: () => void;
handleClear: () => void;
publicShareQueueActive: boolean;
gaplessEnabled: boolean;
crossfadeEnabled: boolean;
crossfadeTrimSilence: boolean;
@@ -34,6 +35,7 @@ interface Props {
export function QueueToolbar({
queue, activePlaylist, saveState, toolbarButtons, shuffleQueue,
handleSave, handleLoad, handleCopyQueueShare, handleClear,
publicShareQueueActive,
gaplessEnabled, crossfadeEnabled, crossfadeTrimSilence,
crossfadeSecs, setCrossfadeSecs,
infiniteQueueEnabled, setInfiniteQueueEnabled,
@@ -99,15 +101,17 @@ export function QueueToolbar({
</button>
{showPlaylistMenu && (
<div className="crossfade-popover queue-menu" ref={playlistMenuRef}>
<button
type="button"
className="queue-menu-item"
onClick={() => { handleSave(); setShowPlaylistMenu(false); }}
disabled={saveState === 'saving'}
>
{saveState === 'saved' ? <Check size={14} /> : <Save size={14} />}
{activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
</button>
{!publicShareQueueActive && (
<button
type="button"
className="queue-menu-item"
onClick={() => { handleSave(); setShowPlaylistMenu(false); }}
disabled={saveState === 'saving'}
>
{saveState === 'saved' ? <Check size={14} /> : <Save size={14} />}
{activePlaylist ? `${t('queue.updatePlaylist')}: ${activePlaylist.name}` : t('queue.savePlaylist')}
</button>
)}
<button
type="button"
className="queue-menu-item"
@@ -126,8 +130,8 @@ export function QueueToolbar({
key={btn.id}
className="queue-round-btn"
onClick={() => void handleCopyQueueShare()}
data-tooltip={t('queue.shareQueue')}
aria-label={t('queue.shareQueue')}
data-tooltip={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
aria-label={publicShareQueueActive ? t('queue.shareNavidromePublic') : t('queue.shareQueue')}
>
<Share2 size={13} />
</button>
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent } from '@testing-library/react';
import QueuePanel from '@/features/queue/components/QueuePanel';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { useAuthStore } from '@/store/authStore';
import { resetAllStores } from '@/test/helpers/storeReset';
import { makeTrack } from '@/test/helpers/factories';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
const copyTextToClipboardMock = vi.fn(async (_text: string) => true);
vi.mock('@/lib/server/serverMagicString', () => ({
copyTextToClipboard: (text: string) => copyTextToClipboardMock(text),
}));
vi.mock('@/features/orbit/utils/orbitBulkGuard', () => ({
orbitBulkGuard: vi.fn(async () => true),
}));
vi.mock('@/lib/api/subsonic', () => ({
savePlayQueue: vi.fn(async () => undefined),
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
getSong: vi.fn(async () => null),
}));
function seedPublicShareQueue(pageUrl: string) {
const track = {
...makeTrack({ id: 'ndshare:AbCdEfGhIj:0', serverId: 'navidrome-public-share' }),
directStreamUrl: 'https://music.test/share/s/jwt-token',
};
usePlayerStore.setState({
queueServerId: 'navidrome-public-share',
navidromePublicSharePageUrl: pageUrl,
queueItems: [{
serverId: 'navidrome-public-share',
trackId: 'ndshare:AbCdEfGhIj:0',
directStreamUrl: track.directStreamUrl,
}],
queueIndex: 0,
currentTrack: track,
});
}
describe('QueuePanel public share toolbar', () => {
beforeEach(() => {
resetAllStores();
copyTextToClipboardMock.mockClear();
const id = useAuthStore.getState().addServer({
name: 'T', url: 'https://x.test', username: 'u', password: 'p',
});
useAuthStore.getState().setActiveServer(id);
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('library_get_recent_play_sessions', () => []);
});
it('hides Save Playlist in the playlist menu for a Navidrome public share queue', () => {
seedPublicShareQueue('https://music.test/share/AbCdEfGhIj');
const { getByLabelText, container } = renderWithProviders(<QueuePanel />);
fireEvent.click(getByLabelText('Playlist'));
const menu = container.querySelector('.queue-menu');
expect(menu?.textContent).not.toContain('Save Playlist');
expect(menu?.textContent).toContain('Load Playlist');
});
it('copies the original Navidrome share page URL from the share button', async () => {
const pageUrl = 'https://music.test/share/AbCdEfGhIj';
seedPublicShareQueue(pageUrl);
const { getByLabelText } = renderWithProviders(<QueuePanel />);
fireEvent.click(getByLabelText('Copy Navidrome share link'));
expect(copyTextToClipboardMock).toHaveBeenCalledWith(pageUrl);
});
});
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playNavidromePublicShare } from '@/features/share/playNavidromePublicShare';
import type { NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
import { resetPlayerStore } from '@/test/helpers/storeReset';
import { onInvoke, registerDefaultCoverInvokeHandlers } from '@/test/mocks/tauri';
vi.mock('@/lib/dom/toast', () => ({
showToast: vi.fn(),
}));
vi.mock('@/features/playback/store/queueTrackResolver', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/features/playback/store/queueTrackResolver')>();
return {
...actual,
seedQueueResolver: vi.fn(),
};
});
const shareRef = {
pageUrl: 'https://music.test/share/AbCdEfGhIj',
origin: 'https://music.test',
basePath: '',
shareId: 'AbCdEfGhIj',
};
const shareInfo: NavidromePublicShareInfo = {
id: 'AbCdEfGhIj',
description: '',
downloadable: false,
tracks: [{
id: 'jwt-token',
title: 'Track',
artist: 'Artist',
album: 'Album',
duration: 180,
}],
};
describe('playNavidromePublicShare', () => {
beforeEach(() => {
resetPlayerStore();
registerDefaultCoverInvokeHandlers();
onInvoke('audio_play', () => undefined);
onInvoke('audio_pause', () => undefined);
onInvoke('audio_stop', () => undefined);
onInvoke('audio_seek', () => undefined);
onInvoke('audio_get_state', () => ({ playing: false }));
onInvoke('audio_update_replay_gain', () => undefined);
onInvoke('discord_update_presence', () => undefined);
onInvoke('library_get_recent_play_sessions', () => []);
});
it('stores the Navidrome share page URL for queue toolbar copy', async () => {
const t = ((key: string) => key) as never;
const ok = await playNavidromePublicShare(shareRef, shareInfo, t);
expect(ok).toBe(true);
expect(usePlayerStore.getState().navidromePublicSharePageUrl).toBe(shareRef.pageUrl);
});
});
@@ -18,6 +18,7 @@ export async function playNavidromePublicShare(
usePlayerStore.getState().clearQueue();
seedQueueResolver(NAVIDROME_PUBLIC_SHARE_SERVER_ID, tracks);
usePlayerStore.setState({ navidromePublicSharePageUrl: ref.pageUrl });
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
showToast(t('sharePaste.openedNavidromePublic', { count: tracks.length }), 3000, 'info');
return true;
@@ -47,3 +47,12 @@ export function isPublicSharePersistedTrack(track: Track | null | undefined): bo
if (!track) return false;
return track.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(track.id);
}
export function tracksArePublicShareQueue(
tracks: ReadonlyArray<Pick<Track, 'id' | 'serverId'>>,
): boolean {
return tracks.length > 0
&& tracks.every(t =>
t.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(t.id),
);
}
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Затвори',
nextTracks: 'Следващи песни',
shareQueue: 'Копирай връзка за споделяне на опашката',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'Опашката е празна — няма какво да се сподели.',
emptyQueue: 'Опашката е празна.',
crossServerEnqueueBlocked: 'Песни от друг сървър не могат да се добавят към текущата опашка. Първо завършете или изчистете опашката.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Schließen',
nextTracks: 'Nächste Titel',
shareQueue: 'Warteschlangen-Link kopieren',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'Die Warteschlange ist leer — nichts zu teilen.',
emptyQueue: 'Die Warteschlange ist leer.',
crossServerEnqueueBlocked: 'Titel von einem anderen Server können der aktuellen Warteschlange nicht hinzugefügt werden. Beende oder leere die Warteschlange zuerst.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Close',
nextTracks: 'Next Tracks',
shareQueue: 'Copy queue share link',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'The queue is empty — nothing to share.',
emptyQueue: 'The queue is empty.',
crossServerEnqueueBlocked: 'Tracks from another server cannot be added to the current queue. Finish or clear the queue first.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Cerrar',
nextTracks: 'Siguientes',
shareQueue: 'Copiar enlace de la cola',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'La cola está vacía — no hay nada que compartir.',
emptyQueue: 'La cola está vacía.',
crossServerEnqueueBlocked: 'No se pueden añadir pistas de otro servidor a la cola actual. Termina o vacía la cola primero.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Fermer',
nextTracks: 'Pistes suivantes',
shareQueue: 'Copier le lien de la file dattente',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'La file dattente est vide — rien à partager.',
emptyQueue: 'La file d\'attente est vide.',
crossServerEnqueueBlocked: 'Les titres d\'un autre serveur ne peuvent pas être ajoutés à la file en cours. Terminez ou videz la file d\'abord.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Bezárás',
nextTracks: 'Következő számok',
shareQueue: 'Sor megosztási linkjének másolása',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'A sor üres — nincs mit megosztani.',
emptyQueue: 'A sor üres.',
crossServerEnqueueBlocked: 'Másik szerverről származó számok nem adhatók a jelenlegi sorhoz. Előbb fejezd be vagy töröld a sort.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Chiudi',
nextTracks: 'Prossimi brani',
shareQueue: 'Copia il link di condivisione della coda',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'La coda è vuota — niente da condividere.',
emptyQueue: 'La coda è vuota.',
crossServerEnqueueBlocked: 'Non possono essere aggiungti brani alla coda attuale da un altro server. Termina o svuota la coda.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: '閉じる',
nextTracks: '次のトラック',
shareQueue: 'キューの共有リンクをコピー',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'キューは空です。共有できるトラックはありません。',
emptyQueue: 'キューは空です。',
crossServerEnqueueBlocked: '別サーバーのトラックは現在のキューへ追加できません。先にキューを再生し終えるかクリアしてください。',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Lukk',
nextTracks: 'Neste spor',
shareQueue: 'Kopiér lenke til kø',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'Køen er tom — ingenting å dele.',
emptyQueue: 'Køen er tom.',
crossServerEnqueueBlocked: 'Spor fra en annen server kan ikke legges til i den nåværende køen. Tøm eller avslutt køen først.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Sluiten',
nextTracks: 'Volgende nummers',
shareQueue: 'Wachtrij-deellink kopiëren',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'De wachtrij is leeg — niets om te delen.',
emptyQueue: 'De wachtrij is leeg.',
crossServerEnqueueBlocked: 'Tracks van een andere server kunnen niet aan de huidige wachtrij worden toegevoegd. Maak de wachtrij eerst leeg of stop afspelen.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Zamknij',
nextTracks: 'Następne utwory',
shareQueue: 'Skopiuj link do udostępnienia kolejki',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'Kolejka jest pusta — nie ma czego udostępnić.',
emptyQueue: 'Kolejka jest pusta.',
crossServerEnqueueBlocked: 'Nie można dodać do bieżącej kolejki utworów z innego serwera. Najpierw zakończ lub wyczyść kolejkę.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Închide',
nextTracks: 'Următoarele Piese',
shareQueue: 'Copiază link-ul distribuirii cozii',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: 'Coada este goală — nimic de distribuit.',
emptyQueue: 'Coada este goală.',
crossServerEnqueueBlocked: 'Piesele de pe alt server nu pot fi adăugate în coada curentă. Golește sau oprește coada mai întâi.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: 'Закрыть',
nextTracks: 'Дальше',
shareQueue: 'Копировать ссылку на очередь',
shareNavidromePublic: 'Копировать ссылку Navidrome share',
shareNavidromePublicMissing: 'Ссылка Navidrome share недоступна.',
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
emptyQueue: 'Очередь пуста.',
crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.',
+2
View File
@@ -33,6 +33,8 @@ export const queue = {
close: '关闭',
nextTracks: '即将播放',
shareQueue: '复制队列分享链接',
shareNavidromePublic: 'Copy Navidrome share link',
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
shareQueueEmpty: '队列为空,无法分享。',
emptyQueue: '队列为空。',
crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。',