23c9125ad8
Backend CI / Lint (push) Waiting to run
Backend CI / Tests (push) Waiting to run
Squashes 26 incremental fork commits (Apr–May 2026) onto upstream main as a single
diff for cleaner cross-fork comparison. Original history preserved on the
pre-squash-backup tag locally.
Feature additions
─────────────────
• Inline single-track download via yt-dlp-worker proxy
New routes: POST /api/v1/track-download/search (source: youtube | spotify),
POST /api/v1/track-download, GET /api/v1/track-download/{id}. Frontend
TrackDownloadButton in album track list AND popular-songs row, with a per-button
source picker. Per-user rate limits live in the worker's SQLite store. On
completion the backend fires Lidarr RefreshArtist + Plex library refresh +
cache invalidation, and the popular-songs list auto-refreshes.
• Per-instance library pinning via MUSICSEERR_LIBRARY env
Backend stamps the library label server-side (music / music-personal /
music-shared); clients cannot override. Drives an instance-segregated
deployment of three musicseerr containers sharing one source tree.
• Lidarr-request flow (single-track requests via Lidarr indexers)
New routes: POST /api/v1/lidarr-request, GET /api/v1/lidarr-request/status.
Per-album asyncio.Lock keyed on album_mbid so rapid-clicks on the same album
serialize correctly. Cross-release track matcher with foreignTrackId →
foreignRecordingId → position+disc → exact-title → substring fallback chain,
evaluated per release (recording UUIDs frequently differ between album,
single, and deluxe edition releases of the same song). Flips
artist.monitored = True on request so Lidarr's WantedAlbums query reaches
the track. Full Lidarr-chain gate (artist AND album AND track) for the
status endpoint to avoid false-positive REQUESTED display. Persistent UI
state so button icons survive refresh and cross-album navigation.
• Privacy: show_now_playing toggle in Settings → Home
Default off. Plex /status/sessions returns active audio sessions across the
whole server with no library-section filter, so a shared instance leaks
every household member's listening activity. The merged store still emits
the user's local MusicSeerr playback bar; only server-derived sessions
(Plex / Jellyfin / Navidrome) are gated.
• Per-button visibility prefs for the track-row action cluster
Settings → Preferences → Download Options / Playback Buttons. Per-context
(popular_songs / album_page) force-off flags layered on top of the existing
source-availability gate.
• UX: wrap action cluster on mobile, hide LidarrRequestButton in tight
layouts, cross-album status-leak fix in AlbumTrackList ($effect keyed on
album.musicbrainz_id to rebuild lookup; map keyed by
"{albumMbid}:{position}:{disc}").
Test coverage
─────────────
Backend pytest: full suite green (2031/2031 as of squash). New: schema-default
tests for HomeSettings, lidarr_request_service cross-release matcher
regression test, singleton-registry expected-count bump to 59. Frontend
vitest: SettingsHome.svelte.spec covers new toggle, nowPlayingSessions
.svelte.spec covers the privacy gate (no fetch when off; fetches when on).
690 lines
22 KiB
Svelte
690 lines
22 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import { browser } from '$app/environment';
|
|
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
|
import { resolve } from '$app/paths';
|
|
import { migratePageSourceKeys } from '$lib/stores/musicSource';
|
|
import { errorModal } from '$lib/stores/errorModal';
|
|
import { libraryStore } from '$lib/stores/library';
|
|
import { integrationStore } from '$lib/stores/integration';
|
|
import { preferencesStore } from '$lib/stores/preferences';
|
|
import { initCacheTTLs } from '$lib/stores/cacheTtl';
|
|
import { playerStore } from '$lib/stores/player.svelte';
|
|
import { launchYouTubePlayback } from '$lib/player/launchYouTubePlayback';
|
|
import { playbackToast } from '$lib/stores/playbackToast.svelte';
|
|
import { scrobbleManager } from '$lib/stores/scrobble.svelte';
|
|
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
|
import { serviceStatusStore } from '$lib/stores/serviceStatus';
|
|
import { setAudioElement, tryGetAudioEngine } from '$lib/player/audioElement';
|
|
import { eqStore } from '$lib/stores/eq.svelte';
|
|
import Player from '$lib/components/Player.svelte';
|
|
import CacheSyncIndicator from '$lib/components/CacheSyncIndicator.svelte';
|
|
import AddToPlaylistModal, {
|
|
registerPlaylistModal,
|
|
unregisterPlaylistModal
|
|
} from '$lib/components/AddToPlaylistModal.svelte';
|
|
import DiscographyDownloadModal from '$lib/components/DiscographyDownloadModal.svelte';
|
|
import BatchDownloadIndicator from '$lib/components/BatchDownloadIndicator.svelte';
|
|
import { syncStatus } from '$lib/stores/syncStatus.svelte';
|
|
import YouTubeIcon from '$lib/components/YouTubeIcon.svelte';
|
|
import NavidromeIcon from '$lib/components/NavidromeIcon.svelte';
|
|
import JellyfinIcon from '$lib/components/JellyfinIcon.svelte';
|
|
import PlexIcon from '$lib/components/PlexIcon.svelte';
|
|
import SidebarServiceHint from '$lib/components/SidebarServiceHint.svelte';
|
|
import DegradedBanner from '$lib/components/DegradedBanner.svelte';
|
|
import VersionOverlays from '$lib/components/VersionOverlays.svelte';
|
|
import SearchSuggestions from '$lib/components/SearchSuggestions.svelte';
|
|
import type { SuggestResult } from '$lib/types';
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { cancelPendingImages } from '$lib/utils/lazyImage';
|
|
import { abortAllPageRequests } from '$lib/utils/navigationAbort';
|
|
import { requestCountStore } from '$lib/stores/requestCountStore.svelte';
|
|
import { nowPlayingMerged } from '$lib/stores/nowPlayingMerged.svelte';
|
|
import { nowPlayingStore } from '$lib/stores/nowPlayingSessions.svelte';
|
|
import { homeSettingsStore } from '$lib/stores/homeSettings.svelte';
|
|
import SidebarVisualiser from '$lib/components/SidebarVisualiser.svelte';
|
|
import { createNavigationProgressController } from '$lib/utils/navigationProgress';
|
|
import { fromStore } from 'svelte/store';
|
|
import {
|
|
Settings,
|
|
Search,
|
|
House,
|
|
Compass,
|
|
Menu,
|
|
Headphones,
|
|
Download,
|
|
PanelLeft,
|
|
TriangleAlert,
|
|
Info,
|
|
X,
|
|
UserRound,
|
|
ListMusic,
|
|
ArrowUpCircle
|
|
} from 'lucide-svelte';
|
|
import type { Snippet } from 'svelte';
|
|
import QueryProvider from '$lib/queries/QueryProvider.svelte';
|
|
|
|
migratePageSourceKeys();
|
|
|
|
let { children }: { children: Snippet } = $props();
|
|
|
|
let query = $state('');
|
|
let audioElement = $state<HTMLAudioElement | undefined>(undefined);
|
|
let playlistModalRef: AddToPlaylistModal | undefined = $state(undefined);
|
|
let modalQuery = $state('');
|
|
let showNavigationProgress = $state(false);
|
|
let currentPath = $state('/');
|
|
let versionUpdateAvailable = $state(false);
|
|
|
|
const NAV_PROGRESS_DELAY_MS = 120;
|
|
const NAV_PROGRESS_MIN_VISIBLE_MS = 220;
|
|
const navigationProgress = createNavigationProgressController({
|
|
delayMs: NAV_PROGRESS_DELAY_MS,
|
|
minVisibleMs: NAV_PROGRESS_MIN_VISIBLE_MS,
|
|
onVisibleChange: (visible) => {
|
|
showNavigationProgress = visible;
|
|
}
|
|
});
|
|
|
|
beforeNavigate((navigation) => {
|
|
const fromPath = navigation.from?.url.pathname;
|
|
const toPath = navigation.to?.url.pathname;
|
|
if (fromPath !== toPath) {
|
|
abortAllPageRequests();
|
|
serviceStatusStore.clear();
|
|
}
|
|
navigationProgress.start();
|
|
cancelPendingImages();
|
|
});
|
|
|
|
afterNavigate(() => {
|
|
if (browser) {
|
|
currentPath = window.location.pathname;
|
|
}
|
|
navigationProgress.finish();
|
|
libraryStore.refreshIfStale(10_000);
|
|
});
|
|
|
|
let cleanupResumeListeners: (() => void) | null = null;
|
|
|
|
onMount(() => {
|
|
if (audioElement) {
|
|
setAudioElement(audioElement);
|
|
eqStore.replayToEngine();
|
|
}
|
|
|
|
const resumeAudioContext = () => {
|
|
tryGetAudioEngine()?.resume();
|
|
cleanupResumeListeners?.();
|
|
cleanupResumeListeners = null;
|
|
};
|
|
document.addEventListener('click', resumeAudioContext, { once: true });
|
|
document.addEventListener('keydown', resumeAudioContext, { once: true });
|
|
cleanupResumeListeners = () => {
|
|
document.removeEventListener('click', resumeAudioContext);
|
|
document.removeEventListener('keydown', resumeAudioContext);
|
|
};
|
|
|
|
if (browser) {
|
|
currentPath = window.location.pathname;
|
|
}
|
|
initCacheTTLs();
|
|
document.addEventListener('keydown', handleGlobalKeydown);
|
|
if (playlistModalRef) registerPlaylistModal(playlistModalRef);
|
|
|
|
const deferInit = (fn: () => void) => {
|
|
if ('requestIdleCallback' in window) {
|
|
requestIdleCallback(fn, { timeout: 2000 });
|
|
} else {
|
|
setTimeout(fn, 100);
|
|
}
|
|
};
|
|
deferInit(() => {
|
|
libraryStore.initialize();
|
|
void imageSettingsStore.load();
|
|
// Pull saved user preferences (download_options, included
|
|
// release types, etc.) so components like AlbumTrackList /
|
|
// TrackRow see the actual saved values on first paint instead
|
|
// of the all-true client defaults. Without this, the prefs
|
|
// only loaded when the Settings page mounted, so any other
|
|
// page rendered with stale defaults.
|
|
void preferencesStore.load();
|
|
void restorePlayerSession();
|
|
void scrobbleManager.init();
|
|
requestCountStore.startPolling();
|
|
syncStatus.connect();
|
|
});
|
|
// Load home settings before starting the now-playing poller. The
|
|
// poller's fetchAll() gate keys on homeSettingsStore.showNowPlaying;
|
|
// without this load, the first 3s tick can leak server sessions
|
|
// against the default (which itself defaults False, but if it were
|
|
// ever flipped True server-side we want the gate to reflect it).
|
|
Promise.all([integrationStore.ensureLoaded(), homeSettingsStore.ensureLoaded()]).then(() => {
|
|
nowPlayingStore.start();
|
|
});
|
|
});
|
|
|
|
onDestroy(() => {
|
|
navigationProgress.cleanup();
|
|
cleanupResumeListeners?.();
|
|
cleanupResumeListeners = null;
|
|
if (browser) {
|
|
document.removeEventListener('keydown', handleGlobalKeydown);
|
|
}
|
|
requestCountStore.stopPolling();
|
|
syncStatus.disconnect();
|
|
nowPlayingStore.stop();
|
|
unregisterPlaylistModal();
|
|
});
|
|
|
|
function handleGlobalKeydown(e: KeyboardEvent): void {
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
if (!playerStore.isPlayerVisible) return;
|
|
|
|
switch (e.key) {
|
|
case ' ':
|
|
e.preventDefault();
|
|
playerStore.togglePlay();
|
|
break;
|
|
case 'ArrowRight':
|
|
e.preventDefault();
|
|
playerStore.seekTo(Math.min(playerStore.progress + 10, playerStore.duration));
|
|
break;
|
|
case 'ArrowLeft':
|
|
e.preventDefault();
|
|
playerStore.seekTo(Math.max(playerStore.progress - 10, 0));
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
playerStore.setVolume(playerStore.volume + 5);
|
|
break;
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
playerStore.setVolume(playerStore.volume - 5);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async function restorePlayerSession(): Promise<void> {
|
|
const session = playerStore.restoreSession();
|
|
if (!session) return;
|
|
|
|
try {
|
|
if (session.nowPlaying.sourceType === 'youtube') {
|
|
if (!session.nowPlaying.trackSourceId) return;
|
|
await launchYouTubePlayback({
|
|
albumId: session.nowPlaying.albumId,
|
|
albumName: session.nowPlaying.albumName,
|
|
artistName: session.nowPlaying.artistName,
|
|
coverUrl: session.nowPlaying.coverUrl,
|
|
videoId: session.nowPlaying.trackSourceId,
|
|
embedUrl: session.nowPlaying.embedUrl
|
|
});
|
|
} else {
|
|
playerStore.resumeSession();
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
if (query.trim()) {
|
|
goto(`/search?q=${encodeURIComponent(query)}`);
|
|
}
|
|
}
|
|
|
|
function handleModalSearch() {
|
|
if (modalQuery.trim()) {
|
|
goto(`/search?q=${encodeURIComponent(modalQuery)}`);
|
|
const modal = document.getElementById('search_modal') as HTMLDialogElement;
|
|
if (modal) modal.close();
|
|
modalQuery = '';
|
|
}
|
|
}
|
|
|
|
function handleSuggestionSelect(result: SuggestResult) {
|
|
const routeId = result.type === 'artist' ? '/artist/[id]' : '/album/[id]';
|
|
goto(resolve(routeId, { id: result.musicbrainz_id }));
|
|
}
|
|
|
|
function handleModalSuggestionSelect(result: SuggestResult) {
|
|
(document.getElementById('search_modal') as HTMLDialogElement)?.close();
|
|
const routeId = result.type === 'artist' ? '/artist/[id]' : '/album/[id]';
|
|
goto(resolve(routeId, { id: result.musicbrainz_id }));
|
|
}
|
|
|
|
function isNavActive(path: string): boolean {
|
|
return currentPath === path || currentPath.startsWith(`${path}/`);
|
|
}
|
|
|
|
const integrations = fromStore(integrationStore);
|
|
const lidarrConfigured = $derived(integrations.current.lidarr || !integrations.current.loaded);
|
|
</script>
|
|
|
|
<QueryProvider>
|
|
<div data-theme="musicseerr">
|
|
{#if showNavigationProgress}
|
|
<div class="fixed top-0 left-0 right-0 z-120 pointer-events-none">
|
|
<progress class="progress progress-primary w-full h-1"></progress>
|
|
</div>
|
|
{/if}
|
|
|
|
<DegradedBanner />
|
|
<VersionOverlays bind:updateAvailable={versionUpdateAvailable} />
|
|
|
|
<div class="drawer drawer-open">
|
|
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
|
|
|
<div class="drawer-content flex flex-col">
|
|
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
|
|
<div class="navbar-start w-auto">
|
|
<a href="/" class="btn btn-ghost" aria-label="Home">
|
|
<img src="/logo_wide.png" alt="Musicseerr" class="h-8" />
|
|
</a>
|
|
</div>
|
|
<div class="navbar-center grow px-4 justify-center">
|
|
<div class="w-full max-w-2xl">
|
|
<SearchSuggestions
|
|
bind:query
|
|
onSearch={handleSearch}
|
|
onSelect={handleSuggestionSelect}
|
|
id="navbar-suggest"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="navbar-end w-auto pr-2">
|
|
<a href="/profile" class="btn btn-ghost btn-circle btn-md" aria-label="Profile">
|
|
<UserRound class="h-6 w-6" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex-1" class:pb-24={playerStore.isPlayerVisible}>
|
|
{@render children()}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="drawer-side is-drawer-close:overflow-visible">
|
|
<label for="main-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
|
<div
|
|
class="is-drawer-close:w-16 is-drawer-open:w-64 bg-base-200 flex flex-col items-start min-h-full"
|
|
>
|
|
<ul class="menu w-full grow p-2 [&_li>*]:py-3">
|
|
<li>
|
|
<button
|
|
onclick={() =>
|
|
(document.getElementById('search_modal') as HTMLDialogElement)?.showModal()}
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Search"
|
|
>
|
|
<Search class="h-6 w-6" />
|
|
<span class="is-drawer-close:hidden">Search</span>
|
|
</button>
|
|
</li>
|
|
|
|
<div class="divider my-0"></div>
|
|
|
|
<li>
|
|
<a
|
|
href="/"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Home"
|
|
>
|
|
<House class="h-6 w-6" />
|
|
<span class="is-drawer-close:hidden">Home</span>
|
|
</a>
|
|
</li>
|
|
|
|
<li>
|
|
<a
|
|
href="/discover"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Discover"
|
|
>
|
|
<Compass class="h-6 w-6" />
|
|
<span class="is-drawer-close:hidden">Discover</span>
|
|
</a>
|
|
</li>
|
|
|
|
{#if lidarrConfigured}
|
|
<li>
|
|
<a
|
|
href="/library"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Library"
|
|
>
|
|
<div class="relative">
|
|
<Menu class="h-6 w-6" />
|
|
{#if syncStatus.isActive}
|
|
<span
|
|
class="absolute -top-1 -right-1 badge badge-primary badge-xs w-2.5 h-2.5 p-0 animate-pulse"
|
|
aria-label="Library sync in progress"
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
<span class="is-drawer-close:hidden">Library</span>
|
|
</a>
|
|
</li>
|
|
|
|
<li>
|
|
<a
|
|
href="/playlists"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
class:menu-active={isNavActive('/playlists')}
|
|
aria-current={isNavActive('/playlists') ? 'page' : undefined}
|
|
data-tip="Playlists"
|
|
>
|
|
<ListMusic class="h-6 w-6" />
|
|
<span class="is-drawer-close:hidden">Playlists</span>
|
|
</a>
|
|
</li>
|
|
{/if}
|
|
|
|
{#if integrations.current.loaded}
|
|
<div class="divider my-0"></div>
|
|
{/if}
|
|
|
|
{#if integrations.current.youtube}
|
|
<li>
|
|
<a
|
|
href="/library/youtube"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="YouTube"
|
|
>
|
|
<YouTubeIcon class="h-6 w-6 text-error" />
|
|
<span class="is-drawer-close:hidden">YouTube</span>
|
|
</a>
|
|
</li>
|
|
{:else if integrations.current.loaded}
|
|
<SidebarServiceHint label="YouTube" settingsTab="youtube">
|
|
{#snippet icon()}<YouTubeIcon class="h-6 w-6 text-error" />{/snippet}
|
|
</SidebarServiceHint>
|
|
{/if}
|
|
|
|
{#if integrations.current.jellyfin}
|
|
<li>
|
|
<a
|
|
href="/library/jellyfin"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Jellyfin"
|
|
>
|
|
<div class="relative inline-flex">
|
|
<JellyfinIcon class="h-6 w-6 text-info" />
|
|
{#if nowPlayingMerged.isSourcePlaying('jellyfin')}
|
|
<span
|
|
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse"
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
<span class="is-drawer-close:hidden">Jellyfin</span>
|
|
{#if nowPlayingMerged.isSourcePlaying('jellyfin')}
|
|
<div
|
|
class="now-playing-bars now-playing-bars--sm ml-auto is-drawer-close:hidden"
|
|
>
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
</li>
|
|
{:else if integrations.current.loaded}
|
|
<SidebarServiceHint label="Jellyfin" settingsTab="jellyfin">
|
|
{#snippet icon()}<JellyfinIcon class="h-6 w-6 text-info" />{/snippet}
|
|
</SidebarServiceHint>
|
|
{/if}
|
|
|
|
{#if integrations.current.navidrome}
|
|
<li>
|
|
<a
|
|
href="/library/navidrome"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Navidrome"
|
|
>
|
|
<div class="relative inline-flex">
|
|
<NavidromeIcon class="h-6 w-6 text-primary" />
|
|
{#if nowPlayingMerged.isSourcePlaying('navidrome')}
|
|
<span
|
|
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse"
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
<span class="is-drawer-close:hidden">Navidrome</span>
|
|
{#if nowPlayingMerged.isSourcePlaying('navidrome')}
|
|
<div
|
|
class="now-playing-bars now-playing-bars--sm ml-auto is-drawer-close:hidden"
|
|
>
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
</li>
|
|
{:else if integrations.current.loaded}
|
|
<SidebarServiceHint label="Navidrome" settingsTab="navidrome">
|
|
{#snippet icon()}<NavidromeIcon class="h-6 w-6 text-primary" />{/snippet}
|
|
</SidebarServiceHint>
|
|
{/if}
|
|
|
|
{#if integrations.current.plex}
|
|
<li>
|
|
<a
|
|
href="/library/plex"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Plex"
|
|
>
|
|
<div class="relative inline-flex">
|
|
<PlexIcon class="h-6 w-6" style="color: rgb(var(--brand-plex))" />
|
|
{#if nowPlayingMerged.isSourcePlaying('plex')}
|
|
<span
|
|
class="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse"
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
<span class="is-drawer-close:hidden">Plex</span>
|
|
{#if nowPlayingMerged.isSourcePlaying('plex')}
|
|
<div
|
|
class="now-playing-bars now-playing-bars--sm ml-auto is-drawer-close:hidden"
|
|
>
|
|
<span></span><span></span><span></span>
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
</li>
|
|
{:else if integrations.current.loaded}
|
|
<SidebarServiceHint label="Plex" settingsTab="plex">
|
|
{#snippet icon()}<PlexIcon
|
|
class="h-6 w-6"
|
|
style="color: rgb(var(--brand-plex))"
|
|
/>{/snippet}
|
|
</SidebarServiceHint>
|
|
{/if}
|
|
|
|
{#if integrations.current.localfiles}
|
|
<li>
|
|
<a
|
|
href="/library/local"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Local Files"
|
|
>
|
|
<Headphones class="h-6 w-6 text-accent" />
|
|
<span class="is-drawer-close:hidden">Local Files</span>
|
|
</a>
|
|
</li>
|
|
{:else if integrations.current.loaded}
|
|
<SidebarServiceHint label="Local Files" settingsTab="local-files">
|
|
{#snippet icon()}<Headphones class="h-6 w-6 text-accent" />{/snippet}
|
|
</SidebarServiceHint>
|
|
{/if}
|
|
|
|
<SidebarVisualiser />
|
|
|
|
{#if lidarrConfigured}
|
|
<div class="divider my-0"></div>
|
|
<li>
|
|
<a
|
|
href="/requests"
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip="Requests"
|
|
>
|
|
<div class="relative">
|
|
<Download class="h-6 w-6" />
|
|
{#if requestCountStore.count > 0}
|
|
<span
|
|
class="absolute -top-2 -right-2 badge badge-info badge-xs w-4 h-4 p-0 text-[10px] font-bold"
|
|
>{requestCountStore.count}</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
<span class="is-drawer-close:hidden">Requests</span>
|
|
</a>
|
|
</li>
|
|
{/if}
|
|
</ul>
|
|
<div class="w-full p-2 flex flex-col gap-1" class:pb-24={playerStore.isPlayerVisible}>
|
|
<div
|
|
class="is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
|
data-tip={versionUpdateAvailable ? 'Settings - update available' : 'Settings'}
|
|
>
|
|
<a
|
|
href={versionUpdateAvailable ? '/settings?tab=about' : '/settings'}
|
|
class="btn btn-ghost btn-circle relative"
|
|
aria-label={versionUpdateAvailable ? 'Settings - update available' : 'Settings'}
|
|
>
|
|
<Settings class="h-6 w-6" />
|
|
{#if versionUpdateAvailable}
|
|
<span
|
|
class="absolute -top-0.5 -right-0.5 flex h-4.5 w-4.5 items-center justify-center rounded-full bg-accent text-accent-content shadow-sm shadow-accent/30"
|
|
>
|
|
<ArrowUpCircle class="h-3 w-3" />
|
|
</span>
|
|
{/if}
|
|
</a>
|
|
</div>
|
|
<div class="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Open">
|
|
<label
|
|
for="main-drawer"
|
|
class="btn btn-ghost btn-circle drawer-button is-drawer-open:rotate-y-180"
|
|
>
|
|
<PanelLeft class="h-6 w-6" />
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<dialog id="search_modal" class="modal">
|
|
<div class="modal-box overflow-visible">
|
|
<form method="dialog">
|
|
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" aria-label="Close"
|
|
><X class="h-4 w-4" /></button
|
|
>
|
|
</form>
|
|
<h3 class="font-bold text-lg mb-4">Search</h3>
|
|
<SearchSuggestions
|
|
bind:query={modalQuery}
|
|
onSearch={handleModalSearch}
|
|
onSelect={handleModalSuggestionSelect}
|
|
placeholder="Search albums or artists..."
|
|
autofocus={true}
|
|
id="modal-suggest"
|
|
/>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button aria-label="Close modal">close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
{#if $errorModal.show}
|
|
<dialog class="modal modal-open">
|
|
<div class="modal-box bg-base-200 border border-base-300 shadow-xl max-w-md">
|
|
<button
|
|
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3 opacity-60 hover:opacity-100"
|
|
onclick={() => errorModal.hide()}
|
|
aria-label="Close"
|
|
>
|
|
<X class="h-4 w-4" />
|
|
</button>
|
|
|
|
<div class="flex flex-col items-center text-center pt-2 pb-1">
|
|
<div class="bg-error/10 rounded-full p-3 mb-4">
|
|
<TriangleAlert class="h-8 w-8 text-error" />
|
|
</div>
|
|
|
|
<h3 class="text-lg font-bold text-base-content mb-2">
|
|
{$errorModal.title}
|
|
</h3>
|
|
|
|
<p class="text-sm text-base-content/70 leading-relaxed">
|
|
{$errorModal.message}
|
|
</p>
|
|
</div>
|
|
|
|
{#if $errorModal.details}
|
|
<div class="mt-4 rounded-box bg-base-300/60 border border-base-300 p-4">
|
|
<div class="flex gap-3 items-start">
|
|
<Info class="h-5 w-5 text-info shrink-0 mt-0.5" />
|
|
<p class="text-sm text-base-content/80 leading-relaxed text-left">
|
|
{$errorModal.details}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="modal-action justify-center mt-5">
|
|
<button class="btn btn-accent btn-sm px-6" onclick={() => errorModal.hide()}>
|
|
Dismiss
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
<form method="dialog" class="modal-backdrop" onclick={() => errorModal.hide()}>
|
|
<button>close</button>
|
|
</form>
|
|
</dialog>
|
|
{/if}
|
|
|
|
{#if playbackToast.visible}
|
|
<div
|
|
class="fixed z-50 left-1/2 -translate-x-1/2 transition-all duration-300"
|
|
style="bottom: {playerStore.isPlayerVisible ? '100px' : '16px'}"
|
|
>
|
|
<div
|
|
class="alert {playbackToast.type === 'error'
|
|
? 'alert-error'
|
|
: playbackToast.type === 'warning'
|
|
? 'alert-warning'
|
|
: 'alert-info'} shadow-lg px-4 py-2 min-w-64 max-w-md"
|
|
>
|
|
{#if playbackToast.type === 'error'}
|
|
<X class="h-5 w-5 shrink-0" />
|
|
{:else if playbackToast.type === 'warning'}
|
|
<TriangleAlert class="h-5 w-5 shrink-0" />
|
|
{:else}
|
|
<Info class="h-5 w-5 shrink-0" />
|
|
{/if}
|
|
<span class="text-sm">{playbackToast.message}</span>
|
|
<button
|
|
class="btn btn-ghost btn-xs btn-circle"
|
|
onclick={() => playbackToast.dismiss()}
|
|
aria-label="Dismiss"
|
|
>
|
|
<X class="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if browser}
|
|
<audio bind:this={audioElement}></audio>
|
|
{/if}
|
|
|
|
<Player />
|
|
<CacheSyncIndicator />
|
|
<BatchDownloadIndicator />
|
|
<DiscographyDownloadModal />
|
|
<AddToPlaylistModal bind:this={playlistModalRef} />
|
|
</div>
|
|
</QueryProvider>
|