mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(queue): adapt toolbar for Navidrome public share queues (#1279)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ export const queue = {
|
||||
close: 'Затвори',
|
||||
nextTracks: 'Следващи песни',
|
||||
shareQueue: 'Копирай връзка за споделяне на опашката',
|
||||
shareNavidromePublic: 'Copy Navidrome share link',
|
||||
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
|
||||
shareQueueEmpty: 'Опашката е празна — няма какво да се сподели.',
|
||||
emptyQueue: 'Опашката е празна.',
|
||||
crossServerEnqueueBlocked: 'Песни от друг сървър не могат да се добавят към текущата опашка. Първо завършете или изчистете опашката.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const queue = {
|
||||
close: 'Fermer',
|
||||
nextTracks: 'Pistes suivantes',
|
||||
shareQueue: 'Copier le lien de la file d’attente',
|
||||
shareNavidromePublic: 'Copy Navidrome share link',
|
||||
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
|
||||
shareQueueEmpty: 'La file d’attente 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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const queue = {
|
||||
close: '閉じる',
|
||||
nextTracks: '次のトラック',
|
||||
shareQueue: 'キューの共有リンクをコピー',
|
||||
shareNavidromePublic: 'Copy Navidrome share link',
|
||||
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
|
||||
shareQueueEmpty: 'キューは空です。共有できるトラックはありません。',
|
||||
emptyQueue: 'キューは空です。',
|
||||
crossServerEnqueueBlocked: '別サーバーのトラックは現在のキューへ追加できません。先にキューを再生し終えるかクリアしてください。',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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ę.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const queue = {
|
||||
close: 'Закрыть',
|
||||
nextTracks: 'Дальше',
|
||||
shareQueue: 'Копировать ссылку на очередь',
|
||||
shareNavidromePublic: 'Копировать ссылку Navidrome share',
|
||||
shareNavidromePublicMissing: 'Ссылка Navidrome share недоступна.',
|
||||
shareQueueEmpty: 'Очередь пуста — нечем поделиться.',
|
||||
emptyQueue: 'Очередь пуста.',
|
||||
crossServerEnqueueBlocked: 'Треки с другого сервера нельзя добавить в текущую очередь. Завершите или очистите очередь.',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const queue = {
|
||||
close: '关闭',
|
||||
nextTracks: '即将播放',
|
||||
shareQueue: '复制队列分享链接',
|
||||
shareNavidromePublic: 'Copy Navidrome share link',
|
||||
shareNavidromePublicMissing: 'Navidrome share link is unavailable.',
|
||||
shareQueueEmpty: '队列为空,无法分享。',
|
||||
emptyQueue: '队列为空。',
|
||||
crossServerEnqueueBlocked: '无法将其他服务器的曲目加入当前播放队列。请先清空或结束当前队列。',
|
||||
|
||||
Reference in New Issue
Block a user