mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 10:06:47 +00:00
feat(share): open Navidrome public share links for anonymous playback (#1275)
This commit is contained in:
@@ -101,6 +101,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
|
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
|
||||||
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
|
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
|
||||||
|
|
||||||
|
### Navidrome public share links — open and play without logging in
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1275](https://github.com/Psychotoxical/psysonic/pull/1275)**
|
||||||
|
|
||||||
|
* Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page.
|
||||||
|
* Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual.
|
||||||
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,7 @@ const CONTRIBUTOR_ENTRIES = [
|
|||||||
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
|
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
|
||||||
'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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
|||||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||||
const coverUrl = portraitCover.src;
|
const coverUrl = portraitCover.src;
|
||||||
const coverKey = portraitCover.cacheKey;
|
const coverKey = portraitCover.cacheKey;
|
||||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
const directCover = currentTrack?.directCoverArtUrl;
|
||||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
const cachedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||||
|
const resolvedCoverUrl = directCover ?? cachedCoverUrl;
|
||||||
|
|
||||||
// Dynamic accent color extracted from the current album cover, applied as
|
// Dynamic accent color extracted from the current album cover, applied as
|
||||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||||
// same-album tracks reuse the color without re-fetching.
|
// same-album tracks reuse the color without re-fetching.
|
||||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
const dynamicAccent = useFsDynamicAccent(directCover ?? artUrl, artKey);
|
||||||
|
|
||||||
// Artist image → portrait on right. Resolved through the shared fullscreen
|
// Artist image → portrait on right. Resolved through the shared fullscreen
|
||||||
// backdrop hook (banner / fanart.tv / Navidrome artist cover, in the user's
|
// backdrop hook (banner / fanart.tv / Navidrome artist cover, in the user's
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export default function FullscreenPlayerPrism({ onClose }: { onClose: () => void
|
|||||||
const albumRef =
|
const albumRef =
|
||||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
||||||
const cover = usePlaybackCoverArt(albumRef, 300);
|
const cover = usePlaybackCoverArt(albumRef, 300);
|
||||||
const dynamicAccent = useFsDynamicAccent(cover.src, cover.cacheKey);
|
const dynamicAccent = useFsDynamicAccent(currentTrack?.directCoverArtUrl ?? cover.src, cover.cacheKey);
|
||||||
|
|
||||||
const [lyricsOpen, setLyricsOpen] = useState(true);
|
const [lyricsOpen, setLyricsOpen] = useState(true);
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
|||||||
@@ -89,9 +89,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
|||||||
// thumbnail — crisp instead of the old low-res tier. It is no longer a
|
// thumbnail — crisp instead of the old low-res tier. It is no longer a
|
||||||
// background source (see below).
|
// background source (see below).
|
||||||
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
||||||
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
|
|
||||||
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
||||||
const thumbUrl = coverUrl;
|
const thumbUrl = currentTrack?.directCoverArtUrl ?? coverUrl;
|
||||||
// Background (§28). The album cover is deliberately NOT a background source —
|
// Background (§28). The album cover is deliberately NOT a background source —
|
||||||
// it only ever feeds the foreground thumbnail. The user-configurable source
|
// it only ever feeds the foreground thumbnail. The user-configurable source
|
||||||
// list (fanart / Navidrome artist image, no banner) drives the rest, resolved
|
// list (fanart / Navidrome artist image, no banner) drives the rest, resolved
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ interface HeroProps {
|
|||||||
networkLoveEnabled: boolean;
|
networkLoveEnabled: boolean;
|
||||||
activeLyricsTab: boolean;
|
activeLyricsTab: boolean;
|
||||||
coverRef?: CoverArtRef;
|
coverRef?: CoverArtRef;
|
||||||
|
directCoverArtUrl?: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
onToggleStar: () => void;
|
onToggleStar: () => void;
|
||||||
onToggleNetworkLove: () => void;
|
onToggleNetworkLove: () => void;
|
||||||
@@ -46,7 +47,7 @@ function renderStars(rating?: number) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
|
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, directCoverArtUrl, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||||
const networkIcon = useEnrichmentPrimaryIcon();
|
const networkIcon = useEnrichmentPrimaryIcon();
|
||||||
@@ -57,7 +58,9 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
|||||||
return (
|
return (
|
||||||
<div className="np-dash-hero">
|
<div className="np-dash-hero">
|
||||||
<div className="np-dash-hero-cover">
|
<div className="np-dash-hero-cover">
|
||||||
{coverRef ? (
|
{directCoverArtUrl ? (
|
||||||
|
<img className="np-cover" src={directCoverArtUrl} alt="" />
|
||||||
|
) : coverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="np-cover"
|
className="np-cover"
|
||||||
coverRef={coverRef}
|
coverRef={coverRef}
|
||||||
|
|||||||
@@ -244,9 +244,11 @@ export default function MobilePlayerView() {
|
|||||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||||
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
|
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
|
||||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||||
|
const directCover = currentTrack?.directCoverArtUrl;
|
||||||
|
const displayCover = directCover ?? resolvedCover;
|
||||||
|
|
||||||
// Dynamic background color extracted from cover art
|
// Dynamic background color extracted from cover art
|
||||||
const accentColor = useAlbumAccentColor(resolvedCover);
|
const accentColor = useAlbumAccentColor(displayCover);
|
||||||
|
|
||||||
// Star / favorite
|
// Star / favorite
|
||||||
const isStarred = currentTrack
|
const isStarred = currentTrack
|
||||||
@@ -361,8 +363,8 @@ export default function MobilePlayerView() {
|
|||||||
|
|
||||||
{/* Cover Art */}
|
{/* Cover Art */}
|
||||||
<div className="mp-cover-wrap">
|
<div className="mp-cover-wrap">
|
||||||
{resolvedCover ? (
|
{displayCover ? (
|
||||||
<img src={resolvedCover} alt="" className="mp-cover" />
|
<img src={displayCover} alt="" className="mp-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="mp-cover mp-cover-fallback">
|
<div className="mp-cover mp-cover-fallback">
|
||||||
<Music size={64} />
|
<Music size={64} />
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export default function NowPlaying() {
|
|||||||
networkLoveEnabled={networkLoveEnabled}
|
networkLoveEnabled={networkLoveEnabled}
|
||||||
activeLyricsTab={activeTab === 'lyrics' && isQueueVisible}
|
activeLyricsTab={activeTab === 'lyrics' && isQueueVisible}
|
||||||
coverRef={playbackCoverRef}
|
coverRef={playbackCoverRef}
|
||||||
|
directCoverArtUrl={currentTrack.directCoverArtUrl}
|
||||||
onNavigate={stableNavigate}
|
onNavigate={stableNavigate}
|
||||||
onToggleStar={toggleStar}
|
onToggleStar={toggleStar}
|
||||||
onToggleNetworkLove={toggleNetworkLove}
|
onToggleNetworkLove={toggleNetworkLove}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function PlayerTrackInfo({
|
|||||||
showPreviewMeta ? { libraryResolve: false } : undefined,
|
showPreviewMeta ? { libraryResolve: false } : undefined,
|
||||||
);
|
);
|
||||||
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
|
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
|
||||||
|
const directCoverUrl = !isRadio && !showPreviewMeta ? currentTrack?.directCoverArtUrl : undefined;
|
||||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||||
@@ -96,6 +97,12 @@ export function PlayerTrackInfo({
|
|||||||
<Cast size={20} />
|
<Cast size={20} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
) : !isRadio && directCoverUrl ? (
|
||||||
|
<img
|
||||||
|
className="player-album-art"
|
||||||
|
src={directCoverUrl}
|
||||||
|
alt={currentTrack?.album ? `${currentTrack.album} Cover` : ''}
|
||||||
|
/>
|
||||||
) : !isRadio && activeCoverRef ? (
|
) : !isRadio && activeCoverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
className="player-album-art"
|
className="player-album-art"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Kept intentionally small — extend as other features need a symbol.
|
* Kept intentionally small — extend as other features need a symbol.
|
||||||
*/
|
*/
|
||||||
export { usePlayerStore } from './store/playerStore';
|
export { usePlayerStore } from './store/playerStore';
|
||||||
|
export { seedQueueResolver } from './store/queueTrackResolver';
|
||||||
export { queueSongStar } from './store/pendingStarSync';
|
export { queueSongStar } from './store/pendingStarSync';
|
||||||
export { getPlaybackProgressSnapshot, subscribePlaybackProgress } from './store/playbackProgress';
|
export { getPlaybackProgressSnapshot, subscribePlaybackProgress } from './store/playbackProgress';
|
||||||
export type { PlaybackProgressSnapshot } from './store/playbackProgress';
|
export type { PlaybackProgressSnapshot } from './store/playbackProgress';
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ vi.mock('@/lib/api/subsonicPlayQueue', () => ({
|
|||||||
getPlayQueueForServer: (...args: unknown[]) => getPlayQueueForServerMock(...args),
|
getPlayQueueForServer: (...args: unknown[]) => getPlayQueueForServerMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/lib/server/serverLookup', () => ({
|
vi.mock('@/lib/server/serverLookup', async (importOriginal) => {
|
||||||
resolveServerIdForIndexKey: (id: string) => id,
|
const actual = await importOriginal<typeof import('@/lib/server/serverLookup')>();
|
||||||
}));
|
return {
|
||||||
|
...actual,
|
||||||
|
resolveServerIdForIndexKey: (id: string) => id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@/lib/media/songToTrack', () => ({
|
vi.mock('@/lib/media/songToTrack', () => ({
|
||||||
songToTrack: (s: { id: string }) => ({
|
songToTrack: (s: { id: string }) => ({
|
||||||
@@ -36,6 +40,7 @@ vi.mock('@/features/playback/store/queueSyncUiState', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const playerState = {
|
const playerState = {
|
||||||
|
queueServerId: null as string | null,
|
||||||
queueItems: [] as QueueItemRef[],
|
queueItems: [] as QueueItemRef[],
|
||||||
queueIndex: 0,
|
queueIndex: 0,
|
||||||
currentTrack: null as { id: string; title: string; artist: string; album: string; albumId: string; duration: number } | null,
|
currentTrack: null as { id: string; title: string; artist: string; album: string; albumId: string; duration: number } | null,
|
||||||
@@ -65,6 +70,7 @@ describe('applyServerPlayQueue idle guards', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
_resetQueuePlaybackIdleForTest();
|
_resetQueuePlaybackIdleForTest();
|
||||||
getPlayQueueForServerMock.mockReset();
|
getPlayQueueForServerMock.mockReset();
|
||||||
|
playerState.queueServerId = null;
|
||||||
playerState.queueItems = [{ serverId: 'srv-a', trackId: 'local-only' }];
|
playerState.queueItems = [{ serverId: 'srv-a', trackId: 'local-only' }];
|
||||||
playerState.queueIndex = 0;
|
playerState.queueIndex = 0;
|
||||||
playerState.currentTrack = {
|
playerState.currentTrack = {
|
||||||
@@ -129,4 +135,44 @@ describe('applyServerPlayQueue idle guards', () => {
|
|||||||
expect(result).toBe('noop');
|
expect(result).toBe('noop');
|
||||||
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
|
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not overwrite an active Navidrome public share queue in idle mode', async () => {
|
||||||
|
playerState.queueServerId = 'navidrome-public-share';
|
||||||
|
playerState.queueItems = [{
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:abc:0',
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||||
|
}];
|
||||||
|
getPlayQueueForServerMock.mockResolvedValue({
|
||||||
|
songs: [{ id: 'remote-a' }],
|
||||||
|
current: 'remote-a',
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
|
||||||
|
|
||||||
|
expect(result).toBe('noop');
|
||||||
|
expect(getPlayQueueForServerMock).not.toHaveBeenCalled();
|
||||||
|
expect(playerState.queueItems[0]?.trackId).toBe('ndshare:abc:0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies server queue on startup even when share refs are still in memory', async () => {
|
||||||
|
playerState.queueServerId = 'navidrome-public-share';
|
||||||
|
playerState.queueItems = [{
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:abc:0',
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||||
|
}];
|
||||||
|
getPlayQueueForServerMock.mockResolvedValue({
|
||||||
|
songs: [{ id: 'remote-a' }],
|
||||||
|
current: 'remote-a',
|
||||||
|
position: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await applyServerPlayQueue('srv-a', { mode: 'startup' });
|
||||||
|
|
||||||
|
expect(result).toBe('applied');
|
||||||
|
expect(getPlayQueueForServerMock).toHaveBeenCalledWith('srv-a');
|
||||||
|
expect(playerState.queueItems[0]?.trackId).toBe('remote-a');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { Track } from '@/lib/media/trackTypes';
|
|||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||||
import { preparePausedRestoreOnStartup } from '@/features/playback/store/pausedRestorePrepare';
|
import { preparePausedRestoreOnStartup } from '@/features/playback/store/pausedRestorePrepare';
|
||||||
|
import { isActivePublicShareQueue } from '@/lib/share/navidromePublicSharePlayback';
|
||||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||||
import {
|
import {
|
||||||
@@ -133,6 +134,11 @@ export async function applyServerPlayQueue(
|
|||||||
const profileId = resolveServerProfileId(serverId);
|
const profileId = resolveServerProfileId(serverId);
|
||||||
if (!profileId) return 'error';
|
if (!profileId) return 'error';
|
||||||
|
|
||||||
|
const local = usePlayerStore.getState();
|
||||||
|
if (options.mode !== 'startup' && isActivePublicShareQueue(local.queueServerId, local.queueItems)) {
|
||||||
|
return 'noop';
|
||||||
|
}
|
||||||
|
|
||||||
if (options.mode === 'idle' && (isIdleQueuePullSuspended() || isQueuePushFailed())) {
|
if (options.mode === 'idle' && (isIdleQueuePullSuspended() || isQueuePushFailed())) {
|
||||||
return 'noop';
|
return 'noop';
|
||||||
}
|
}
|
||||||
@@ -142,6 +148,14 @@ export async function applyServerPlayQueue(
|
|||||||
const q = await getPlayQueueForServer(profileId);
|
const q = await getPlayQueueForServer(profileId);
|
||||||
if (q.songs.length === 0) return 'empty';
|
if (q.songs.length === 0) return 'empty';
|
||||||
|
|
||||||
|
const localAfterFetch = usePlayerStore.getState();
|
||||||
|
if (
|
||||||
|
options.mode !== 'startup'
|
||||||
|
&& isActivePublicShareQueue(localAfterFetch.queueServerId, localAfterFetch.queueItems)
|
||||||
|
) {
|
||||||
|
return 'noop';
|
||||||
|
}
|
||||||
|
|
||||||
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
|
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
|
||||||
if (options.mode === 'idle') {
|
if (options.mode === 'idle') {
|
||||||
if (isIdleQueuePullSuspended() || isQueuePushFailed()) return 'noop';
|
if (isIdleQueuePullSuspended() || isQueuePushFailed()) return 'noop';
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
playbackProfileIdForRef,
|
playbackProfileIdForRef,
|
||||||
playbackProfileIdForTrack,
|
playbackProfileIdForTrack,
|
||||||
} from '@/features/playback/utils/playback/playbackServer';
|
} from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
|
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
|
||||||
import {
|
import {
|
||||||
applyGaplessQueueAdvance,
|
applyGaplessQueueAdvance,
|
||||||
@@ -392,7 +392,7 @@ export function handleAudioProgress(
|
|||||||
const analysisServerId = nextRef
|
const analysisServerId = nextRef
|
||||||
? playbackCacheKeyForRef(nextRef)
|
? playbackCacheKeyForRef(nextRef)
|
||||||
: getPlaybackIndexKey();
|
: getPlaybackIndexKey();
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrlForTrack(nextTrack, serverId);
|
||||||
|
|
||||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { autodjMaxOverlapCapSec } from '@/lib/audio/autodjOverlapCap';
|
|||||||
import { computeWaveformSilence, planCrossfadeTransition } from '@/lib/waveform/waveformSilence';
|
import { computeWaveformSilence, planCrossfadeTransition } from '@/lib/waveform/waveformSilence';
|
||||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||||
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||||
import type { Track } from '@/lib/media/trackTypes';
|
import type { Track } from '@/lib/media/trackTypes';
|
||||||
import {
|
import {
|
||||||
@@ -71,7 +71,7 @@ export function kickEagerCrossfadePreload(
|
|||||||
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
|
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
|
||||||
if (track.id === getBytePreloadingId()) return;
|
if (track.id === getBytePreloadingId()) return;
|
||||||
const serverId = cacheKey || profileId;
|
const serverId = cacheKey || profileId;
|
||||||
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
|
const url = resolvePlaybackUrlForTrack(track, serverId ?? undefined);
|
||||||
setBytePreloadingId(track.id);
|
setBytePreloadingId(track.id);
|
||||||
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
|
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
|
||||||
audioPreload({
|
audioPreload({
|
||||||
@@ -148,7 +148,7 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi
|
|||||||
if (!nextTrack || nextTrack.id === track.id) return;
|
if (!nextTrack || nextTrack.id === track.id) return;
|
||||||
|
|
||||||
const serverId = playbackCacheKeyForRef(nextRef);
|
const serverId = playbackCacheKeyForRef(nextRef);
|
||||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
const nextUrl = resolvePlaybackUrlForTrack(nextTrack, serverId);
|
||||||
|
|
||||||
// Byte pre-download — skipped when the hot cache is on (it already keeps the
|
// Byte pre-download — skipped when the hot cache is on (it already keeps the
|
||||||
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
|
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
getPlaybackIndexKey,
|
getPlaybackIndexKey,
|
||||||
playbackCacheKeyForTrack,
|
playbackCacheKeyForTrack,
|
||||||
} from '@/features/playback/utils/playback/playbackServer';
|
} from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -40,7 +40,7 @@ export function engineLoadTrackAtPosition(opts: {
|
|||||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||||
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
||||||
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
||||||
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
const url = resolvePlaybackUrlForTrack(track, playbackCacheSid);
|
||||||
recordEnginePlayUrl(track.id, url);
|
recordEnginePlayUrl(track.id, url);
|
||||||
usePlayerStore.setState({
|
usePlayerStore.setState({
|
||||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getPlaybackIndexKey,
|
getPlaybackIndexKey,
|
||||||
playbackCacheKeyForRef,
|
playbackCacheKeyForRef,
|
||||||
} from '@/features/playback/utils/playback/playbackServer';
|
} from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||||
import {
|
import {
|
||||||
getGaplessPreloadingId,
|
getGaplessPreloadingId,
|
||||||
@@ -53,7 +53,7 @@ function invokeGaplessChainPreload(
|
|||||||
const analysisServerId = ctx.nextRef
|
const analysisServerId = ctx.nextRef
|
||||||
? playbackCacheKeyForRef(ctx.nextRef)
|
? playbackCacheKeyForRef(ctx.nextRef)
|
||||||
: getPlaybackIndexKey();
|
: getPlaybackIndexKey();
|
||||||
const nextUrl = resolvePlaybackUrl(prepared.id, serverId);
|
const nextUrl = resolvePlaybackUrlForTrack(prepared, serverId);
|
||||||
const nextNeighbour = gaplessNextNeighbour(ctx);
|
const nextNeighbour = gaplessNextNeighbour(ctx);
|
||||||
const replayGainDb = resolveReplayGainDb(
|
const replayGainDb = resolveReplayGainDb(
|
||||||
prepared, ctx.currentTrack, nextNeighbour,
|
prepared, ctx.currentTrack, nextNeighbour,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
playbackCacheKeyForRef,
|
playbackCacheKeyForRef,
|
||||||
playbackProfileIdForTrack,
|
playbackProfileIdForTrack,
|
||||||
} from '@/features/playback/utils/playback/playbackServer';
|
} from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||||
import { syncQueueToServer } from '@/features/playback/store/queueSync';
|
import { syncQueueToServer } from '@/features/playback/store/queueSync';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -86,7 +86,7 @@ function applyGaplessSuccessorUi(
|
|||||||
): void {
|
): void {
|
||||||
const switchRef = store.queueItems[newIndex];
|
const switchRef = store.queueItems[newIndex];
|
||||||
const switchServerId = playbackCacheKeyForRef(switchRef);
|
const switchServerId = playbackCacheKeyForRef(switchRef);
|
||||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
const switchResolvedUrl = resolvePlaybackUrlForTrack(nextTrack, switchServerId);
|
||||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
|
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
|
||||||
nextTrack.id,
|
nextTrack.id,
|
||||||
switchServerId,
|
switchServerId,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
findLocalPlaybackUrl,
|
findLocalPlaybackUrl,
|
||||||
hasLocalPersistentPlaybackBytes,
|
hasLocalPersistentPlaybackBytes,
|
||||||
} from '@/store/localPlaybackResolve';
|
} from '@/store/localPlaybackResolve';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||||
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||||
@@ -63,7 +63,7 @@ import type { Track } from '@/lib/media/trackTypes';
|
|||||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||||
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
import { getCachedTrack, seedQueueResolver, mergeDirectShareUrls } from '@/features/playback/store/queueTrackResolver';
|
||||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||||
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
|
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
|
||||||
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
|
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
|
||||||
@@ -201,7 +201,27 @@ export function runPlayTrack(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopedTrack = scopedTrackEarly;
|
const stateBeforePlay = get();
|
||||||
|
const replacingEarly = queue !== undefined;
|
||||||
|
const srcLenEarly = replacingEarly ? (queue?.length ?? 0) : stateBeforePlay.queueItems.length;
|
||||||
|
const playIdxEarly = (() => {
|
||||||
|
if (typeof targetQueueIndex === 'number' && targetQueueIndex >= 0 && targetQueueIndex < srcLenEarly) {
|
||||||
|
return targetQueueIndex;
|
||||||
|
}
|
||||||
|
if (replacingEarly && queue) {
|
||||||
|
const i = queue.findIndex(t => sameQueueTrackId(t.id, scopedTrackEarly.id));
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
}
|
||||||
|
const i = stateBeforePlay.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrackEarly.id));
|
||||||
|
return i >= 0 ? i : 0;
|
||||||
|
})();
|
||||||
|
const playingRefEarly = replacingEarly ? undefined : stateBeforePlay.queueItems[playIdxEarly];
|
||||||
|
const scopedTrack = playingRefEarly
|
||||||
|
? mergeDirectShareUrls(
|
||||||
|
(!replacingEarly ? getCachedTrack(playingRefEarly) : undefined) ?? scopedTrackEarly,
|
||||||
|
playingRefEarly,
|
||||||
|
)
|
||||||
|
: scopedTrackEarly;
|
||||||
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
|
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
|
||||||
|
|
||||||
clearAllPlaybackScheduleTimers();
|
clearAllPlaybackScheduleTimers();
|
||||||
@@ -335,7 +355,7 @@ export function runPlayTrack(
|
|||||||
const url = libraryLocalUrl
|
const url = libraryLocalUrl
|
||||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
|
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
|
||||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
|
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
|
||||||
?? resolvePlaybackUrl(trackForPlay.id, playbackCacheSid);
|
?? resolvePlaybackUrlForTrack(trackForPlay, playbackCacheSid);
|
||||||
recordEnginePlayUrl(trackForPlay.id, url);
|
recordEnginePlayUrl(trackForPlay.id, url);
|
||||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||||
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
|
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
|
||||||
@@ -361,10 +381,15 @@ export function runPlayTrack(
|
|||||||
// resolver with those tracks so the UI / hot paths resolve them without a
|
// resolver with those tracks so the UI / hot paths resolve them without a
|
||||||
// network round-trip. No-arg jumps reuse already-cached refs.
|
// network round-trip. No-arg jumps reuse already-cached refs.
|
||||||
if (scopedQueue) {
|
if (scopedQueue) {
|
||||||
|
const bySid = new Map<string, Track[]>();
|
||||||
for (const t of scopedQueue) {
|
for (const t of scopedQueue) {
|
||||||
const sid = playbackCacheKeyForTrack(t);
|
const sid = playbackCacheKeyForTrack(t);
|
||||||
if (sid) seedQueueResolver(sid, [t]);
|
if (!sid) continue;
|
||||||
|
const bucket = bySid.get(sid);
|
||||||
|
if (bucket) bucket.push(t);
|
||||||
|
else bySid.set(sid, [t]);
|
||||||
}
|
}
|
||||||
|
for (const [sid, tracks] of bySid) seedQueueResolver(sid, tracks);
|
||||||
} else if (queueSid) {
|
} else if (queueSid) {
|
||||||
seedQueueResolver(queueSid, [trackForPlay]);
|
seedQueueResolver(queueSid, [trackForPlay]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,4 +305,24 @@ describe('merge: restores the queue from any old persisted blob', () => {
|
|||||||
expect(merged.queueItems).toEqual([]);
|
expect(merged.queueItems).toEqual([]);
|
||||||
expect(merged.queueItemsIndex).toBeUndefined();
|
expect(merged.queueItemsIndex).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('drops persisted Navidrome public share queues on rehydrate', () => {
|
||||||
|
const merged = getMerge()(
|
||||||
|
{
|
||||||
|
queueServerId: 'navidrome-public-share',
|
||||||
|
queueItems: [{
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:abc:0',
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||||
|
}],
|
||||||
|
queueItemsIndex: 0,
|
||||||
|
currentTrack: makeTrack({ id: 'ndshare:abc:0', serverId: 'navidrome-public-share' }),
|
||||||
|
},
|
||||||
|
current(),
|
||||||
|
);
|
||||||
|
expect(merged.queueItems).toEqual([]);
|
||||||
|
expect(merged.queueItemsIndex).toBeUndefined();
|
||||||
|
expect(merged.currentTrack).toBeNull();
|
||||||
|
expect(merged.queueServerId).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
|||||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||||
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
||||||
|
import {
|
||||||
|
isActivePublicShareQueue,
|
||||||
|
isPublicSharePersistedTrack,
|
||||||
|
NAVIDROME_PUBLIC_SHARE_SERVER_ID,
|
||||||
|
} from '@/lib/share/navidromePublicSharePlayback';
|
||||||
import { readInitialQueueVisibility } from '@/features/playback/store/queueVisibilityStorage';
|
import { readInitialQueueVisibility } from '@/features/playback/store/queueVisibilityStorage';
|
||||||
import { createNetworkLoveActions } from '@/features/playback/store/networkLoveActions';
|
import { createNetworkLoveActions } from '@/features/playback/store/networkLoveActions';
|
||||||
import { createMiscActions } from '@/features/playback/store/miscActions';
|
import { createMiscActions } from '@/features/playback/store/miscActions';
|
||||||
@@ -152,6 +157,27 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
|
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistedTrack = blob.currentTrack as Track | null | undefined;
|
||||||
|
let strippedPublicShare = false;
|
||||||
|
if (queueItems?.length && isActivePublicShareQueue(canonicalSid, queueItems)) {
|
||||||
|
strippedPublicShare = true;
|
||||||
|
queueItems = undefined;
|
||||||
|
queueItemsIndex = undefined;
|
||||||
|
delete blob.queueItems;
|
||||||
|
delete blob.queueItemsIndex;
|
||||||
|
delete blob.queueRefs;
|
||||||
|
delete blob.queueRefsIndex;
|
||||||
|
delete blob.queueIndex;
|
||||||
|
}
|
||||||
|
if (isPublicSharePersistedTrack(persistedTrack)) {
|
||||||
|
delete blob.currentTrack;
|
||||||
|
}
|
||||||
|
if (strippedPublicShare || canonicalSid === NAVIDROME_PUBLIC_SHARE_SERVER_ID) {
|
||||||
|
blob.queueServerId = null;
|
||||||
|
} else if (canonicalSid !== null) {
|
||||||
|
blob.queueServerId = canonicalSid;
|
||||||
|
}
|
||||||
|
|
||||||
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
|
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
|
||||||
delete blob.queue;
|
delete blob.queue;
|
||||||
// volume/repeatMode are owned by `psysonic_player_prefs`; strip any legacy
|
// volume/repeatMode are owned by `psysonic_player_prefs`; strip any legacy
|
||||||
@@ -161,11 +187,6 @@ export const usePlayerStore = create<PlayerState>()(
|
|||||||
delete blob.isQueueVisible;
|
delete blob.isQueueVisible;
|
||||||
delete blob.lastfmLovedCache;
|
delete blob.lastfmLovedCache;
|
||||||
delete blob.networkLovedCache;
|
delete blob.networkLovedCache;
|
||||||
// Persist the canonical form back onto the merged blob so subsequent
|
|
||||||
// reads of state.queueServerId always see the index key.
|
|
||||||
if (canonicalSid !== null) {
|
|
||||||
blob.queueServerId = canonicalSid;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
|
|||||||
@@ -13,4 +13,20 @@ describe('toQueueItemRefs', () => {
|
|||||||
expect(refs[0].trackId).toBe('t1');
|
expect(refs[0].trackId).toBe('t1');
|
||||||
expect(refs[1].trackId).toBe('t2');
|
expect(refs[1].trackId).toBe('t2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists Navidrome public share direct URLs on refs', () => {
|
||||||
|
const queue: Track[] = [{
|
||||||
|
id: 'ndshare:Ab12:0',
|
||||||
|
title: 'A',
|
||||||
|
artist: '',
|
||||||
|
album: '',
|
||||||
|
albumId: '',
|
||||||
|
duration: 1,
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||||
|
directCoverArtUrl: 'https://music.example.com/share/img/jwt-a?size=300',
|
||||||
|
}];
|
||||||
|
const refs = toQueueItemRefs('navidrome-public-share', queue);
|
||||||
|
expect(refs[0]?.directStreamUrl).toBe('https://music.example.com/share/s/jwt-a');
|
||||||
|
expect(refs[0]?.directCoverArtUrl).toBe('https://music.example.com/share/img/jwt-a?size=300');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[
|
|||||||
if (t.autoAdded) ref.autoAdded = true;
|
if (t.autoAdded) ref.autoAdded = true;
|
||||||
if (t.radioAdded) ref.radioAdded = true;
|
if (t.radioAdded) ref.radioAdded = true;
|
||||||
if (t.playNextAdded) ref.playNextAdded = true;
|
if (t.playNextAdded) ref.playNextAdded = true;
|
||||||
|
if (t.directStreamUrl) ref.directStreamUrl = t.directStreamUrl;
|
||||||
|
if (t.directCoverArtUrl) ref.directCoverArtUrl = t.directCoverArtUrl;
|
||||||
return ref;
|
return ref;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID } from '@/lib/share/navidromePublicSharePlayback';
|
||||||
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
|
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
|
||||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||||
import { isSubsonicServerReachable } from '@/lib/network/subsonicNetworkGuard';
|
import { isSubsonicServerReachable } from '@/lib/network/subsonicNetworkGuard';
|
||||||
@@ -47,7 +48,8 @@ let lastQueueHeartbeatAt = 0;
|
|||||||
|
|
||||||
function isPlaybackServerReachable(): boolean {
|
function isPlaybackServerReachable(): boolean {
|
||||||
const serverId = getPlaybackServerId();
|
const serverId = getPlaybackServerId();
|
||||||
return serverId ? isSubsonicServerReachable(serverId) : false;
|
if (!serverId || serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) return false;
|
||||||
|
return isSubsonicServerReachable(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns true when the server accepted the queue (or there was nothing to push). */
|
/** @returns true when the server accepted the queue (or there was nothing to push). */
|
||||||
|
|||||||
@@ -100,6 +100,50 @@ describe('queueTrackResolver', () => {
|
|||||||
expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded');
|
expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('seedQueueResolver keeps an entire large replacement queue in cache', () => {
|
||||||
|
const tracks = Array.from({ length: 555 }, (_, i) => ({
|
||||||
|
id: `bulk-${i}`,
|
||||||
|
title: `Track ${i}`,
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
albumId: '',
|
||||||
|
duration: 200,
|
||||||
|
}));
|
||||||
|
seedQueueResolver('navidrome-public-share', tracks);
|
||||||
|
expect(getCachedTrack(ref('bulk-0', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 0');
|
||||||
|
expect(getCachedTrack(ref('bulk-554', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 554');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveBatch builds public share tracks from ref directStreamUrl', async () => {
|
||||||
|
await resolveBatch([{
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:Ab12:0',
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||||
|
directCoverArtUrl: 'https://music.example.com/share/img/jwt-a?size=300',
|
||||||
|
}]);
|
||||||
|
expect(getCachedTrack({
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:Ab12:0',
|
||||||
|
})?.directStreamUrl).toBe('https://music.example.com/share/s/jwt-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('single-track seed does not evict a bulk-seeded public share queue', () => {
|
||||||
|
const tracks = Array.from({ length: 555 }, (_, i) => ({
|
||||||
|
id: `bulk-${i}`,
|
||||||
|
title: `Track ${i}`,
|
||||||
|
artist: 'Artist',
|
||||||
|
album: 'Album',
|
||||||
|
albumId: '',
|
||||||
|
duration: 200,
|
||||||
|
directStreamUrl: `https://music.example.com/share/s/token-${i}`,
|
||||||
|
}));
|
||||||
|
seedQueueResolver('navidrome-public-share', tracks);
|
||||||
|
seedQueueResolver('navidrome-public-share', [tracks[10]!]);
|
||||||
|
expect(getCachedTrack(ref('bulk-0', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 0');
|
||||||
|
expect(getCachedTrack(ref('bulk-100', { serverId: 'navidrome-public-share' }))?.directStreamUrl)
|
||||||
|
.toBe('https://music.example.com/share/s/token-100');
|
||||||
|
});
|
||||||
|
|
||||||
it('invalidateQueueResolver drops the cached entry', async () => {
|
it('invalidateQueueResolver drops the cached entry', async () => {
|
||||||
ready();
|
ready();
|
||||||
echoBatch();
|
echoBatch();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
|||||||
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
||||||
import { trackToSong } from '@/lib/library/advancedSearchLocal';
|
import { trackToSong } from '@/lib/library/advancedSearchLocal';
|
||||||
import { libraryIsReady } from '@/lib/library/libraryReady';
|
import { libraryIsReady } from '@/lib/library/libraryReady';
|
||||||
|
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID } from '@/lib/share/navidromePublicSharePlayback';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue track resolver (thin-state phase 2). Resolves `QueueItemRef`s to full
|
* Queue track resolver (thin-state phase 2). Resolves `QueueItemRef`s to full
|
||||||
@@ -50,9 +51,10 @@ export function subscribeQueueResolver(cb: () => void): () => void {
|
|||||||
return () => { listeners.delete(cb); };
|
return () => { listeners.delete(cb); };
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheSet(key: string, track: Track): void {
|
function cacheSet(key: string, track: Track, opts?: { skipCap?: boolean }): void {
|
||||||
if (cache.has(key)) cache.delete(key);
|
if (cache.has(key)) cache.delete(key);
|
||||||
cache.set(key, track);
|
cache.set(key, track);
|
||||||
|
if (opts?.skipCap) return;
|
||||||
while (cache.size > CACHE_CAP) {
|
while (cache.size > CACHE_CAP) {
|
||||||
const oldest = cache.keys().next().value;
|
const oldest = cache.keys().next().value;
|
||||||
if (oldest === undefined) break;
|
if (oldest === undefined) break;
|
||||||
@@ -60,6 +62,13 @@ function cacheSet(key: string, track: Track): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimCacheExcept(preserve: Set<string>): void {
|
||||||
|
for (const key of [...cache.keys()]) {
|
||||||
|
if (cache.size <= CACHE_CAP) return;
|
||||||
|
if (!preserve.has(key)) cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
|
function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
|
||||||
if (ref?.autoAdded) track.autoAdded = true;
|
if (ref?.autoAdded) track.autoAdded = true;
|
||||||
if (ref?.radioAdded) track.radioAdded = true;
|
if (ref?.radioAdded) track.radioAdded = true;
|
||||||
@@ -97,6 +106,23 @@ export function placeholderTrack(ref: QueueItemRef): Track {
|
|||||||
autoAdded: ref.autoAdded,
|
autoAdded: ref.autoAdded,
|
||||||
radioAdded: ref.radioAdded,
|
radioAdded: ref.radioAdded,
|
||||||
playNextAdded: ref.playNextAdded,
|
playNextAdded: ref.playNextAdded,
|
||||||
|
directStreamUrl: ref.directStreamUrl,
|
||||||
|
directCoverArtUrl: ref.directCoverArtUrl,
|
||||||
|
serverId: ref.serverId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDirectShareUrls(track: Track, ref: QueueItemRef): Track {
|
||||||
|
if (!ref.directStreamUrl && !ref.directCoverArtUrl) return track;
|
||||||
|
if (track.directStreamUrl === ref.directStreamUrl
|
||||||
|
&& track.directCoverArtUrl === ref.directCoverArtUrl) {
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...track,
|
||||||
|
directStreamUrl: track.directStreamUrl ?? ref.directStreamUrl,
|
||||||
|
directCoverArtUrl: track.directCoverArtUrl ?? ref.directCoverArtUrl,
|
||||||
|
serverId: track.serverId ?? ref.serverId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +146,15 @@ export function applyQueueOverrides(track: Track): Track {
|
|||||||
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
const canonicalId = canonicalQueueServerKey(serverId);
|
const canonicalId = canonicalQueueServerKey(serverId);
|
||||||
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
|
const preserve = new Set<string>();
|
||||||
|
for (const t of tracks) {
|
||||||
|
const key = refKey({ serverId: canonicalId, trackId: t.id });
|
||||||
|
preserve.add(key);
|
||||||
|
cacheSet(key, t, { skipCap: true });
|
||||||
|
}
|
||||||
|
// Bulk queue replace: keep the whole incoming set (may exceed CACHE_CAP).
|
||||||
|
// Single-track touch (queue navigation): no eviction — must not wipe siblings.
|
||||||
|
if (tracks.length > 1) trimCacheExcept(preserve);
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +182,45 @@ export async function resolveBatch(refs: QueueItemRef[]): Promise<void> {
|
|||||||
|
|
||||||
for (const [serverId, serverRefs] of byServer) {
|
for (const [serverId, serverRefs] of byServer) {
|
||||||
if (!serverId) continue;
|
if (!serverId) continue;
|
||||||
|
|
||||||
|
if (serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) {
|
||||||
|
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
||||||
|
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
||||||
|
|
||||||
|
if (await libraryIsReady(serverId)) {
|
||||||
|
for (let i = 0; i < serverRefs.length; i += BATCH) {
|
||||||
|
const chunk: TrackRefDto[] = serverRefs
|
||||||
|
.slice(i, i + BATCH)
|
||||||
|
.map(r => ({ serverId, trackId: r.trackId }));
|
||||||
|
try {
|
||||||
|
const dtos = await libraryGetTracksBatch(chunk);
|
||||||
|
for (const d of dtos) {
|
||||||
|
const ref = refByTrack.get(d.id);
|
||||||
|
const track = carryFlags(
|
||||||
|
mergeDirectShareUrls(
|
||||||
|
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? serverId },
|
||||||
|
ref ?? { serverId, trackId: d.id },
|
||||||
|
),
|
||||||
|
ref,
|
||||||
|
);
|
||||||
|
cacheSet(refKey({ serverId, trackId: d.id }), track, { skipCap: true });
|
||||||
|
stillMissing.delete(d.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trackId of stillMissing) {
|
||||||
|
const ref = refByTrack.get(trackId);
|
||||||
|
if (!ref?.directStreamUrl) continue;
|
||||||
|
const track = carryFlags(mergeDirectShareUrls(placeholderTrack(ref), ref), ref);
|
||||||
|
cacheSet(refKey({ serverId, trackId }), track, { skipCap: true });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
||||||
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||||
import { getCachedTrack, placeholderTrack, applyQueueOverrides } from './queueTrackResolver';
|
import { getCachedTrack, placeholderTrack, applyQueueOverrides, mergeDirectShareUrls } from './queueTrackResolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
|
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
|
||||||
@@ -43,7 +43,10 @@ export function bridgeQueueFromItems(items: QueueItemRef[], pools: Track[][]): T
|
|||||||
* for exactly this reason; see the freeze fix in queueTrackResolver).
|
* for exactly this reason; see the freeze fix in queueTrackResolver).
|
||||||
*/
|
*/
|
||||||
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
|
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
|
||||||
const base = getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref);
|
const base = mergeDirectShareUrls(
|
||||||
|
getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref),
|
||||||
|
ref,
|
||||||
|
);
|
||||||
// Carry the ref's queue-only flags onto the resolved track without mutating the
|
// Carry the ref's queue-only flags onto the resolved track without mutating the
|
||||||
// cached object (a render-time mutation is what caused the earlier render loop).
|
// cached object (a render-time mutation is what caused the earlier render loop).
|
||||||
const needsFlags =
|
const needsFlags =
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ const hoisted = vi.hoisted(() => {
|
|||||||
|
|
||||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||||
vi.mock('@/lib/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
|
vi.mock('@/lib/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
|
||||||
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({ resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock }));
|
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({
|
||||||
|
resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock,
|
||||||
|
resolvePlaybackUrlForTrack: (
|
||||||
|
track: { id: string; directStreamUrl?: string },
|
||||||
|
) => track.directStreamUrl ?? hoisted.resolvePlaybackUrlMock(track.id),
|
||||||
|
}));
|
||||||
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
|
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
|
||||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||||
vi.mock('@/features/playback/store/engineState', () => ({
|
vi.mock('@/features/playback/store/engineState', () => ({
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
getPlaybackCacheServerKey,
|
getPlaybackCacheServerKey,
|
||||||
getPlaybackIndexKey,
|
getPlaybackIndexKey,
|
||||||
} from '@/features/playback/utils/playback/playbackServer';
|
} from '@/features/playback/utils/playback/playbackServer';
|
||||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||||
import { songToTrack } from '@/lib/media/songToTrack';
|
import { songToTrack } from '@/lib/media/songToTrack';
|
||||||
@@ -176,7 +176,7 @@ export function runResume(set: SetState, get: GetState): void {
|
|||||||
);
|
);
|
||||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||||
setDeferHotCachePrefetch(true);
|
setDeferHotCachePrefetch(true);
|
||||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
const coldUrl = resolvePlaybackUrlForTrack(trackToPlay, coldServerId);
|
||||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||||
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock('@/store/localPlaybackStore', () => ({
|
|||||||
import {
|
import {
|
||||||
getPlaybackSourceKind,
|
getPlaybackSourceKind,
|
||||||
resolvePlaybackUrl,
|
resolvePlaybackUrl,
|
||||||
|
resolvePlaybackUrlForTrack,
|
||||||
streamUrlTrackId,
|
streamUrlTrackId,
|
||||||
} from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
} from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -89,6 +90,19 @@ describe('resolvePlaybackUrl — precedence', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolvePlaybackUrlForTrack', () => {
|
||||||
|
it('returns directStreamUrl when set on the track', () => {
|
||||||
|
const url = resolvePlaybackUrlForTrack(
|
||||||
|
{
|
||||||
|
id: 'ndshare:abc:0',
|
||||||
|
directStreamUrl: 'https://music.example.com/share/s/jwt-token',
|
||||||
|
},
|
||||||
|
'navidrome-public-share',
|
||||||
|
);
|
||||||
|
expect(url).toBe('https://music.example.com/share/s/jwt-token');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getPlaybackSourceKind', () => {
|
describe('getPlaybackSourceKind', () => {
|
||||||
it('returns "offline" when the library tier has the track', () => {
|
it('returns "offline" when the library tier has the track', () => {
|
||||||
seedLibraryEntry('t1', 'srv-1', '/library/t1.flac');
|
seedLibraryEntry('t1', 'srv-1', '/library/t1.flac');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
|
|||||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||||
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||||
import { getPlaybackCacheServerKey, getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
import { getPlaybackCacheServerKey, getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||||
|
import type { Track } from '@/lib/media/trackTypes';
|
||||||
|
|
||||||
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||||
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||||
@@ -72,3 +73,12 @@ export function resolvePlaybackUrl(trackId: string, serverId?: string): string {
|
|||||||
if (hot) return hot;
|
if (hot) return hot;
|
||||||
return buildStreamUrlForServer(profileId, trackId);
|
return buildStreamUrlForServer(profileId, trackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Like {@link resolvePlaybackUrl} but honours {@link Track.directStreamUrl}. */
|
||||||
|
export function resolvePlaybackUrlForTrack(
|
||||||
|
track: Pick<Track, 'id' | 'directStreamUrl'>,
|
||||||
|
serverId?: string,
|
||||||
|
): string {
|
||||||
|
if (track.directStreamUrl) return track.directStreamUrl;
|
||||||
|
return resolvePlaybackUrl(track.id, serverId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function QueueCurrentTrack({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||||
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||||
|
const directCoverUrl = currentTrack?.directCoverArtUrl;
|
||||||
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
||||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||||
const bpmTech = formatQueueBpmTech(enrichment, t);
|
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||||
@@ -206,7 +207,13 @@ export function QueueCurrentTrack({
|
|||||||
})()}
|
})()}
|
||||||
<div className="queue-current-track-body">
|
<div className="queue-current-track-body">
|
||||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||||
{coverRef ? (
|
{directCoverUrl ? (
|
||||||
|
<img
|
||||||
|
className="queue-current-cover-img"
|
||||||
|
src={directCoverUrl}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
) : coverRef ? (
|
||||||
<CoverArtImage
|
<CoverArtImage
|
||||||
coverRef={coverRef}
|
coverRef={coverRef}
|
||||||
displayCssPx={128}
|
displayCssPx={128}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ export default function LiveSearch() {
|
|||||||
id: 'share-link',
|
id: 'share-link',
|
||||||
action: () => {
|
action: () => {
|
||||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||||
|
else if (share.canPlayNavidromePublic) void share.playNavidromePublic();
|
||||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||||
@@ -167,6 +168,7 @@ export default function LiveSearch() {
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||||
|
else if (share.canPlayNavidromePublic) void share.playNavidromePublic();
|
||||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||||
|
|||||||
@@ -60,10 +60,15 @@ const shareStub = {
|
|||||||
shareComposerResolving: false,
|
shareComposerResolving: false,
|
||||||
shareComposerUnavailable: false,
|
shareComposerUnavailable: false,
|
||||||
canQueueShareMatch: false,
|
canQueueShareMatch: false,
|
||||||
|
canPlayNavidromePublic: false,
|
||||||
canOpenShareAlbum: false,
|
canOpenShareAlbum: false,
|
||||||
canOpenShareArtist: false,
|
canOpenShareArtist: false,
|
||||||
canOpenShareComposer: false,
|
canOpenShareComposer: false,
|
||||||
hasShareKeyboardTarget: false,
|
hasShareKeyboardTarget: false,
|
||||||
|
playNavidromePublic: vi.fn(),
|
||||||
|
navidromeShareInfo: null,
|
||||||
|
navidromeShareResolving: false,
|
||||||
|
navidromeShareError: null,
|
||||||
} as ReturnType<typeof useShareSearch>;
|
} as ReturnType<typeof useShareSearch>;
|
||||||
|
|
||||||
const results: SearchResults = {
|
const results: SearchResults = {
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export default function LiveSearchDropdown({
|
|||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
shareQueueBusy={share.shareQueueBusy}
|
shareQueueBusy={share.shareQueueBusy}
|
||||||
onEnqueue={() => void share.enqueueShareMatch()}
|
onEnqueue={() => void share.enqueueShareMatch()}
|
||||||
|
onPlayNavidromePublic={() => void share.playNavidromePublic()}
|
||||||
onOpenAlbum={share.openShareAlbum}
|
onOpenAlbum={share.openShareAlbum}
|
||||||
onOpenArtist={share.openShareArtist}
|
onOpenArtist={share.openShareArtist}
|
||||||
onOpenComposer={share.openShareComposer}
|
onOpenComposer={share.openShareComposer}
|
||||||
@@ -119,6 +120,9 @@ export default function LiveSearchDropdown({
|
|||||||
shareComposer={share.shareComposer}
|
shareComposer={share.shareComposer}
|
||||||
shareComposerResolving={share.shareComposerResolving}
|
shareComposerResolving={share.shareComposerResolving}
|
||||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||||
|
navidromeShareInfo={share.navidromeShareInfo}
|
||||||
|
navidromeShareResolving={share.navidromeShareResolving}
|
||||||
|
navidromeShareError={share.navidromeShareError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
shareCoverServer={share.shareCoverServer}
|
shareCoverServer={share.shareCoverServer}
|
||||||
shareQueueBusy={share.shareQueueBusy}
|
shareQueueBusy={share.shareQueueBusy}
|
||||||
onEnqueue={() => void share.enqueueShareMatch()}
|
onEnqueue={() => void share.enqueueShareMatch()}
|
||||||
|
onPlayNavidromePublic={() => void share.playNavidromePublic()}
|
||||||
onOpenAlbum={share.openShareAlbum}
|
onOpenAlbum={share.openShareAlbum}
|
||||||
onOpenArtist={share.openShareArtist}
|
onOpenArtist={share.openShareArtist}
|
||||||
onOpenComposer={share.openShareComposer}
|
onOpenComposer={share.openShareComposer}
|
||||||
@@ -354,6 +355,9 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
|||||||
shareComposer={share.shareComposer}
|
shareComposer={share.shareComposer}
|
||||||
shareComposerResolving={share.shareComposerResolving}
|
shareComposerResolving={share.shareComposerResolving}
|
||||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||||
|
navidromeShareInfo={share.navidromeShareInfo}
|
||||||
|
navidromeShareResolving={share.navidromeShareResolving}
|
||||||
|
navidromeShareError={share.navidromeShareError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Music, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
import type { NavidromePublicSharePreviewState } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||||
|
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||||
|
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||||
|
import { usePlayerStore } from '@/features/playback';
|
||||||
|
|
||||||
|
type NavidromePublicShareModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
publicShareRef: NavidromePublicShareRef;
|
||||||
|
preview: NavidromePublicSharePreviewState;
|
||||||
|
hostLabel?: string | null;
|
||||||
|
onPlay: () => void;
|
||||||
|
playBusy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function navidromeShareErrorMessage(
|
||||||
|
reason: NonNullable<NavidromePublicSharePreviewState['navidromeShareError']>,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'not-found':
|
||||||
|
return t('sharePaste.navidromeShareNotFound');
|
||||||
|
case 'expired':
|
||||||
|
return t('sharePaste.navidromeShareExpired');
|
||||||
|
case 'unreachable':
|
||||||
|
return t('sharePaste.navidromeShareUnreachable');
|
||||||
|
default:
|
||||||
|
return t('sharePaste.navidromeShareMalformed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewBody({
|
||||||
|
preview,
|
||||||
|
}: {
|
||||||
|
preview: NavidromePublicSharePreviewState;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (preview.navidromeShareResolving) {
|
||||||
|
return <div className="share-queue-preview-modal__status">{t('sharePaste.navidromeShareLoading')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.navidromeShareError) {
|
||||||
|
return (
|
||||||
|
<div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">
|
||||||
|
{navidromeShareErrorMessage(preview.navidromeShareError, t)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = preview.navidromeShareInfo;
|
||||||
|
if (!info) {
|
||||||
|
return (
|
||||||
|
<div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">
|
||||||
|
{t('sharePaste.navidromeShareMalformed')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{info.imageUrl && (
|
||||||
|
<div className="share-queue-preview-modal__cover">
|
||||||
|
<img src={info.imageUrl} alt="" className="share-queue-preview-modal__cover-img" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<OverlayScrollArea
|
||||||
|
className="share-queue-preview-modal__list-wrap"
|
||||||
|
viewportClassName="share-queue-preview-modal__list-viewport"
|
||||||
|
measureDeps={[info.tracks.length]}
|
||||||
|
railInset="panel"
|
||||||
|
>
|
||||||
|
<ul className="share-queue-preview-modal__list">
|
||||||
|
{info.tracks.map(track => (
|
||||||
|
<li key={track.id} className="share-queue-preview-track">
|
||||||
|
<div className="share-queue-preview-track__icon">
|
||||||
|
<Music size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="share-queue-preview-track__meta">
|
||||||
|
<div className="share-queue-preview-track__title">{track.title}</div>
|
||||||
|
<div className="share-queue-preview-track__sub">
|
||||||
|
{track.artist}{track.album ? ` · ${track.album}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{track.duration > 0 && (
|
||||||
|
<span className="share-queue-preview-track__dur">{formatTrackTime(track.duration)}</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</OverlayScrollArea>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavidromePublicShareModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
publicShareRef: shareRef,
|
||||||
|
preview,
|
||||||
|
hostLabel,
|
||||||
|
onPlay,
|
||||||
|
playBusy,
|
||||||
|
}: NavidromePublicShareModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const count = preview.navidromeShareInfo?.tracks.length ?? 0;
|
||||||
|
const title = preview.navidromeShareInfo?.description?.trim()
|
||||||
|
|| t('sharePaste.navidromeShareTitle', { count: count || 1 });
|
||||||
|
const canPlay = !!preview.navidromeShareInfo && preview.navidromeShareInfo.tracks.length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
usePlayerStore.getState().closeContextMenu();
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
const blockContextMenu = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
usePlayerStore.getState().closeContextMenu();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
document.addEventListener('contextmenu', blockContextMenu, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handler);
|
||||||
|
document.removeEventListener('contextmenu', blockContextMenu, true);
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-overlay share-queue-preview-modal-overlay"
|
||||||
|
role="presentation"
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={e => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content share-queue-preview-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="navidrome-share-preview-title"
|
||||||
|
onContextMenu={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<header className="share-queue-preview-modal__header">
|
||||||
|
<h2 id="navidrome-share-preview-title" className="share-queue-preview-modal__title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{hostLabel && (
|
||||||
|
<p className="share-queue-preview-modal__server">
|
||||||
|
{t('search.shareFromServer', { server: hostLabel })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="share-queue-preview-modal__body">
|
||||||
|
<PreviewBody preview={preview} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="share-queue-preview-modal__footer">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={() => void openUrl(shareRef.pageUrl)}
|
||||||
|
>
|
||||||
|
{t('sharePaste.openInBrowser')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canPlay || playBusy}
|
||||||
|
onClick={() => void onPlay()}
|
||||||
|
>
|
||||||
|
{playBusy ? t('sharePaste.navidromeSharePlaying') : t('sharePaste.navidromeSharePlay')}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { songToTrack } from '@/lib/media/songToTrack';
|
|||||||
import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
|
import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
|
||||||
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
|
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||||
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
|
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
|
||||||
|
import type { NavidromePublicSharePreviewState } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
|
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
|
||||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||||
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
|
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
|
||||||
@@ -15,6 +16,7 @@ import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
|
|||||||
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
|
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
|
||||||
import { useShareQueuePreview } from '@/features/search/hooks/useShareQueuePreview';
|
import { useShareQueuePreview } from '@/features/search/hooks/useShareQueuePreview';
|
||||||
import ShareQueuePreviewModal from '@/features/search/components/ShareQueuePreviewModal';
|
import ShareQueuePreviewModal from '@/features/search/components/ShareQueuePreviewModal';
|
||||||
|
import NavidromePublicShareModal from '@/features/search/components/NavidromePublicShareModal';
|
||||||
|
|
||||||
type ShareSearchResultsProps = {
|
type ShareSearchResultsProps = {
|
||||||
variant: 'desktop' | 'mobile';
|
variant: 'desktop' | 'mobile';
|
||||||
@@ -26,11 +28,12 @@ type ShareSearchResultsProps = {
|
|||||||
activeIndex?: number;
|
activeIndex?: number;
|
||||||
shareQueueBusy: boolean;
|
shareQueueBusy: boolean;
|
||||||
onEnqueue: () => void | Promise<boolean>;
|
onEnqueue: () => void | Promise<boolean>;
|
||||||
|
onPlayNavidromePublic?: () => void | Promise<boolean>;
|
||||||
onOpenAlbum: () => void;
|
onOpenAlbum: () => void;
|
||||||
onOpenArtist: () => void;
|
onOpenArtist: () => void;
|
||||||
onOpenComposer: () => void;
|
onOpenComposer: () => void;
|
||||||
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
||||||
} & ShareSearchPreviewState;
|
} & ShareSearchPreviewState & NavidromePublicSharePreviewState;
|
||||||
|
|
||||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||||
if (coverServer) {
|
if (coverServer) {
|
||||||
@@ -128,7 +131,7 @@ function withShareServer(
|
|||||||
t: TFunction,
|
t: TFunction,
|
||||||
fn: () => void,
|
fn: () => void,
|
||||||
): void {
|
): void {
|
||||||
if (shareMatch.type === 'unsupported') return;
|
if (shareMatch.type === 'unsupported' || shareMatch.type === 'navidrome-public') return;
|
||||||
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
@@ -148,6 +151,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
activeIndex = 0,
|
activeIndex = 0,
|
||||||
shareQueueBusy,
|
shareQueueBusy,
|
||||||
onEnqueue,
|
onEnqueue,
|
||||||
|
onPlayNavidromePublic,
|
||||||
onOpenAlbum,
|
onOpenAlbum,
|
||||||
onOpenArtist,
|
onOpenArtist,
|
||||||
onOpenComposer,
|
onOpenComposer,
|
||||||
@@ -164,6 +168,9 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
shareComposer,
|
shareComposer,
|
||||||
shareComposerResolving,
|
shareComposerResolving,
|
||||||
shareComposerUnavailable,
|
shareComposerUnavailable,
|
||||||
|
navidromeShareInfo,
|
||||||
|
navidromeShareResolving,
|
||||||
|
navidromeShareError,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -192,6 +199,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
|
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
|
||||||
const showEntityKindSub = !desktop || !!shareServerLabel;
|
const showEntityKindSub = !desktop || !!shareServerLabel;
|
||||||
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
|
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
|
||||||
|
const [navidromePreviewOpen, setNavidromePreviewOpen] = useState(false);
|
||||||
const queuePayload =
|
const queuePayload =
|
||||||
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
|
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
|
||||||
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
|
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
|
||||||
@@ -210,6 +218,101 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
|||||||
return wrap(unsupportedRow);
|
return wrap(unsupportedRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shareMatch.type === 'navidrome-public') {
|
||||||
|
const count = navidromeShareInfo?.tracks.length ?? 0;
|
||||||
|
const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row';
|
||||||
|
const title = navidromeShareInfo?.description?.trim()
|
||||||
|
|| t('sharePaste.navidromeShareTitle', { count: count || 1 });
|
||||||
|
|
||||||
|
if (navidromeShareResolving) {
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{t('common.loading')}</div>
|
||||||
|
<div className={subCls}>{sub(t('sharePaste.navidromeShareLoading'))}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navidromeShareError) {
|
||||||
|
const errMsg =
|
||||||
|
navidromeShareError === 'not-found'
|
||||||
|
? t('sharePaste.navidromeShareNotFound')
|
||||||
|
: navidromeShareError === 'expired'
|
||||||
|
? t('sharePaste.navidromeShareExpired')
|
||||||
|
: navidromeShareError === 'unreachable'
|
||||||
|
? t('sharePaste.navidromeShareUnreachable')
|
||||||
|
: t('sharePaste.navidromeShareMalformed');
|
||||||
|
return wrap(
|
||||||
|
<div className={mutedCls}>
|
||||||
|
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{errMsg}</div>
|
||||||
|
<div className={subCls}>{sub('')}</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navidromeShareInfo) {
|
||||||
|
const handlePlay = () => {
|
||||||
|
void Promise.resolve(onPlayNavidromePublic?.()).then(ok => {
|
||||||
|
if (ok) setNavidromePreviewOpen(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{wrap(
|
||||||
|
<div
|
||||||
|
className={`${rowCls}${activeIndex === 0 ? ' active' : ''}`}
|
||||||
|
role={desktop ? 'option' : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={desktop ? 'search-share-queue-main' : 'mobile-search-item search-share-queue-main'}
|
||||||
|
onClick={handlePlay}
|
||||||
|
disabled={shareQueueBusy}
|
||||||
|
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||||
|
>
|
||||||
|
<StaticIcon className={iconCls}><ListPlus size={desktop ? 14 : 20} /></StaticIcon>
|
||||||
|
<div className={infoWrap}>
|
||||||
|
<div className={nameCls}>{title}</div>
|
||||||
|
<div className={subCls}>
|
||||||
|
{shareQueueBusy
|
||||||
|
? sub(t('sharePaste.navidromeSharePlaying'))
|
||||||
|
: sub(t('search.shareQueueTitle', { count }))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-share-queue-preview-btn"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setNavidromePreviewOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={t('sharePaste.navidromeSharePreview')}
|
||||||
|
>
|
||||||
|
<Eye size={desktop ? 16 : 18} />
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
)}
|
||||||
|
<NavidromePublicShareModal
|
||||||
|
open={navidromePreviewOpen}
|
||||||
|
onClose={() => setNavidromePreviewOpen(false)}
|
||||||
|
publicShareRef={shareMatch.publicShareRef}
|
||||||
|
preview={{ navidromeShareInfo, navidromeShareResolving, navidromeShareError }}
|
||||||
|
hostLabel={shareServerLabel}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
playBusy={shareQueueBusy}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (shareMatch.type === 'artist') {
|
if (shareMatch.type === 'artist') {
|
||||||
if (shareArtistResolving) {
|
if (shareArtistResolving) {
|
||||||
return wrap(
|
return wrap(
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { fetchNavidromePublicShare } from '@/lib/share/fetchNavidromePublicShare';
|
||||||
|
import type { FetchNavidromePublicShareError, NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
|
||||||
|
export type NavidromePublicSharePreviewState = {
|
||||||
|
navidromeShareInfo: NavidromePublicShareInfo | null;
|
||||||
|
navidromeShareResolving: boolean;
|
||||||
|
navidromeShareError: FetchNavidromePublicShareError | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY: NavidromePublicSharePreviewState = {
|
||||||
|
navidromeShareInfo: null,
|
||||||
|
navidromeShareResolving: false,
|
||||||
|
navidromeShareError: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNavidromePublicSharePreview(
|
||||||
|
ref: NavidromePublicShareRef | null,
|
||||||
|
enabled = true,
|
||||||
|
): NavidromePublicSharePreviewState {
|
||||||
|
const [state, setState] = useState<NavidromePublicSharePreviewState>(EMPTY);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !ref) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setState(EMPTY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setState({ ...EMPTY, navidromeShareResolving: true });
|
||||||
|
|
||||||
|
void fetchNavidromePublicShare(ref)
|
||||||
|
.then(result => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.type === 'ok') {
|
||||||
|
setState({
|
||||||
|
navidromeShareInfo: result.info,
|
||||||
|
navidromeShareResolving: false,
|
||||||
|
navidromeShareError: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState({
|
||||||
|
navidromeShareInfo: null,
|
||||||
|
navidromeShareResolving: false,
|
||||||
|
navidromeShareError: result.reason,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setState({
|
||||||
|
navidromeShareInfo: null,
|
||||||
|
navidromeShareResolving: false,
|
||||||
|
navidromeShareError: 'unreachable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [enabled, ref]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ import { shareServerOriginLabel } from '@/lib/share/shareServerOriginLabel';
|
|||||||
import { parseShareSearchText } from '@/lib/share/shareSearch';
|
import { parseShareSearchText } from '@/lib/share/shareSearch';
|
||||||
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
||||||
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
|
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
|
||||||
|
import { useNavidromePublicSharePreview } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||||
|
import { playNavidromePublicShare } from '@/features/share';
|
||||||
|
|
||||||
export function useShareSearch(query: string, onSuccess?: () => void) {
|
export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -26,7 +28,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
[shareMatch, servers, activeServerId],
|
[shareMatch, servers, activeServerId],
|
||||||
);
|
);
|
||||||
const shareCoverServer = useMemo((): ServerProfile | null => {
|
const shareCoverServer = useMemo((): ServerProfile | null => {
|
||||||
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
if (!shareMatch || shareMatch.type === 'unsupported' || shareMatch.type === 'navidrome-public') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||||
if (!serverId || serverId === activeServerId) return null;
|
if (!serverId || serverId === activeServerId) return null;
|
||||||
return servers.find(s => s.id === serverId)
|
return servers.find(s => s.id === serverId)
|
||||||
@@ -34,6 +38,8 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
?? null;
|
?? null;
|
||||||
}, [shareMatch, servers, activeServerId]);
|
}, [shareMatch, servers, activeServerId]);
|
||||||
const preview = useShareSearchPreview(shareMatch);
|
const preview = useShareSearchPreview(shareMatch);
|
||||||
|
const navidromeRef = shareMatch?.type === 'navidrome-public' ? shareMatch.publicShareRef : null;
|
||||||
|
const navidromePreview = useNavidromePublicSharePreview(navidromeRef);
|
||||||
const [shareQueueBusy, setShareQueueBusy] = useState(false);
|
const [shareQueueBusy, setShareQueueBusy] = useState(false);
|
||||||
|
|
||||||
const canQueueShareMatch =
|
const canQueueShareMatch =
|
||||||
@@ -41,6 +47,11 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
(shareMatch.payload.k === 'queue' ||
|
(shareMatch.payload.k === 'queue' ||
|
||||||
(!preview.shareTrackResolving && !!preview.shareTrackSong));
|
(!preview.shareTrackResolving && !!preview.shareTrackSong));
|
||||||
|
|
||||||
|
const canPlayNavidromePublic =
|
||||||
|
shareMatch?.type === 'navidrome-public' &&
|
||||||
|
!!navidromePreview.navidromeShareInfo &&
|
||||||
|
!navidromePreview.navidromeShareResolving;
|
||||||
|
|
||||||
const canOpenShareAlbum =
|
const canOpenShareAlbum =
|
||||||
shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving;
|
shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving;
|
||||||
const canOpenShareArtist =
|
const canOpenShareArtist =
|
||||||
@@ -49,7 +60,11 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving;
|
shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving;
|
||||||
|
|
||||||
const hasShareKeyboardTarget =
|
const hasShareKeyboardTarget =
|
||||||
canQueueShareMatch || canOpenShareAlbum || canOpenShareArtist || canOpenShareComposer;
|
canQueueShareMatch ||
|
||||||
|
canPlayNavidromePublic ||
|
||||||
|
canOpenShareAlbum ||
|
||||||
|
canOpenShareArtist ||
|
||||||
|
canOpenShareComposer;
|
||||||
|
|
||||||
const openShareAlbum = useCallback(() => {
|
const openShareAlbum = useCallback(() => {
|
||||||
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
|
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
|
||||||
@@ -72,6 +87,25 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
}, [shareMatch, preview.shareComposer, navigate, t, onSuccess]);
|
}, [shareMatch, preview.shareComposer, navigate, t, onSuccess]);
|
||||||
|
|
||||||
|
const playNavidromePublic = useCallback(async () => {
|
||||||
|
if (
|
||||||
|
shareMatch?.type !== 'navidrome-public' ||
|
||||||
|
!navidromePreview.navidromeShareInfo ||
|
||||||
|
shareQueueBusy
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setShareQueueBusy(true);
|
||||||
|
const ok = await playNavidromePublicShare(
|
||||||
|
shareMatch.publicShareRef,
|
||||||
|
navidromePreview.navidromeShareInfo,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
setShareQueueBusy(false);
|
||||||
|
if (ok) onSuccess?.();
|
||||||
|
return ok;
|
||||||
|
}, [shareMatch, navidromePreview.navidromeShareInfo, shareQueueBusy, t, onSuccess]);
|
||||||
|
|
||||||
const enqueueShareMatch = useCallback(async () => {
|
const enqueueShareMatch = useCallback(async () => {
|
||||||
if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false;
|
if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false;
|
||||||
if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) {
|
if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) {
|
||||||
@@ -90,6 +124,7 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
shareCoverServer,
|
shareCoverServer,
|
||||||
shareQueueBusy,
|
shareQueueBusy,
|
||||||
canQueueShareMatch,
|
canQueueShareMatch,
|
||||||
|
canPlayNavidromePublic,
|
||||||
canOpenShareAlbum,
|
canOpenShareAlbum,
|
||||||
canOpenShareArtist,
|
canOpenShareArtist,
|
||||||
canOpenShareComposer,
|
canOpenShareComposer,
|
||||||
@@ -98,6 +133,8 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
|||||||
openShareArtist,
|
openShareArtist,
|
||||||
openShareComposer,
|
openShareComposer,
|
||||||
enqueueShareMatch,
|
enqueueShareMatch,
|
||||||
|
playNavidromePublic,
|
||||||
...preview,
|
...preview,
|
||||||
|
...navidromePreview,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,7 @@
|
|||||||
export { default as LiveSearch } from './components/LiveSearch';
|
export { default as LiveSearch } from './components/LiveSearch';
|
||||||
export { default as MobileSearchOverlay } from './components/MobileSearchOverlay';
|
export { default as MobileSearchOverlay } from './components/MobileSearchOverlay';
|
||||||
export { default as ShareQueuePreviewModal } from './components/ShareQueuePreviewModal';
|
export { default as ShareQueuePreviewModal } from './components/ShareQueuePreviewModal';
|
||||||
|
export { default as NavidromePublicShareModal } from './components/NavidromePublicShareModal';
|
||||||
export { useLiveSearchRouteScope } from './hooks/useLiveSearchRouteScope';
|
export { useLiveSearchRouteScope } from './hooks/useLiveSearchRouteScope';
|
||||||
export { useShareQueuePreview } from './hooks/useShareQueuePreview';
|
export { useShareQueuePreview } from './hooks/useShareQueuePreview';
|
||||||
|
export { useNavidromePublicSharePreview } from './hooks/useNavidromePublicSharePreview';
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import type { EntitySharePayloadV1 } from '@/lib/share/shareLink';
|
import { extractNavidromePublicShareFromText } from '@/lib/share/navidromePublicShareUrl';
|
||||||
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
|
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
|
||||||
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
|
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
|
||||||
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
|
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
|
||||||
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
|
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
|
||||||
import { showToast } from '@/lib/dom/toast';
|
import { showToast } from '@/lib/dom/toast';
|
||||||
import { useShareQueuePreview } from '@/features/search';
|
import { useShareQueuePreview, ShareQueuePreviewModal, NavidromePublicShareModal, useNavidromePublicSharePreview } from '@/features/search';
|
||||||
import { ShareQueuePreviewModal } from '@/features/search';
|
import { playNavidromePublicShare } from '@/features/share';
|
||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
import {
|
import {
|
||||||
parseOrbitShareLink,
|
parseOrbitShareLink,
|
||||||
joinOrbitSession,
|
joinOrbitSession,
|
||||||
@@ -34,7 +35,10 @@ const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
|||||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||||
*/
|
*/
|
||||||
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
type QueuePastePayload = Extract<
|
||||||
|
NonNullable<ReturnType<typeof decodeSharePayloadFromText>>,
|
||||||
|
{ k: 'queue' }
|
||||||
|
>;
|
||||||
|
|
||||||
export default function PasteClipboardHandler() {
|
export default function PasteClipboardHandler() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -47,8 +51,11 @@ export default function PasteClipboardHandler() {
|
|||||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
||||||
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
|
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
|
||||||
|
const [navidromePaste, setNavidromePaste] = useState<NavidromePublicShareRef | null>(null);
|
||||||
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
|
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
|
||||||
|
const [navidromePasteBusy, setNavidromePasteBusy] = useState(false);
|
||||||
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
|
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
|
||||||
|
const navidromePreview = useNavidromePublicSharePreview(navidromePaste, !!navidromePaste);
|
||||||
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
|
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
|
||||||
() =>
|
() =>
|
||||||
queuePaste
|
queuePaste
|
||||||
@@ -148,6 +155,15 @@ export default function PasteClipboardHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navidromeShare = extractNavidromePublicShareFromText(text);
|
||||||
|
if (navidromeShare) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (busy.current || queuePaste || navidromePaste) return;
|
||||||
|
setNavidromePaste(navidromeShare);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const share = decodeSharePayloadFromText(text);
|
const share = decodeSharePayloadFromText(text);
|
||||||
if (share) {
|
if (share) {
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
@@ -202,13 +218,18 @@ export default function PasteClipboardHandler() {
|
|||||||
// the global paste listener is intentionally not re-registered on every render
|
// the global paste listener is intentionally not re-registered on every render
|
||||||
// or navigation, only when the auth/handler inputs below change.
|
// or navigation, only when the auth/handler inputs below change.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [navigate, t, isLoggedIn, queuePaste]);
|
}, [navigate, t, isLoggedIn, queuePaste, navidromePaste]);
|
||||||
|
|
||||||
const closeQueuePaste = () => {
|
const closeQueuePaste = () => {
|
||||||
if (queuePasteBusy) return;
|
if (queuePasteBusy) return;
|
||||||
setQueuePaste(null);
|
setQueuePaste(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeNavidromePaste = () => {
|
||||||
|
if (navidromePasteBusy) return;
|
||||||
|
setNavidromePaste(null);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmQueuePaste = async () => {
|
const confirmQueuePaste = async () => {
|
||||||
if (!queuePaste || queuePasteBusy) return;
|
if (!queuePaste || queuePasteBusy) return;
|
||||||
setQueuePasteBusy(true);
|
setQueuePasteBusy(true);
|
||||||
@@ -217,8 +238,40 @@ export default function PasteClipboardHandler() {
|
|||||||
if (ok) setQueuePaste(null);
|
if (ok) setQueuePaste(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmNavidromePaste = async () => {
|
||||||
|
if (!navidromePaste || navidromePasteBusy || !navidromePreview.navidromeShareInfo) return;
|
||||||
|
setNavidromePasteBusy(true);
|
||||||
|
const ok = await playNavidromePublicShare(
|
||||||
|
navidromePaste,
|
||||||
|
navidromePreview.navidromeShareInfo,
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
setNavidromePasteBusy(false);
|
||||||
|
if (ok) setNavidromePaste(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const navidromeHostLabel = useMemo(() => {
|
||||||
|
if (!navidromePaste) return null;
|
||||||
|
try {
|
||||||
|
return new URL(navidromePaste.origin).host;
|
||||||
|
} catch {
|
||||||
|
return navidromePaste.origin;
|
||||||
|
}
|
||||||
|
}, [navidromePaste]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{navidromePaste && (
|
||||||
|
<NavidromePublicShareModal
|
||||||
|
open
|
||||||
|
onClose={closeNavidromePaste}
|
||||||
|
publicShareRef={navidromePaste}
|
||||||
|
preview={navidromePreview}
|
||||||
|
hostLabel={navidromeHostLabel}
|
||||||
|
onPlay={() => void confirmNavidromePaste()}
|
||||||
|
playBusy={navidromePasteBusy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{queuePaste && (
|
{queuePaste && (
|
||||||
<ShareQueuePreviewModal
|
<ShareQueuePreviewModal
|
||||||
open
|
open
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
*/
|
*/
|
||||||
export * from './applySharePaste';
|
export * from './applySharePaste';
|
||||||
export * from './enqueueShareSearchPayload';
|
export * from './enqueueShareSearchPayload';
|
||||||
|
export * from './playNavidromePublicShare';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
import type { NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||||
|
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID, navidromePublicShareToTracks } from '@/lib/share/navidromePublicSharePlayback';
|
||||||
|
import { seedQueueResolver, usePlayerStore } from '@/features/playback';
|
||||||
|
import { showToast } from '@/lib/dom/toast';
|
||||||
|
|
||||||
|
export async function playNavidromePublicShare(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
info: NavidromePublicShareInfo,
|
||||||
|
t: TFunction,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const tracks = navidromePublicShareToTracks(ref, info);
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
usePlayerStore.getState().clearQueue();
|
||||||
|
seedQueueResolver(NAVIDROME_PUBLIC_SHARE_SERVER_ID, tracks);
|
||||||
|
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
|
||||||
|
showToast(t('sharePaste.openedNavidromePublic', { count: tracks.length }), 3000, 'info');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ export interface Track {
|
|||||||
* end of the current Play-Next streak. Stale flags behind queueIndex are
|
* end of the current Play-Next streak. Stale flags behind queueIndex are
|
||||||
* harmless — the streak scan only looks forward from queueIndex+1. */
|
* harmless — the streak scan only looks forward from queueIndex+1. */
|
||||||
playNextAdded?: boolean;
|
playNextAdded?: boolean;
|
||||||
|
/** Absolute HTTP(S) stream URL — bypasses Subsonic `stream.view` (Navidrome public share). */
|
||||||
|
directStreamUrl?: string;
|
||||||
|
/** Absolute cover art URL — bypasses Subsonic `getCoverArt` (Navidrome public share). */
|
||||||
|
directCoverArtUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,4 +61,8 @@ export interface QueueItemRef {
|
|||||||
autoAdded?: boolean;
|
autoAdded?: boolean;
|
||||||
radioAdded?: boolean;
|
radioAdded?: boolean;
|
||||||
playNextAdded?: boolean;
|
playNextAdded?: boolean;
|
||||||
|
/** Persisted for Navidrome public share restore (JWT stream URL). */
|
||||||
|
directStreamUrl?: string;
|
||||||
|
/** Persisted for Navidrome public share restore (JWT cover URL). */
|
||||||
|
directCoverArtUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
fetchNavidromePublicShare,
|
||||||
|
parseShareInfoFromHtml,
|
||||||
|
parseShareInfoFromM3u,
|
||||||
|
} from '@/lib/share/fetchNavidromePublicShare';
|
||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
|
||||||
|
const ref: NavidromePublicShareRef = {
|
||||||
|
pageUrl: 'https://music.example.com/share/Ab12Cd34Ef',
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '',
|
||||||
|
shareId: 'Ab12Cd34Ef',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareInfoJson = {
|
||||||
|
id: 'Ab12Cd34Ef',
|
||||||
|
description: 'Weekend mix',
|
||||||
|
downloadable: true,
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'stream-jwt-1',
|
||||||
|
title: 'Track One',
|
||||||
|
artist: 'Artist A',
|
||||||
|
album: 'Album X',
|
||||||
|
duration: 245,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseShareInfoFromHtml', () => {
|
||||||
|
it('extracts __SHARE_INFO__ JSON from HTML', () => {
|
||||||
|
const html = `<html><script>window.__SHARE_INFO__ = ${JSON.stringify(shareInfoJson)}</script></html>`;
|
||||||
|
expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts Navidrome string-assigned __SHARE_INFO__ (escaped JSON in JS quotes)', () => {
|
||||||
|
const html = `<html><script>window.__SHARE_INFO__ = ${JSON.stringify(JSON.stringify(shareInfoJson))}</script></html>`;
|
||||||
|
expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses real Navidrome v0.63 HTML assignment shape', () => {
|
||||||
|
const html = `<script>window.__SHARE_INFO__ = "{\\"id\\":\\"m4dSzkJhZc\\",\\"description\\":\\"testo\\",\\"downloadable\\":false,\\"tracks\\":[{\\"id\\":\\"jwt\\",\\"title\\":\\"Обман\\",\\"artist\\":\\"Ария\\",\\"album\\":\\"Колизей\\",\\"duration\\":310.15}]}"</script>`;
|
||||||
|
expect(parseShareInfoFromHtml(html)).toEqual({
|
||||||
|
id: 'm4dSzkJhZc',
|
||||||
|
description: 'testo',
|
||||||
|
downloadable: false,
|
||||||
|
tracks: [{
|
||||||
|
id: 'jwt',
|
||||||
|
title: 'Обман',
|
||||||
|
artist: 'Ария',
|
||||||
|
album: 'Колизей',
|
||||||
|
duration: 310.15,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when marker is missing', () => {
|
||||||
|
expect(parseShareInfoFromHtml('<html></html>')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseShareInfoFromM3u', () => {
|
||||||
|
it('parses extended m3u entries', () => {
|
||||||
|
const body = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF:245,Track One',
|
||||||
|
'https://music.example.com/share/s/stream-jwt-1',
|
||||||
|
].join('\n');
|
||||||
|
expect(parseShareInfoFromM3u(body)).toEqual([
|
||||||
|
{
|
||||||
|
id: 'stream-jwt-1',
|
||||||
|
title: 'Track One',
|
||||||
|
artist: '',
|
||||||
|
album: '',
|
||||||
|
duration: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchNavidromePublicShare', () => {
|
||||||
|
it('maps 404 to not-found', async () => {
|
||||||
|
const result = await fetchNavidromePublicShare(ref, async () => new Response('', { status: 404 }));
|
||||||
|
expect(result).toEqual({ type: 'error', reason: 'not-found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps 410 to expired', async () => {
|
||||||
|
const result = await fetchNavidromePublicShare(ref, async () => new Response('', { status: 410 }));
|
||||||
|
expect(result).toEqual({ type: 'error', reason: 'expired' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns parsed HTML share info', async () => {
|
||||||
|
const html = `<html><meta property="og:image" content="https://music.example.com/cover.jpg"><script>window.__SHARE_INFO__ = ${JSON.stringify(shareInfoJson)}</script></html>`;
|
||||||
|
const result = await fetchNavidromePublicShare(ref, async () => new Response(html, { status: 200 }));
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: 'ok',
|
||||||
|
info: {
|
||||||
|
...shareInfoJson,
|
||||||
|
imageUrl: 'https://music.example.com/share/img/stream-jwt-1?size=300',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to m3u when HTML has no share info', async () => {
|
||||||
|
const m3u = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXTINF:120,Fallback Track',
|
||||||
|
'https://music.example.com/share/s/token-2',
|
||||||
|
].join('\n');
|
||||||
|
const fetchImpl = async (url: string) => {
|
||||||
|
if (url.endsWith('/m3u')) return new Response(m3u, { status: 200 });
|
||||||
|
return new Response('<html></html>', { status: 200 });
|
||||||
|
};
|
||||||
|
const result = await fetchNavidromePublicShare(ref, fetchImpl);
|
||||||
|
expect(result.type).toBe('ok');
|
||||||
|
if (result.type === 'ok') {
|
||||||
|
expect(result.info.tracks).toHaveLength(1);
|
||||||
|
expect(result.info.tracks[0]?.title).toBe('Fallback Track');
|
||||||
|
expect(result.info.imageUrl).toBe('https://music.example.com/share/img/token-2?size=300');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
import {
|
||||||
|
buildNavidromePublicShareM3uUrl,
|
||||||
|
buildNavidromePublicCoverUrl,
|
||||||
|
} from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
import type {
|
||||||
|
FetchNavidromePublicShareResult,
|
||||||
|
NavidromePublicShareInfo,
|
||||||
|
NavidromePublicShareTrack,
|
||||||
|
} from '@/lib/share/navidromePublicShareTypes';
|
||||||
|
|
||||||
|
export type FetchNavidromePublicShareFn = (
|
||||||
|
url: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
) => Promise<Response>;
|
||||||
|
|
||||||
|
const defaultFetch: FetchNavidromePublicShareFn = (url, init) => fetch(url, init);
|
||||||
|
|
||||||
|
function parseShareInfoJson(raw: unknown): NavidromePublicShareInfo | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const obj = raw as Record<string, unknown>;
|
||||||
|
const id = typeof obj.id === 'string' ? obj.id.trim() : '';
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const description = typeof obj.description === 'string' ? obj.description : '';
|
||||||
|
const downloadable = obj.downloadable === true;
|
||||||
|
const tracksRaw = obj.tracks;
|
||||||
|
if (!Array.isArray(tracksRaw) || tracksRaw.length === 0) return null;
|
||||||
|
|
||||||
|
const tracks: NavidromePublicShareTrack[] = [];
|
||||||
|
for (const row of tracksRaw) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const t = row as Record<string, unknown>;
|
||||||
|
const token = typeof t.id === 'string' ? t.id.trim() : '';
|
||||||
|
const title = typeof t.title === 'string' ? t.title : '';
|
||||||
|
if (!token || !title) continue;
|
||||||
|
tracks.push({
|
||||||
|
id: token,
|
||||||
|
title,
|
||||||
|
artist: typeof t.artist === 'string' ? t.artist : '',
|
||||||
|
album: typeof t.album === 'string' ? t.album : '',
|
||||||
|
duration: typeof t.duration === 'number' && Number.isFinite(t.duration) ? t.duration : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.length === 0) return null;
|
||||||
|
return { id, description, downloadable, tracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navidrome assigns `window.__SHARE_INFO__ = "{...json...}"` (escaped JSON in JS quotes). */
|
||||||
|
function decodeJsStringLiteral(raw: string): string {
|
||||||
|
return raw.replace(/\\(.)/g, (_, ch: string) => {
|
||||||
|
switch (ch) {
|
||||||
|
case '"':
|
||||||
|
return '"';
|
||||||
|
case '\\':
|
||||||
|
return '\\';
|
||||||
|
case 'n':
|
||||||
|
return '\n';
|
||||||
|
case 'r':
|
||||||
|
return '\r';
|
||||||
|
case 't':
|
||||||
|
return '\t';
|
||||||
|
default:
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShareInfoFromJsonText(jsonText: string): NavidromePublicShareInfo | null {
|
||||||
|
try {
|
||||||
|
return parseShareInfoJson(JSON.parse(jsonText));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShareInfoFromHtml(html: string): NavidromePublicShareInfo | null {
|
||||||
|
const marker = 'window.__SHARE_INFO__';
|
||||||
|
const idx = html.indexOf(marker);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
|
||||||
|
const afterMarker = html.slice(idx + marker.length);
|
||||||
|
|
||||||
|
const stringAssign = afterMarker.match(/^\s*=\s*"((?:\\.|[^"\\])*)"/);
|
||||||
|
if (stringAssign?.[1]) {
|
||||||
|
const fromString = parseShareInfoFromJsonText(decodeJsStringLiteral(stringAssign[1]));
|
||||||
|
if (fromString) return fromString;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = afterMarker.indexOf('{');
|
||||||
|
if (start < 0) return null;
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let inString = false;
|
||||||
|
let escape = false;
|
||||||
|
for (let i = start; i < afterMarker.length; i++) {
|
||||||
|
const ch = afterMarker[i]!;
|
||||||
|
if (inString) {
|
||||||
|
if (escape) {
|
||||||
|
escape = false;
|
||||||
|
} else if (ch === '\\') {
|
||||||
|
escape = true;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (ch === '{') depth++;
|
||||||
|
if (ch === '}') {
|
||||||
|
depth--;
|
||||||
|
if (depth === 0) {
|
||||||
|
const jsonText = afterMarker.slice(start, i + 1);
|
||||||
|
return parseShareInfoFromJsonText(jsonText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseShareInfoFromM3u(body: string): NavidromePublicShareTrack[] {
|
||||||
|
const lines = body.split(/\r?\n/);
|
||||||
|
const tracks: NavidromePublicShareTrack[] = [];
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]!.trim();
|
||||||
|
if (!line.startsWith('#EXTINF:')) continue;
|
||||||
|
const meta = line.slice('#EXTINF:'.length);
|
||||||
|
const comma = meta.lastIndexOf(',');
|
||||||
|
const title = comma >= 0 ? meta.slice(comma + 1).trim() : meta.trim();
|
||||||
|
const urlLine = lines.slice(i + 1).find(l => l.trim() && !l.trim().startsWith('#'))?.trim();
|
||||||
|
if (!urlLine || !title) continue;
|
||||||
|
const token = urlLine.split('/').pop() ?? '';
|
||||||
|
if (!token) continue;
|
||||||
|
tracks.push({
|
||||||
|
id: token,
|
||||||
|
title,
|
||||||
|
artist: '',
|
||||||
|
album: '',
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapHttpStatus(status: number): FetchNavidromePublicShareResult {
|
||||||
|
if (status === 404) return { type: 'error', reason: 'not-found' };
|
||||||
|
if (status === 410) return { type: 'error', reason: 'expired' };
|
||||||
|
return { type: 'error', reason: 'malformed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOgImage(html: string): string | undefined {
|
||||||
|
const m = html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/i);
|
||||||
|
return m?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharePreviewImageUrl(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
info: NavidromePublicShareInfo,
|
||||||
|
ogImage?: string,
|
||||||
|
): string | undefined {
|
||||||
|
const firstTrack = info.tracks[0];
|
||||||
|
if (firstTrack) return buildNavidromePublicCoverUrl(ref, firstTrack.id);
|
||||||
|
return ogImage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNavidromePublicShare(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
fetchImpl: FetchNavidromePublicShareFn = defaultFetch,
|
||||||
|
): Promise<FetchNavidromePublicShareResult> {
|
||||||
|
let htmlResp: Response;
|
||||||
|
try {
|
||||||
|
htmlResp = await fetchImpl(ref.pageUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/html,*/*' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { type: 'error', reason: 'unreachable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (htmlResp.status === 404) return { type: 'error', reason: 'not-found' };
|
||||||
|
if (htmlResp.status === 410) return { type: 'error', reason: 'expired' };
|
||||||
|
if (!htmlResp.ok) return mapHttpStatus(htmlResp.status);
|
||||||
|
|
||||||
|
const html = await htmlResp.text();
|
||||||
|
const fromHtml = parseShareInfoFromHtml(html);
|
||||||
|
if (fromHtml) {
|
||||||
|
const ogImage = parseOgImage(html);
|
||||||
|
return {
|
||||||
|
type: 'ok',
|
||||||
|
info: {
|
||||||
|
...fromHtml,
|
||||||
|
imageUrl: sharePreviewImageUrl(ref, fromHtml, ogImage),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const m3uResp = await fetchImpl(buildNavidromePublicShareM3uUrl(ref), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'audio/x-mpegurl,*/*' },
|
||||||
|
});
|
||||||
|
if (m3uResp.status === 404) return { type: 'error', reason: 'not-found' };
|
||||||
|
if (m3uResp.status === 410) return { type: 'error', reason: 'expired' };
|
||||||
|
if (!m3uResp.ok) return mapHttpStatus(m3uResp.status);
|
||||||
|
|
||||||
|
const tracks = parseShareInfoFromM3u(await m3uResp.text());
|
||||||
|
if (tracks.length === 0) return { type: 'error', reason: 'malformed' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'ok',
|
||||||
|
info: {
|
||||||
|
id: ref.shareId,
|
||||||
|
description: '',
|
||||||
|
downloadable: false,
|
||||||
|
tracks,
|
||||||
|
imageUrl: sharePreviewImageUrl(ref, { id: ref.shareId, description: '', downloadable: false, tracks }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { type: 'error', reason: 'unreachable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
isActivePublicShareQueue,
|
||||||
|
isPublicSharePersistedTrack,
|
||||||
|
isPublicShareTrackId,
|
||||||
|
} from '@/lib/share/navidromePublicSharePlayback';
|
||||||
|
|
||||||
|
describe('isActivePublicShareQueue', () => {
|
||||||
|
it('detects share queue by queueServerId', () => {
|
||||||
|
expect(isActivePublicShareQueue('navidrome-public-share', [
|
||||||
|
{ serverId: 'navidrome-public-share', trackId: 'ndshare:abc:0' },
|
||||||
|
])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects ndshare refs on another queueServerId', () => {
|
||||||
|
expect(isActivePublicShareQueue('music.test', [{
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
trackId: 'ndshare:abc:0',
|
||||||
|
}])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for a normal server queue', () => {
|
||||||
|
expect(isActivePublicShareQueue('music.test', [
|
||||||
|
{ serverId: 'music.test', trackId: 'real-track-id' },
|
||||||
|
])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for an empty queue', () => {
|
||||||
|
expect(isActivePublicShareQueue('navidrome-public-share', [])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPublicShareTrackId', () => {
|
||||||
|
it('matches ndshare synthetic ids', () => {
|
||||||
|
expect(isPublicShareTrackId('ndshare:Ab12:0')).toBe(true);
|
||||||
|
expect(isPublicShareTrackId('real-id')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPublicSharePersistedTrack', () => {
|
||||||
|
it('matches share currentTrack blobs', () => {
|
||||||
|
expect(isPublicSharePersistedTrack({
|
||||||
|
id: 'ndshare:abc:0',
|
||||||
|
title: 't',
|
||||||
|
artist: 'a',
|
||||||
|
album: 'al',
|
||||||
|
albumId: '',
|
||||||
|
duration: 1,
|
||||||
|
serverId: 'navidrome-public-share',
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Track } from '@/lib/media/trackTypes';
|
||||||
|
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||||
|
import {
|
||||||
|
buildNavidromePublicStreamUrl,
|
||||||
|
buildNavidromePublicCoverUrl,
|
||||||
|
type NavidromePublicShareRef,
|
||||||
|
} from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
import type { NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||||
|
|
||||||
|
/** Synthetic queue server bucket for anonymous Navidrome public shares. */
|
||||||
|
export const NAVIDROME_PUBLIC_SHARE_SERVER_ID = 'navidrome-public-share';
|
||||||
|
|
||||||
|
export function isPublicShareTrackId(trackId: string | null | undefined): boolean {
|
||||||
|
return typeof trackId === 'string' && trackId.startsWith('ndshare:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navidromePublicShareToTracks(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
info: NavidromePublicShareInfo,
|
||||||
|
): Track[] {
|
||||||
|
return info.tracks.map((t, index) => ({
|
||||||
|
id: `ndshare:${info.id}:${index}`,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
album: t.album,
|
||||||
|
albumId: '',
|
||||||
|
duration: t.duration,
|
||||||
|
serverId: NAVIDROME_PUBLIC_SHARE_SERVER_ID,
|
||||||
|
directStreamUrl: buildNavidromePublicStreamUrl(ref, t.id),
|
||||||
|
directCoverArtUrl: buildNavidromePublicCoverUrl(ref, t.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while a Navidrome public share queue is live in this session. */
|
||||||
|
export function isActivePublicShareQueue(
|
||||||
|
queueServerId: string | null | undefined,
|
||||||
|
queueItems: QueueItemRef[],
|
||||||
|
): boolean {
|
||||||
|
if (queueItems.length === 0) return false;
|
||||||
|
if (queueServerId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) return true;
|
||||||
|
return queueItems.some(
|
||||||
|
r => r.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(r.trackId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPublicSharePersistedTrack(track: Track | null | undefined): boolean {
|
||||||
|
if (!track) return false;
|
||||||
|
return track.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(track.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
export type NavidromePublicShareTrack = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NavidromePublicShareInfo = {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
downloadable: boolean;
|
||||||
|
tracks: NavidromePublicShareTrack[];
|
||||||
|
imageUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FetchNavidromePublicShareError =
|
||||||
|
| 'not-found'
|
||||||
|
| 'expired'
|
||||||
|
| 'unreachable'
|
||||||
|
| 'malformed';
|
||||||
|
|
||||||
|
export type FetchNavidromePublicShareResult =
|
||||||
|
| { type: 'ok'; info: NavidromePublicShareInfo }
|
||||||
|
| { type: 'error'; reason: FetchNavidromePublicShareError };
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildNavidromePublicCoverUrl,
|
||||||
|
buildNavidromePublicShareM3uUrl,
|
||||||
|
buildNavidromePublicStreamUrl,
|
||||||
|
extractNavidromePublicShareFromText,
|
||||||
|
parseNavidromePublicShareUrl,
|
||||||
|
} from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
|
||||||
|
const SHARE_ID = 'Ab12Cd34Ef';
|
||||||
|
|
||||||
|
describe('parseNavidromePublicShareUrl', () => {
|
||||||
|
it('parses a root-level share URL', () => {
|
||||||
|
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/${SHARE_ID}`)).toEqual({
|
||||||
|
pageUrl: `https://music.example.com/share/${SHARE_ID}`,
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '',
|
||||||
|
shareId: SHARE_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses a share URL with base path', () => {
|
||||||
|
expect(parseNavidromePublicShareUrl(`https://music.example.com/navidrome/share/${SHARE_ID}/`)).toEqual({
|
||||||
|
pageUrl: `https://music.example.com/navidrome/share/${SHARE_ID}`,
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '/navidrome',
|
||||||
|
shareId: SHARE_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects stream token URLs', () => {
|
||||||
|
expect(parseNavidromePublicShareUrl('https://music.example.com/share/s/jwt.token.here')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects m3u and image paths', () => {
|
||||||
|
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/${SHARE_ID}/m3u`)).toBeNull();
|
||||||
|
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/img/${SHARE_ID}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid share ids', () => {
|
||||||
|
expect(parseNavidromePublicShareUrl('https://music.example.com/share/tooshort')).toBeNull();
|
||||||
|
expect(parseNavidromePublicShareUrl('https://music.example.com/share/12345678901')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractNavidromePublicShareFromText', () => {
|
||||||
|
it('finds a share URL embedded in text', () => {
|
||||||
|
const url = `https://music.example.com/share/${SHARE_ID}`;
|
||||||
|
expect(extractNavidromePublicShareFromText(`check this out: ${url}.`)).toEqual({
|
||||||
|
pageUrl: url,
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '',
|
||||||
|
shareId: SHARE_ID,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stream and m3u URL builders', () => {
|
||||||
|
const ref = {
|
||||||
|
pageUrl: `https://music.example.com/navidrome/share/${SHARE_ID}`,
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '/navidrome',
|
||||||
|
shareId: SHARE_ID,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('builds public stream URLs', () => {
|
||||||
|
expect(buildNavidromePublicStreamUrl(ref, 'jwt-token')).toBe(
|
||||||
|
'https://music.example.com/navidrome/share/s/jwt-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds public cover URLs from track stream tokens', () => {
|
||||||
|
expect(buildNavidromePublicCoverUrl(ref, 'jwt-token')).toBe(
|
||||||
|
'https://music.example.com/navidrome/share/img/jwt-token?size=300',
|
||||||
|
);
|
||||||
|
expect(buildNavidromePublicCoverUrl(ref, 'jwt-token', 512)).toBe(
|
||||||
|
'https://music.example.com/navidrome/share/img/jwt-token?size=512',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds m3u URLs', () => {
|
||||||
|
expect(buildNavidromePublicShareM3uUrl(ref)).toBe(
|
||||||
|
`https://music.example.com/navidrome/share/${SHARE_ID}/m3u`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/** Navidrome public share page: `{origin}{basePath}/share/{shareId}` (no auth). */
|
||||||
|
export type NavidromePublicShareRef = {
|
||||||
|
pageUrl: string;
|
||||||
|
origin: string;
|
||||||
|
basePath: string;
|
||||||
|
shareId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SHARE_ID_RE = /^[0-9A-Za-z]{10}$/;
|
||||||
|
|
||||||
|
function normalizeOrigin(url: URL): string {
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRef(url: URL, basePath: string, shareId: string): NavidromePublicShareRef {
|
||||||
|
const origin = normalizeOrigin(url);
|
||||||
|
const pageUrl = `${origin}${basePath}/share/${shareId}`;
|
||||||
|
return { pageUrl, origin, basePath, shareId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a single URL string (must be the share page, not stream/img/m3u). */
|
||||||
|
export function parseNavidromePublicShareUrl(text: string): NavidromePublicShareRef | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
|
||||||
|
const parts = url.pathname.split('/').filter(Boolean);
|
||||||
|
const shareIdx = parts.lastIndexOf('share');
|
||||||
|
if (shareIdx < 0 || shareIdx !== parts.length - 2) return null;
|
||||||
|
|
||||||
|
const shareId = parts[shareIdx + 1] ?? '';
|
||||||
|
if (!SHARE_ID_RE.test(shareId)) return null;
|
||||||
|
|
||||||
|
const baseParts = parts.slice(0, shareIdx);
|
||||||
|
const basePath = baseParts.length ? `/${baseParts.join('/')}` : '';
|
||||||
|
return buildRef(url, basePath, shareId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a Navidrome public share URL inside pasted or search text. */
|
||||||
|
export function extractNavidromePublicShareFromText(text: string): NavidromePublicShareRef | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const direct = parseNavidromePublicShareUrl(trimmed);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
const urlRe = /https?:\/\/[^\s<>"']+/gi;
|
||||||
|
for (const match of trimmed.matchAll(urlRe)) {
|
||||||
|
const candidate = match[0]!.replace(/[),.;!?]+$/g, '');
|
||||||
|
const parsed = parseNavidromePublicShareUrl(candidate);
|
||||||
|
if (parsed) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNavidromePublicStreamUrl(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
streamToken: string,
|
||||||
|
): string {
|
||||||
|
const prefix = ref.basePath ? `${ref.origin}${ref.basePath}` : ref.origin;
|
||||||
|
return `${prefix}/share/s/${streamToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public artwork — reuses the track stream JWT (`/share/img/{token}`). */
|
||||||
|
export function buildNavidromePublicCoverUrl(
|
||||||
|
ref: NavidromePublicShareRef,
|
||||||
|
streamToken: string,
|
||||||
|
size = 300,
|
||||||
|
): string {
|
||||||
|
const prefix = ref.basePath ? `${ref.origin}${ref.basePath}` : ref.origin;
|
||||||
|
return `${prefix}/share/img/${streamToken}?size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNavidromePublicShareM3uUrl(ref: NavidromePublicShareRef): string {
|
||||||
|
return `${ref.pageUrl}/m3u`;
|
||||||
|
}
|
||||||
@@ -122,4 +122,28 @@ describe('share search parsing', () => {
|
|||||||
|
|
||||||
expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' });
|
expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects Navidrome public share URLs before psysonic2 links', () => {
|
||||||
|
const url = 'https://music.example.com/navidrome/share/Ab12Cd34Ef';
|
||||||
|
expect(parseShareSearchText(url)).toEqual({
|
||||||
|
type: 'navidrome-public',
|
||||||
|
publicShareRef: {
|
||||||
|
pageUrl: url,
|
||||||
|
origin: 'https://music.example.com',
|
||||||
|
basePath: '/navidrome',
|
||||||
|
shareId: 'Ab12Cd34Ef',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers Navidrome URL when both patterns appear in text', () => {
|
||||||
|
const magic = encodeSharePayload({
|
||||||
|
srv: 'https://music.example.com',
|
||||||
|
k: 'track',
|
||||||
|
id: 'song-1',
|
||||||
|
});
|
||||||
|
const url = 'https://music.example.com/share/Ab12Cd34Ef';
|
||||||
|
const match = parseShareSearchText(`${url} ${magic}`);
|
||||||
|
expect(match?.type).toBe('navidrome-public');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import {
|
|||||||
decodeSharePayloadFromText,
|
decodeSharePayloadFromText,
|
||||||
PSYSONIC_SHARE_PREFIX,
|
PSYSONIC_SHARE_PREFIX,
|
||||||
} from '@/lib/share/shareLink';
|
} from '@/lib/share/shareLink';
|
||||||
|
import {
|
||||||
|
extractNavidromePublicShareFromText,
|
||||||
|
type NavidromePublicShareRef,
|
||||||
|
} from '@/lib/share/navidromePublicShareUrl';
|
||||||
|
|
||||||
export type QueueableShareSearchPayload =
|
export type QueueableShareSearchPayload =
|
||||||
| { srv: string; k: 'track'; id: string }
|
| { srv: string; k: 'track'; id: string }
|
||||||
@@ -12,6 +16,7 @@ export type ArtistShareSearchPayload = { srv: string; k: 'artist'; id: string };
|
|||||||
export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string };
|
export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string };
|
||||||
|
|
||||||
export type ShareSearchMatch =
|
export type ShareSearchMatch =
|
||||||
|
| { type: 'navidrome-public'; publicShareRef: NavidromePublicShareRef }
|
||||||
| { type: 'queueable'; payload: QueueableShareSearchPayload }
|
| { type: 'queueable'; payload: QueueableShareSearchPayload }
|
||||||
| { type: 'album'; payload: AlbumShareSearchPayload }
|
| { type: 'album'; payload: AlbumShareSearchPayload }
|
||||||
| { type: 'artist'; payload: ArtistShareSearchPayload }
|
| { type: 'artist'; payload: ArtistShareSearchPayload }
|
||||||
@@ -20,6 +25,11 @@ export type ShareSearchMatch =
|
|||||||
|
|
||||||
export function parseShareSearchText(text: string): ShareSearchMatch | null {
|
export function parseShareSearchText(text: string): ShareSearchMatch | null {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const navidromeRef = extractNavidromePublicShareFromText(trimmed);
|
||||||
|
if (navidromeRef) return { type: 'navidrome-public', publicShareRef: navidromeRef };
|
||||||
|
|
||||||
if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null;
|
if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null;
|
||||||
|
|
||||||
const payload = decodeSharePayloadFromText(trimmed);
|
const payload = decodeSharePayloadFromText(trimmed);
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
|||||||
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
|
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
|
||||||
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
|
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||||
|
|
||||||
|
function hostFromOrigin(origin: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
return origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display name for the share link's origin server when it differs from the
|
* Display name for the share link's origin server when it differs from the
|
||||||
* active server. Returns null when the link targets the active server, is
|
* active server. Returns null when the link targets the active server, is
|
||||||
@@ -16,6 +24,10 @@ export function shareServerOriginLabel(
|
|||||||
): string | null {
|
): string | null {
|
||||||
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
||||||
|
|
||||||
|
if (shareMatch.type === 'navidrome-public') {
|
||||||
|
return hostFromOrigin(shareMatch.publicShareRef.origin);
|
||||||
|
}
|
||||||
|
|
||||||
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||||
if (!shareServerId || shareServerId === activeServerId) return null;
|
if (!shareServerId || shareServerId === activeServerId) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Пусни опашка',
|
playQueue: 'Пусни опашка',
|
||||||
playQueueing: 'Стартиране на възпроизвеждане…',
|
playQueueing: 'Стартиране на възпроизвеждане…',
|
||||||
genericError: 'Линкът за споделяне не може да бъде отворен.',
|
genericError: 'Линкът за споделяне не може да бъде отворен.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
|
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Could not open the share link.',
|
genericError: 'Could not open the share link.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'No se pudo abrir el enlace para compartir.',
|
genericError: 'No se pudo abrir el enlace para compartir.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Impossible d’ouvrir le lien de partage.',
|
genericError: 'Impossible d’ouvrir le lien de partage.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Sor lejátszása',
|
playQueue: 'Sor lejátszása',
|
||||||
playQueueing: 'Lejátszás indítása…',
|
playQueueing: 'Lejátszás indítása…',
|
||||||
genericError: 'A megosztási link megnyitása nem sikerült.',
|
genericError: 'A megosztási link megnyitása nem sikerült.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Riproduci coda',
|
playQueue: 'Riproduci coda',
|
||||||
playQueueing: 'Avvio della riproduzione…',
|
playQueueing: 'Avvio della riproduzione…',
|
||||||
genericError: 'Impossibile aprire il link di condivisione.',
|
genericError: 'Impossibile aprire il link di condivisione.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: '再生キュー',
|
playQueue: '再生キュー',
|
||||||
playQueueing: '再生を開始中…',
|
playQueueing: '再生を開始中…',
|
||||||
genericError: '共有リンクを開けませんでした。',
|
genericError: '共有リンクを開けませんでした。',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Klarte ikke å åpne delingslenken.',
|
genericError: 'Klarte ikke å åpne delingslenken.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'De deellink kon niet worden geopend.',
|
genericError: 'De deellink kon niet worden geopend.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Odtwórz kolejki',
|
playQueue: 'Odtwórz kolejki',
|
||||||
playQueueing: 'Rozpoczynanie odtwarzania…',
|
playQueueing: 'Rozpoczynanie odtwarzania…',
|
||||||
genericError: 'Nie można otworzyć linku udostępniania.',
|
genericError: 'Nie można otworzyć linku udostępniania.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: 'Nu s-a putut deschide link-ul distribuit.',
|
genericError: 'Nu s-a putut deschide link-ul distribuit.',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,4 +19,18 @@ export const sharePaste = {
|
|||||||
playQueue: 'Воспроизвести очередь',
|
playQueue: 'Воспроизвести очередь',
|
||||||
playQueueing: 'Запуск воспроизведения…',
|
playQueueing: 'Запуск воспроизведения…',
|
||||||
genericError: 'Не удалось открыть ссылку для обмена.',
|
genericError: 'Не удалось открыть ссылку для обмена.',
|
||||||
|
navidromeShareTitle: 'Публичная ссылка Navidrome ({{count}} треков)',
|
||||||
|
navidromeSharePreview: 'Предпросмотр',
|
||||||
|
navidromeSharePlay: 'Воспроизвести',
|
||||||
|
navidromeSharePlaying: 'Запуск воспроизведения…',
|
||||||
|
navidromeShareLoading: 'Загрузка публичной ссылки…',
|
||||||
|
navidromeShareNotFound: 'Публичная ссылка не найдена.',
|
||||||
|
navidromeShareExpired: 'Срок действия публичной ссылки истёк.',
|
||||||
|
navidromeShareUnreachable: 'Не удалось связаться с сервером. Проверьте ссылку или откройте её в браузере.',
|
||||||
|
navidromeShareMalformed: 'Не удалось прочитать метаданные ссылки.',
|
||||||
|
openInBrowser: 'Открыть в браузере',
|
||||||
|
openedNavidromePublic_one: 'Воспроизводится {{count}} трек из публичной ссылки.',
|
||||||
|
openedNavidromePublic_few: 'Воспроизводится {{count}} трека из публичной ссылки.',
|
||||||
|
openedNavidromePublic_many: 'Воспроизводится {{count}} треков из публичной ссылки.',
|
||||||
|
openedNavidromePublic_other: 'Воспроизводится {{count}} треков из публичной ссылки.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,4 +16,16 @@ export const sharePaste = {
|
|||||||
playQueue: 'Play queue',
|
playQueue: 'Play queue',
|
||||||
playQueueing: 'Starting playback…',
|
playQueueing: 'Starting playback…',
|
||||||
genericError: '无法打开分享链接。',
|
genericError: '无法打开分享链接。',
|
||||||
|
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||||
|
navidromeSharePreview: 'Preview share',
|
||||||
|
navidromeSharePlay: 'Play share',
|
||||||
|
navidromeSharePlaying: 'Starting playback…',
|
||||||
|
navidromeShareLoading: 'Loading public share…',
|
||||||
|
navidromeShareNotFound: 'This public share link was not found.',
|
||||||
|
navidromeShareExpired: 'This public share link has expired.',
|
||||||
|
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||||
|
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||||
|
openInBrowser: 'Open in browser',
|
||||||
|
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||||
|
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user