3559 lines
116 KiB
Svelte
3559 lines
116 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy, tick } from 'svelte';
|
|
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
|
|
import { listen, emitTo, type UnlistenFn } from '@tauri-apps/api/event';
|
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
|
|
|
// Offline cache state management
|
|
import {
|
|
initOfflineCacheStates,
|
|
startOfflineCacheEventListeners,
|
|
stopOfflineCacheEventListeners,
|
|
cacheTrackForOffline,
|
|
removeCachedTrack,
|
|
getOfflineCacheState,
|
|
openAlbumFolder,
|
|
openTrackFolder,
|
|
subscribe as subscribeOfflineCache,
|
|
type OfflineCacheStatus
|
|
} from '$lib/stores/offlineCacheState';
|
|
|
|
// Toast state management
|
|
import {
|
|
showToast,
|
|
hideToast,
|
|
subscribe as subscribeToast,
|
|
type Toast as ToastData
|
|
} from '$lib/stores/toastStore';
|
|
|
|
// Search state for performer search
|
|
import { setSearchState, triggerSearchFocus } from '$lib/stores/searchState';
|
|
|
|
// Playback context and preferences
|
|
import {
|
|
initPlaybackContextStore,
|
|
setPlaybackContext,
|
|
clearPlaybackContext,
|
|
getCurrentContext,
|
|
requestContextTrackFocus
|
|
} from '$lib/stores/playbackContextStore';
|
|
import {
|
|
initPlaybackPreferences,
|
|
getCachedPreferences,
|
|
isAutoplayEnabled
|
|
} from '$lib/stores/playbackPreferencesStore';
|
|
import { initBlacklistStore, isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
|
|
|
|
// UI state management
|
|
import {
|
|
subscribe as subscribeUI,
|
|
openQueue,
|
|
closeQueue,
|
|
toggleQueue,
|
|
openFullScreen,
|
|
closeFullScreen,
|
|
toggleFullScreen,
|
|
openFocusMode,
|
|
closeFocusMode,
|
|
toggleFocusMode,
|
|
openCastPicker,
|
|
closeCastPicker,
|
|
openPlaylistModal,
|
|
closePlaylistModal,
|
|
openPlaylistImport,
|
|
closePlaylistImport,
|
|
handleEscapeKey as handleUIEscape,
|
|
getUIState,
|
|
type UIState
|
|
} from '$lib/stores/uiStore';
|
|
|
|
// Sidebar state management
|
|
import {
|
|
subscribe as subscribeSidebar,
|
|
initSidebarStore,
|
|
getIsExpanded,
|
|
toggleSidebar
|
|
} from '$lib/stores/sidebarStore';
|
|
|
|
// Title bar state management
|
|
import {
|
|
subscribe as subscribeTitleBar,
|
|
initTitleBarStore,
|
|
shouldShowTitleBar
|
|
} from '$lib/stores/titleBarStore';
|
|
|
|
// Auth state management
|
|
import {
|
|
subscribe as subscribeAuth,
|
|
setLoggedIn,
|
|
setLoggedOut,
|
|
getAuthState,
|
|
type UserInfo
|
|
} from '$lib/stores/authStore';
|
|
|
|
// Favorites state management
|
|
import { loadFavorites } from '$lib/stores/favoritesStore';
|
|
import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore';
|
|
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
|
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
|
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
|
|
|
// Navigation state management
|
|
import {
|
|
subscribe as subscribeNav,
|
|
navigateTo as navTo,
|
|
navigateToFavorites,
|
|
goBack as navGoBack,
|
|
goForward as navGoForward,
|
|
selectPlaylist,
|
|
getNavigationState,
|
|
getFavoritesTabFromView,
|
|
isFavoritesView,
|
|
type ViewType,
|
|
type NavigationState,
|
|
type FavoritesTab
|
|
} from '$lib/stores/navigationStore';
|
|
|
|
// Player state management
|
|
import {
|
|
subscribe as subscribePlayer,
|
|
setCurrentTrack,
|
|
setIsPlaying,
|
|
setIsFavorite,
|
|
setIsSkipping,
|
|
setQueueEnded,
|
|
setOnTrackEnded,
|
|
togglePlay,
|
|
seek as playerSeek,
|
|
setVolume as playerSetVolume,
|
|
stop as stopPlayback,
|
|
setPendingSessionRestore,
|
|
startPolling,
|
|
stopPolling,
|
|
reset as resetPlayer,
|
|
getPlayerState,
|
|
getVolume,
|
|
type PlayingTrack,
|
|
type PlayerState
|
|
} from '$lib/stores/playerStore';
|
|
|
|
// Queue state management
|
|
import {
|
|
subscribe as subscribeQueue,
|
|
syncQueueState,
|
|
toggleShuffle as queueToggleShuffle,
|
|
toggleRepeat as queueToggleRepeat,
|
|
addToQueueNext,
|
|
addToQueue,
|
|
addTracksToQueue,
|
|
setQueue,
|
|
clearQueue,
|
|
playQueueIndex,
|
|
nextTrack,
|
|
previousTrack,
|
|
moveQueueTrack,
|
|
setLocalTrackIds,
|
|
clearLocalTrackIds,
|
|
isLocalTrack,
|
|
getBackendQueueState,
|
|
getQueueState,
|
|
setOfflineMode as setQueueOfflineMode,
|
|
type QueueTrack,
|
|
type BackendQueueTrack,
|
|
type RepeatMode
|
|
} from '$lib/stores/queueStore';
|
|
|
|
type MediaControlPayload = {
|
|
action: string;
|
|
direction?: 'forward' | 'backward';
|
|
offset_secs?: number;
|
|
position_secs?: number;
|
|
volume?: number;
|
|
};
|
|
|
|
const MEDIA_SEEK_FALLBACK_SECS = 10;
|
|
|
|
// Types
|
|
import type {
|
|
QobuzTrack,
|
|
QobuzAlbum,
|
|
QobuzArtist,
|
|
Track,
|
|
AlbumDetail,
|
|
ArtistDetail,
|
|
LabelDetail,
|
|
PlaylistTrack,
|
|
DisplayTrack,
|
|
LocalLibraryTrack,
|
|
SongLinkResponse
|
|
} from '$lib/types';
|
|
|
|
// Adapters
|
|
import {
|
|
convertQobuzAlbum,
|
|
convertQobuzArtist,
|
|
formatDuration,
|
|
appendArtistAlbums
|
|
} from '$lib/adapters/qobuzAdapters';
|
|
|
|
// Services
|
|
import {
|
|
playTrack,
|
|
checkTrackFavorite,
|
|
toggleTrackFavorite,
|
|
showTrackNotification,
|
|
updateLastfmNowPlaying,
|
|
cleanup as cleanupPlayback
|
|
} from '$lib/services/playbackService';
|
|
|
|
import {
|
|
queueTrackNext,
|
|
queueTrackLater,
|
|
buildQueueTrackFromQobuz,
|
|
buildQueueTrackFromAlbumTrack,
|
|
buildQueueTrackFromPlaylistTrack,
|
|
buildQueueTrackFromLocalTrack,
|
|
queueQobuzTrackNext,
|
|
queueQobuzTrackLater,
|
|
queuePlaylistTrackNext,
|
|
queuePlaylistTrackLater,
|
|
queueLocalTrackNext,
|
|
queueLocalTrackLater,
|
|
handleAddToFavorites,
|
|
addToPlaylist,
|
|
shareQobuzTrackLink,
|
|
shareSonglinkTrack
|
|
} from '$lib/services/trackActions';
|
|
|
|
// Internationalization
|
|
import { t } from '$lib/i18n';
|
|
|
|
// App bootstrap
|
|
import { bootstrapApp } from '$lib/app/bootstrap';
|
|
|
|
// Recommendation scoring
|
|
import { trainScores } from '$lib/services/recoService';
|
|
|
|
// Session persistence
|
|
import {
|
|
loadSessionState,
|
|
saveSessionState,
|
|
saveSessionVolume,
|
|
saveSessionPlaybackMode,
|
|
debouncedSavePosition,
|
|
flushPositionSave,
|
|
clearSession,
|
|
type PersistedQueueTrack
|
|
} from '$lib/services/sessionService';
|
|
|
|
// MiniPlayer
|
|
import { enterMiniplayerMode } from '$lib/services/miniplayerService';
|
|
|
|
// Sidebar mutual exclusion
|
|
import { closeContentSidebar, subscribeContentSidebar, type ContentSidebarType } from '$lib/stores/sidebarStore';
|
|
|
|
// Lyrics state management
|
|
import {
|
|
subscribe as subscribeLyrics,
|
|
toggleSidebar as toggleLyricsSidebar,
|
|
hideSidebar as hideLyricsSidebar,
|
|
startWatching as startLyricsWatching,
|
|
stopWatching as stopLyricsWatching,
|
|
startActiveLineUpdates,
|
|
stopActiveLineUpdates,
|
|
getLyricsState,
|
|
type LyricsLine
|
|
} from '$lib/stores/lyricsStore';
|
|
|
|
// Cast state management
|
|
import {
|
|
subscribe as subscribeCast,
|
|
getCastState,
|
|
isCasting,
|
|
setOnAskContinueLocally
|
|
} from '$lib/stores/castStore';
|
|
|
|
// Components
|
|
import TitleBar from '$lib/components/TitleBar.svelte';
|
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
|
import AboutModal from '$lib/components/AboutModal.svelte';
|
|
import NowPlayingBar from '$lib/components/NowPlayingBar.svelte';
|
|
import Toast from '$lib/components/Toast.svelte';
|
|
|
|
// Views
|
|
import LoginView from '$lib/components/views/LoginView.svelte';
|
|
import HomeView from '$lib/components/views/HomeView.svelte';
|
|
import SearchView from '$lib/components/views/SearchView.svelte';
|
|
import SettingsView from '$lib/components/views/SettingsView.svelte';
|
|
import AlbumDetailView from '$lib/components/views/AlbumDetailView.svelte';
|
|
import ArtistDetailView from '$lib/components/views/ArtistDetailView.svelte';
|
|
import MusicianPageView from '$lib/components/views/MusicianPageView.svelte';
|
|
import LabelView from '$lib/components/views/LabelView.svelte';
|
|
import PlaylistDetailView from '$lib/components/views/PlaylistDetailView.svelte';
|
|
import FavoritesView from '$lib/components/views/FavoritesView.svelte';
|
|
import LocalLibraryView from '$lib/components/views/LocalLibraryView.svelte';
|
|
import PlaylistManagerView from '$lib/components/views/PlaylistManagerView.svelte';
|
|
import BlacklistManagerView from '$lib/components/views/BlacklistManagerView.svelte';
|
|
|
|
// Overlays
|
|
import QueuePanel from '$lib/components/QueuePanel.svelte';
|
|
import { ImmersivePlayer } from '$lib/components/immersive';
|
|
import PlaylistModal from '$lib/components/PlaylistModal.svelte';
|
|
import PlaylistImportModal from '$lib/components/PlaylistImportModal.svelte';
|
|
import TrackInfoModal from '$lib/components/TrackInfoModal.svelte';
|
|
import AlbumCreditsModal from '$lib/components/AlbumCreditsModal.svelte';
|
|
import MusicianModal from '$lib/components/MusicianModal.svelte';
|
|
import CastPicker from '$lib/components/CastPicker.svelte';
|
|
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
|
|
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
|
|
import UpdateAvailableModal from '$lib/components/updates/UpdateAvailableModal.svelte';
|
|
import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte';
|
|
import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte';
|
|
import FlatpakWelcomeModal from '$lib/components/updates/FlatpakWelcomeModal.svelte';
|
|
import type { ReleaseInfo } from '$lib/stores/updatesStore';
|
|
import {
|
|
decideLaunchModals,
|
|
disableUpdateChecks,
|
|
ignoreReleaseVersion,
|
|
markFlatpakWelcomeShown,
|
|
openReleasePageAndAcknowledge,
|
|
} from '$lib/services/updatesService';
|
|
|
|
// Offline state
|
|
import {
|
|
subscribe as subscribeOffline,
|
|
getStatus as getOfflineStatus,
|
|
isOffline as checkIsOffline,
|
|
getOfflineReason,
|
|
setManualOffline,
|
|
refreshStatus as refreshOfflineStatus,
|
|
type OfflineStatus
|
|
} from '$lib/stores/offlineStore';
|
|
|
|
// Auth State (from authStore subscription)
|
|
let isLoggedIn = $state(false);
|
|
let userInfo = $state<UserInfo | null>(null);
|
|
|
|
// Offline State (from offlineStore subscription)
|
|
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
|
|
|
// Sidebar State (from sidebarStore subscription)
|
|
let sidebarExpanded = $state(getIsExpanded());
|
|
|
|
// Title Bar State (from titleBarStore subscription)
|
|
let showTitleBar = $state(shouldShowTitleBar());
|
|
|
|
// View State (from navigationStore subscription)
|
|
let activeView = $state<ViewType>('home');
|
|
let selectedPlaylistId = $state<number | null>(null);
|
|
let updatesCurrentVersion = $state('');
|
|
let updateRelease = $state<ReleaseInfo | null>(null);
|
|
let whatsNewRelease = $state<ReleaseInfo | null>(null);
|
|
let isUpdateModalOpen = $state(false);
|
|
let isReminderModalOpen = $state(false);
|
|
let isWhatsNewModalOpen = $state(false);
|
|
let isFlatpakWelcomeOpen = $state(false);
|
|
let updatesLaunchTriggered = $state(false);
|
|
|
|
// Sequential modal queue: Flatpak → What's new → Update available
|
|
let pendingWhatsNewRelease = $state<ReleaseInfo | null>(null);
|
|
let pendingUpdateRelease = $state<ReleaseInfo | null>(null);
|
|
|
|
// Album, Artist and Label data are fetched, so kept local
|
|
let selectedAlbum = $state<AlbumDetail | null>(null);
|
|
let selectedArtist = $state<ArtistDetail | null>(null);
|
|
let selectedLabel = $state<{ id: number; name: string } | null>(null);
|
|
let selectedMusician = $state<ResolvedMusician | null>(null);
|
|
let musicianModalData = $state<ResolvedMusician | null>(null);
|
|
let isArtistAlbumsLoading = $state(false);
|
|
|
|
function waitForHomePaint(): Promise<void> {
|
|
if (typeof window === 'undefined') return Promise.resolve();
|
|
return new Promise((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => resolve());
|
|
});
|
|
});
|
|
}
|
|
|
|
async function runLaunchUpdateFlow(): Promise<void> {
|
|
// Ensure the UI has rendered and Home is visible before showing any modal.
|
|
await tick();
|
|
await waitForHomePaint();
|
|
if (activeView !== 'home') return;
|
|
|
|
const decision = await decideLaunchModals();
|
|
updatesCurrentVersion = decision.currentVersion;
|
|
|
|
// Store pending modals for sequential display
|
|
// Order: Flatpak → What's new → Update available
|
|
pendingWhatsNewRelease = decision.whatsNewRelease;
|
|
pendingUpdateRelease = decision.updateRelease;
|
|
|
|
// Show first modal in queue (Flatpak has highest priority)
|
|
if (decision.showFlatpakWelcome) {
|
|
isFlatpakWelcomeOpen = true;
|
|
return;
|
|
}
|
|
|
|
// No Flatpak modal, try What's New
|
|
showNextModalInQueue();
|
|
}
|
|
|
|
function showNextModalInQueue(): void {
|
|
// What's New has second priority
|
|
if (pendingWhatsNewRelease) {
|
|
whatsNewRelease = pendingWhatsNewRelease;
|
|
pendingWhatsNewRelease = null;
|
|
isWhatsNewModalOpen = true;
|
|
return;
|
|
}
|
|
|
|
// Update Available has lowest priority
|
|
if (pendingUpdateRelease) {
|
|
updateRelease = pendingUpdateRelease;
|
|
pendingUpdateRelease = null;
|
|
isUpdateModalOpen = true;
|
|
}
|
|
}
|
|
|
|
function handleUpdateVisit(): void {
|
|
if (!updateRelease) return;
|
|
void openReleasePageAndAcknowledge(updateRelease);
|
|
isUpdateModalOpen = false;
|
|
updateRelease = null;
|
|
}
|
|
|
|
function handleUpdateClose(): void {
|
|
isUpdateModalOpen = false;
|
|
if (updateRelease) {
|
|
isReminderModalOpen = true;
|
|
}
|
|
}
|
|
|
|
function handleReminderClose(): void {
|
|
isReminderModalOpen = false;
|
|
updateRelease = null;
|
|
}
|
|
|
|
function handleReminderLater(): void {
|
|
// No persistence by design.
|
|
}
|
|
|
|
function handleReminderIgnoreRelease(): void {
|
|
if (!updateRelease) return;
|
|
void ignoreReleaseVersion(updateRelease.version);
|
|
}
|
|
|
|
function handleReminderDisableUpdates(): void {
|
|
void disableUpdateChecks();
|
|
}
|
|
|
|
function handleFlatpakWelcomeClose(): void {
|
|
isFlatpakWelcomeOpen = false;
|
|
void markFlatpakWelcomeShown();
|
|
// Show next modal in queue
|
|
showNextModalInQueue();
|
|
}
|
|
|
|
function handleWhatsNewClose(): void {
|
|
isWhatsNewModalOpen = false;
|
|
whatsNewRelease = null;
|
|
// Show next modal in queue
|
|
showNextModalInQueue();
|
|
}
|
|
|
|
$effect(() => {
|
|
if (updatesLaunchTriggered) return;
|
|
if (activeView !== 'home') return;
|
|
updatesLaunchTriggered = true;
|
|
void runLaunchUpdateFlow();
|
|
});
|
|
|
|
// Artist albums for "By the same artist" section in album view
|
|
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string; releaseDate?: string }[]>([]);
|
|
|
|
// Overlay States (from uiStore subscription)
|
|
let isQueueOpen = $state(false);
|
|
let isFullScreenOpen = $state(false);
|
|
let isFocusModeOpen = $state(false);
|
|
let isCastPickerOpen = $state(false);
|
|
|
|
// Cast State
|
|
let isCastConnected = $state(false);
|
|
|
|
// Playlist Modal State (from uiStore subscription)
|
|
let isPlaylistModalOpen = $state(false);
|
|
let playlistModalMode = $state<'create' | 'edit' | 'addTrack'>('create');
|
|
let playlistModalTrackIds = $state<number[]>([]);
|
|
let playlistModalTracksAreLocal = $state(false);
|
|
let isPlaylistImportOpen = $state(false);
|
|
let isAboutModalOpen = $state(false);
|
|
|
|
// Track Info Modal State
|
|
let isTrackInfoOpen = $state(false);
|
|
let trackInfoTrackId = $state<number | null>(null);
|
|
let userPlaylists = $state<{ id: number; name: string; tracks_count: number }[]>([]);
|
|
|
|
// Album Credits Modal State
|
|
let isAlbumCreditsOpen = $state(false);
|
|
let albumCreditsAlbumId = $state<string | null>(null);
|
|
|
|
// Sidebar reference for refreshing playlists
|
|
let sidebarRef: { getPlaylists: () => { id: number; name: string; tracks_count: number }[], refreshPlaylists: () => void } | undefined;
|
|
|
|
// Playback State (from playerStore subscription)
|
|
let currentTrack = $state<PlayingTrack | null>(null);
|
|
let isPlaying = $state(false);
|
|
let currentTime = $state(0);
|
|
let duration = $state(0);
|
|
let volume = $state(getVolume()); // Load persisted volume from localStorage
|
|
let isFavorite = $state(false);
|
|
|
|
// Queue/Shuffle State (from queueStore subscription)
|
|
let isShuffle = $state(false);
|
|
let repeatMode = $state<RepeatMode>('off');
|
|
let queue = $state<QueueTrack[]>([]);
|
|
let queueTotalTracks = $state(0);
|
|
let queueRemainingTracks = $state(0); // Actual remaining tracks (total - current_index - 1)
|
|
let historyTracks = $state<QueueTrack[]>([]);
|
|
let infinitePlayEnabled = $state(false);
|
|
|
|
// Toast State (from store subscription)
|
|
let toast = $state<ToastData | null>(null);
|
|
|
|
// Lyrics State (from lyricsStore subscription)
|
|
let lyricsStatus = $state<'idle' | 'loading' | 'loaded' | 'error' | 'not_found'>('idle');
|
|
let lyricsError = $state<string | null>(null);
|
|
let lyricsLines = $state<LyricsLine[]>([]);
|
|
let lyricsIsSynced = $state(false);
|
|
let lyricsActiveIndex = $state(-1);
|
|
let lyricsActiveProgress = $state(0);
|
|
let lyricsSidebarVisible = $state(false);
|
|
|
|
let favoritesDefaultTab = $state<FavoritesTab>('tracks');
|
|
|
|
async function loadFavoritesDefaultTab(): Promise<void> {
|
|
try {
|
|
const prefs = await invoke<FavoritesPreferences>('get_favorites_preferences');
|
|
favoritesDefaultTab = getDefaultFavoritesTab(prefs.tab_order);
|
|
} catch (err) {
|
|
console.error('Failed to load favorites preferences:', err);
|
|
favoritesDefaultTab = 'tracks';
|
|
}
|
|
}
|
|
|
|
// Navigation wrapper (keeps debug logging)
|
|
async function navigateTo(view: string) {
|
|
console.log('navigateTo called with:', view, 'current activeView:', activeView);
|
|
if (view === 'favorites') {
|
|
await loadFavoritesDefaultTab();
|
|
navigateToFavorites(favoritesDefaultTab);
|
|
return;
|
|
}
|
|
// If already on search, trigger scroll to top and focus
|
|
if (view === 'search' && activeView === 'search') {
|
|
triggerSearchFocus();
|
|
return;
|
|
}
|
|
navTo(view as ViewType);
|
|
}
|
|
|
|
async function handleAlbumClick(albumId: string) {
|
|
try {
|
|
showToast($t('toast.loadingAlbum'), 'info');
|
|
const album = await invoke<QobuzAlbum>('get_album', { albumId });
|
|
console.log('Album details:', album);
|
|
|
|
selectedAlbum = convertQobuzAlbum(album);
|
|
navigateTo('album');
|
|
hideToast();
|
|
|
|
// Fetch artist albums for "By the same artist" section (non-blocking)
|
|
if (album.artist?.id) {
|
|
fetchAlbumArtistAlbums(album.artist.id);
|
|
} else {
|
|
albumArtistAlbums = [];
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load album:', err);
|
|
showToast($t('toast.failedLoadAlbum'), 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch artist albums for the "By the same artist" section
|
|
* Only includes studio albums and live albums
|
|
*/
|
|
async function fetchAlbumArtistAlbums(artistId: number) {
|
|
try {
|
|
const artist = await invoke<QobuzArtist>('get_artist_detail', { artistId });
|
|
const artistDetail = convertQobuzArtist(artist);
|
|
|
|
// Combine studio albums and live albums, limit to 16
|
|
const combined = [
|
|
...artistDetail.albums.map(a => ({
|
|
id: a.id,
|
|
title: a.title,
|
|
artwork: a.artwork,
|
|
quality: a.quality,
|
|
genre: a.genre,
|
|
releaseDate: a.releaseDate
|
|
})),
|
|
...artistDetail.liveAlbums.map(a => ({
|
|
id: a.id,
|
|
title: a.title,
|
|
artwork: a.artwork,
|
|
quality: a.quality,
|
|
genre: a.genre,
|
|
releaseDate: a.releaseDate
|
|
}))
|
|
].slice(0, 16);
|
|
|
|
albumArtistAlbums = combined;
|
|
} catch (err) {
|
|
console.error('Failed to fetch artist albums for "By the same artist":', err);
|
|
albumArtistAlbums = [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Navigate to artist view and scroll to Discography section
|
|
*/
|
|
function handleViewArtistDiscography() {
|
|
if (selectedAlbum?.artistId) {
|
|
// Store scroll target for artist view
|
|
const artistId = selectedAlbum.artistId;
|
|
handleArtistClick(artistId).then(() => {
|
|
// Use setTimeout to allow the view to render before scrolling
|
|
setTimeout(() => {
|
|
const discographySection = document.querySelector('.artist-section');
|
|
if (discographySection) {
|
|
discographySection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}, 100);
|
|
});
|
|
}
|
|
}
|
|
|
|
|
|
async function handleArtistClick(artistId: number) {
|
|
try {
|
|
showToast($t('toast.loadingArtist'), 'info');
|
|
const artist = await invoke<QobuzArtist>('get_artist_detail', { artistId });
|
|
console.log('Artist details:', artist);
|
|
|
|
selectedArtist = convertQobuzArtist(artist);
|
|
navigateTo('artist');
|
|
hideToast();
|
|
} catch (err) {
|
|
console.error('Failed to load artist:', err);
|
|
showToast($t('toast.failedLoadArtist'), 'error');
|
|
}
|
|
}
|
|
|
|
function handleLabelClick(labelId: number, labelName?: string) {
|
|
selectedLabel = { id: labelId, name: labelName || '' };
|
|
navigateTo('label');
|
|
}
|
|
|
|
/**
|
|
* Handle musician click from credits
|
|
* Resolves musician and routes based on confidence level:
|
|
* - Confirmed (3): Navigate to Qobuz Artist Page
|
|
* - Contextual (2): Navigate to Musician Page
|
|
* - Weak (1), None (0): Show Informational Modal
|
|
*/
|
|
async function handleMusicianClick(name: string, role: string) {
|
|
showToast($t('toast.lookingUp', { values: { name } }), 'info');
|
|
try {
|
|
const musician = await invoke<ResolvedMusician>('resolve_musician', { name, role });
|
|
console.log('Resolved musician:', musician);
|
|
|
|
switch (musician.confidence) {
|
|
case 'confirmed':
|
|
// Has a Qobuz artist page - navigate there
|
|
if (musician.qobuz_artist_id) {
|
|
handleArtistClick(musician.qobuz_artist_id);
|
|
} else {
|
|
// Fallback: show modal
|
|
musicianModalData = musician;
|
|
}
|
|
break;
|
|
|
|
case 'contextual':
|
|
// Show full Musician Page
|
|
selectedMusician = musician;
|
|
navigateTo('musician');
|
|
break;
|
|
|
|
case 'weak':
|
|
case 'none':
|
|
default:
|
|
// Show Informational Modal only
|
|
musicianModalData = musician;
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to resolve musician:', err);
|
|
showToast($t('toast.failedLookupMusician'), 'error');
|
|
// Fallback: open modal with basic info
|
|
musicianModalData = {
|
|
name,
|
|
role,
|
|
confidence: 'none',
|
|
bands: [],
|
|
appears_on_count: 0
|
|
};
|
|
}
|
|
}
|
|
|
|
function closeMusicianModal() {
|
|
musicianModalData = null;
|
|
}
|
|
|
|
/**
|
|
* Search for a performer by name (from track credits)
|
|
*/
|
|
function searchForPerformer(name: string) {
|
|
// Set search state with performer name, clear previous results to trigger auto-search
|
|
setSearchState({
|
|
query: name,
|
|
activeTab: 'all',
|
|
filterType: null,
|
|
albumResults: null,
|
|
trackResults: null,
|
|
artistResults: null,
|
|
allResults: null
|
|
});
|
|
navigateTo('search');
|
|
}
|
|
|
|
/**
|
|
* Navigate to the source of current playback context
|
|
*/
|
|
async function handleContextNavigation() {
|
|
const context = getCurrentContext();
|
|
if (!context) {
|
|
console.log('[ContextNav] No context available');
|
|
return;
|
|
}
|
|
|
|
console.log('[ContextNav] Navigating to:', context);
|
|
|
|
const focusTrackId = currentTrack?.id;
|
|
const requestFocus = (contextType: typeof context.type, contextId: string) => {
|
|
if (typeof focusTrackId === 'number') {
|
|
requestContextTrackFocus(contextType, contextId, focusTrackId);
|
|
}
|
|
};
|
|
|
|
try {
|
|
switch (context.type) {
|
|
case 'album':
|
|
// Navigate to album page
|
|
requestFocus('album', context.id);
|
|
await handleAlbumClick(context.id);
|
|
break;
|
|
|
|
case 'playlist':
|
|
// Navigate to playlist page
|
|
const playlistId = parseInt(context.id);
|
|
if (!isNaN(playlistId)) {
|
|
requestFocus('playlist', context.id);
|
|
selectedPlaylistId = playlistId;
|
|
navigateTo('playlist');
|
|
}
|
|
break;
|
|
|
|
case 'artist_top':
|
|
// Navigate to artist page
|
|
const artistId = parseInt(context.id);
|
|
if (!isNaN(artistId)) {
|
|
requestFocus('artist_top', context.id);
|
|
await handleArtistClick(artistId);
|
|
}
|
|
break;
|
|
|
|
case 'favorites':
|
|
// Navigate to favorites page
|
|
requestFocus('favorites', 'favorites');
|
|
navigateToFavorites('tracks');
|
|
break;
|
|
|
|
case 'home_list':
|
|
// Navigate to home page
|
|
navigateTo('home');
|
|
break;
|
|
|
|
case 'search':
|
|
// Navigate to search (could restore query if needed)
|
|
navigateTo('search');
|
|
break;
|
|
|
|
case 'radio':
|
|
// Radio is dynamic/endless - no specific page to navigate to
|
|
console.log('[ContextNav] Radio is currently playing');
|
|
break;
|
|
|
|
default:
|
|
console.warn('[ContextNav] Unknown context type:', context.type);
|
|
}
|
|
} catch (err) {
|
|
console.error('[ContextNav] Navigation failed:', err);
|
|
showToast($t('toast.failedNavigateSource'), 'error');
|
|
}
|
|
}
|
|
|
|
interface ArtistAlbumsResponse {
|
|
items: QobuzAlbum[];
|
|
total: number;
|
|
offset: number;
|
|
limit: number;
|
|
}
|
|
|
|
async function loadMoreArtistAlbums() {
|
|
if (!selectedArtist || isArtistAlbumsLoading) return;
|
|
|
|
const offset = selectedArtist.albumsFetched || 0;
|
|
if (offset >= selectedArtist.totalAlbums) return;
|
|
|
|
isArtistAlbumsLoading = true;
|
|
try {
|
|
const result = await invoke<ArtistAlbumsResponse>('get_artist_albums', {
|
|
artistId: selectedArtist.id,
|
|
limit: 200,
|
|
offset
|
|
});
|
|
|
|
if (result.items.length === 0) return;
|
|
|
|
selectedArtist = appendArtistAlbums(
|
|
selectedArtist,
|
|
result.items,
|
|
result.total,
|
|
result.offset + result.items.length
|
|
);
|
|
} catch (err) {
|
|
console.error('Failed to load more artist albums:', err);
|
|
showToast($t('toast.failedLoadMoreAlbums'), 'error');
|
|
} finally {
|
|
isArtistAlbumsLoading = false;
|
|
}
|
|
}
|
|
|
|
|
|
// Album-specific queue track builder (needs selectedAlbum context)
|
|
function buildAlbumQueueTrack(track: Track): BackendQueueTrack {
|
|
return buildQueueTrackFromAlbumTrack(
|
|
track,
|
|
selectedAlbum?.artwork || '',
|
|
selectedAlbum?.artist || 'Unknown Artist',
|
|
selectedAlbum?.title || '',
|
|
selectedAlbum?.id,
|
|
selectedAlbum?.artistId
|
|
);
|
|
}
|
|
|
|
async function fetchAlbumDetail(albumId: string): Promise<AlbumDetail | null> {
|
|
try {
|
|
const album = await invoke<QobuzAlbum>('get_album', { albumId });
|
|
return convertQobuzAlbum(album);
|
|
} catch (err) {
|
|
console.error('Failed to load album:', err);
|
|
showToast($t('toast.failedLoadAlbum'), 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function playAlbumById(albumId: string) {
|
|
const album = await fetchAlbumDetail(albumId);
|
|
if (!album?.tracks?.length) return;
|
|
|
|
const artwork = album.artwork || '';
|
|
const queueTracks: BackendQueueTrack[] = album.tracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || album.artist || 'Unknown Artist',
|
|
album: album.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: album.id,
|
|
artist_id: t.artistId ?? album.artistId
|
|
}));
|
|
|
|
await setQueue(queueTracks, 0, true);
|
|
const firstTrack = album.tracks[0];
|
|
const quality = firstTrack.hires && firstTrack.bitDepth && firstTrack.samplingRate
|
|
? `${firstTrack.bitDepth}bit/${firstTrack.samplingRate}kHz`
|
|
: firstTrack.hires
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
await playTrack({
|
|
id: firstTrack.id,
|
|
title: firstTrack.title,
|
|
artist: firstTrack.artist || album.artist || 'Unknown Artist',
|
|
album: album.title || '',
|
|
artwork,
|
|
duration: firstTrack.durationSeconds,
|
|
quality,
|
|
bitDepth: firstTrack.bitDepth,
|
|
samplingRate: firstTrack.samplingRate,
|
|
albumId: album.id,
|
|
artistId: firstTrack.artistId
|
|
});
|
|
}
|
|
|
|
async function queueAlbumNextById(albumId: string) {
|
|
const album = await fetchAlbumDetail(albumId);
|
|
if (!album?.tracks?.length) return;
|
|
|
|
const artwork = album.artwork || '';
|
|
for (let i = album.tracks.length - 1; i >= 0; i--) {
|
|
const t = album.tracks[i];
|
|
queueTrackNext({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || album.artist || 'Unknown Artist',
|
|
album: album.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: album.id,
|
|
artist_id: t.artistId ?? album.artistId
|
|
});
|
|
}
|
|
showToast($t('toast.playingTracksNext', { values: { count: album.tracks.length } }), 'success');
|
|
}
|
|
|
|
async function queueAlbumLaterById(albumId: string) {
|
|
const album = await fetchAlbumDetail(albumId);
|
|
if (!album?.tracks?.length) return;
|
|
|
|
const artwork = album.artwork || '';
|
|
const queueTracks: BackendQueueTrack[] = album.tracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || album.artist || 'Unknown Artist',
|
|
album: album.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: album.id,
|
|
artist_id: t.artistId ?? album.artistId
|
|
}));
|
|
|
|
const success = await addTracksToQueue(queueTracks);
|
|
if (success) {
|
|
showToast($t('toast.addedTracksToQueue', { values: { count: queueTracks.length } }), 'success');
|
|
} else {
|
|
showToast($t('toast.failedAddToQueue'), 'error');
|
|
}
|
|
}
|
|
|
|
function shareAlbumQobuzLinkById(albumId: string) {
|
|
const url = `https://play.qobuz.com/album/${albumId}`;
|
|
writeText(url);
|
|
showToast($t('toast.albumLinkCopied'), 'success');
|
|
}
|
|
|
|
async function shareAlbumSonglinkById(albumId: string) {
|
|
try {
|
|
showToast($t('toast.fetchingAlbumLink'), 'info');
|
|
const album = await fetchAlbumDetail(albumId);
|
|
if (!album) {
|
|
showToast($t('toast.couldNotFetchDetails'), 'error');
|
|
return;
|
|
}
|
|
const response = await invoke<{ pageUrl: string }>('share_album_songlink', {
|
|
upc: album.upc || null,
|
|
albumId: album.id,
|
|
title: album.title,
|
|
artist: album.artist
|
|
});
|
|
writeText(response.pageUrl);
|
|
showToast($t('toast.albumLinkCopiedSonglink'), 'success');
|
|
} catch (err) {
|
|
console.error('Failed to get Album.link:', err);
|
|
showToast($t('toast.albumLinkError', { values: { error: String(err) } }), 'error');
|
|
}
|
|
}
|
|
|
|
async function downloadAlbumById(albumId: string) {
|
|
const album = await fetchAlbumDetail(albumId);
|
|
if (!album) return;
|
|
|
|
const tracksToDownload = album.tracks.filter(track => {
|
|
const status = getOfflineCacheState(track.id).status;
|
|
return status === 'none' || status === 'failed';
|
|
});
|
|
|
|
if (tracksToDownload.length === 0) {
|
|
showToast($t('toast.allTracksOffline'), 'info');
|
|
return;
|
|
}
|
|
|
|
showToast($t('toast.preparingTracksOffline', { values: { count: tracksToDownload.length, album: album.title } }), 'info');
|
|
|
|
for (const track of tracksToDownload) {
|
|
try {
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || album.artist || 'Unknown',
|
|
album: album.title,
|
|
albumId: album.id,
|
|
durationSecs: track.durationSeconds,
|
|
quality: track.quality || '-',
|
|
bitDepth: track.bitDepth,
|
|
sampleRate: track.samplingRate,
|
|
});
|
|
} catch (err) {
|
|
console.error(`Failed to queue download for "${track.title}":`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============ Playlist Handler Functions (for Search) ============
|
|
|
|
interface PlaylistData {
|
|
id: number;
|
|
name: string;
|
|
owner: { id: number; name: string };
|
|
images?: string[];
|
|
tracks_count: number;
|
|
duration: number;
|
|
tracks?: {
|
|
items: Array<{
|
|
id: number;
|
|
title: string;
|
|
duration: number;
|
|
performer?: { id?: number; name: string };
|
|
album?: {
|
|
id: string;
|
|
title: string;
|
|
image?: { small?: string; thumbnail?: string; large?: string };
|
|
};
|
|
hires_streamable?: boolean;
|
|
maximum_bit_depth?: number;
|
|
maximum_sampling_rate?: number;
|
|
}>;
|
|
};
|
|
}
|
|
|
|
async function fetchPlaylistData(playlistId: number): Promise<PlaylistData | null> {
|
|
try {
|
|
const playlist = await invoke<PlaylistData>('get_playlist', { playlistId });
|
|
return playlist;
|
|
} catch (err) {
|
|
console.error('Failed to load playlist:', err);
|
|
showToast($t('toast.failedLoadPlaylist'), 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function playPlaylistById(playlistId: number) {
|
|
const playlist = await fetchPlaylistData(playlistId);
|
|
if (!playlist?.tracks?.items?.length) {
|
|
showToast($t('toast.playlistNoTracks'), 'info');
|
|
return;
|
|
}
|
|
|
|
const tracks = playlist.tracks.items;
|
|
const queueTracks: BackendQueueTrack[] = tracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.performer?.name || 'Unknown Artist',
|
|
album: t.album?.title || '',
|
|
duration_secs: t.duration,
|
|
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small || null,
|
|
hires: t.hires_streamable ?? false,
|
|
bit_depth: t.maximum_bit_depth ?? null,
|
|
sample_rate: t.maximum_sampling_rate ?? null,
|
|
is_local: false,
|
|
album_id: t.album?.id,
|
|
artist_id: t.performer?.id
|
|
}));
|
|
|
|
await setQueue(queueTracks, 0);
|
|
|
|
const firstTrack = tracks[0];
|
|
const artwork = firstTrack.album?.image?.large || firstTrack.album?.image?.thumbnail || firstTrack.album?.image?.small || '';
|
|
const quality = firstTrack.hires_streamable && firstTrack.maximum_bit_depth && firstTrack.maximum_sampling_rate
|
|
? `${firstTrack.maximum_bit_depth}bit/${firstTrack.maximum_sampling_rate}kHz`
|
|
: firstTrack.hires_streamable
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
await playTrack({
|
|
id: firstTrack.id,
|
|
title: firstTrack.title,
|
|
artist: firstTrack.performer?.name || 'Unknown Artist',
|
|
album: firstTrack.album?.title || '',
|
|
artwork,
|
|
duration: firstTrack.duration,
|
|
quality,
|
|
bitDepth: firstTrack.maximum_bit_depth,
|
|
samplingRate: firstTrack.maximum_sampling_rate,
|
|
albumId: firstTrack.album?.id,
|
|
artistId: firstTrack.performer?.id
|
|
});
|
|
}
|
|
|
|
async function queuePlaylistNextById(playlistId: number) {
|
|
const playlist = await fetchPlaylistData(playlistId);
|
|
if (!playlist?.tracks?.items?.length) {
|
|
showToast($t('toast.playlistNoTracks'), 'info');
|
|
return;
|
|
}
|
|
|
|
const tracks = playlist.tracks.items;
|
|
// Add in reverse order so they play in correct sequence
|
|
for (let i = tracks.length - 1; i >= 0; i--) {
|
|
const t = tracks[i];
|
|
queueTrackNext({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.performer?.name || 'Unknown Artist',
|
|
album: t.album?.title || '',
|
|
duration_secs: t.duration,
|
|
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small || null,
|
|
hires: t.hires_streamable ?? false,
|
|
bit_depth: t.maximum_bit_depth ?? null,
|
|
sample_rate: t.maximum_sampling_rate ?? null,
|
|
is_local: false,
|
|
album_id: t.album?.id,
|
|
artist_id: t.performer?.id
|
|
});
|
|
}
|
|
showToast($t('toast.playingTracksNext', { values: { count: tracks.length } }), 'success');
|
|
}
|
|
|
|
async function queuePlaylistLaterById(playlistId: number) {
|
|
const playlist = await fetchPlaylistData(playlistId);
|
|
if (!playlist?.tracks?.items?.length) {
|
|
showToast($t('toast.playlistNoTracks'), 'info');
|
|
return;
|
|
}
|
|
|
|
const tracks = playlist.tracks.items;
|
|
const queueTracks: BackendQueueTrack[] = tracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.performer?.name || 'Unknown Artist',
|
|
album: t.album?.title || '',
|
|
duration_secs: t.duration,
|
|
artwork_url: t.album?.image?.large || t.album?.image?.thumbnail || t.album?.image?.small || null,
|
|
hires: t.hires_streamable ?? false,
|
|
bit_depth: t.maximum_bit_depth ?? null,
|
|
sample_rate: t.maximum_sampling_rate ?? null,
|
|
is_local: false,
|
|
album_id: t.album?.id,
|
|
artist_id: t.performer?.id
|
|
}));
|
|
|
|
const success = await addTracksToQueue(queueTracks);
|
|
if (success) {
|
|
showToast($t('toast.addedTracksToQueue', { values: { count: queueTracks.length } }), 'success');
|
|
} else {
|
|
showToast($t('toast.failedAddToQueue'), 'error');
|
|
}
|
|
}
|
|
|
|
async function copyPlaylistToLibraryById(playlistId: number) {
|
|
try {
|
|
showToast($t('toast.copyingToLibrary'), 'info');
|
|
await invoke('subscribe_playlist', { playlistId });
|
|
sidebarRef?.refreshPlaylists();
|
|
showToast($t('toast.playlistCopied'), 'success');
|
|
} catch (err) {
|
|
console.error('Failed to copy playlist:', err);
|
|
showToast($t('toast.failedCopyPlaylist', { values: { error: String(err) } }), 'error');
|
|
}
|
|
}
|
|
|
|
function sharePlaylistQobuzLinkById(playlistId: number) {
|
|
const url = `https://play.qobuz.com/playlist/${playlistId}`;
|
|
writeText(url);
|
|
showToast($t('toast.playlistLinkCopied'), 'success');
|
|
}
|
|
|
|
async function removePlaylistFavoriteById(playlistId: number) {
|
|
try {
|
|
await invoke('playlist_set_favorite', { playlistId, favorite: false });
|
|
showToast($t('toast.playlistRemovedFavorites'), 'success');
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
} catch (err) {
|
|
console.error('Failed to remove playlist favorite:', err);
|
|
showToast($t('toast.failedRemoveFavorites', { values: { error: String(err) } }), 'error');
|
|
}
|
|
}
|
|
|
|
// Playback Functions - QobuzTrack from search results
|
|
async function handleTrackPlay(track: QobuzTrack) {
|
|
console.log('Playing track:', track);
|
|
|
|
const artwork = track.album?.image?.large || track.album?.image?.thumbnail || track.album?.image?.small || '';
|
|
const quality = track.hires_streamable && track.maximum_bit_depth && track.maximum_sampling_rate
|
|
? `${track.maximum_bit_depth}bit/${track.maximum_sampling_rate}kHz`
|
|
: track.hires_streamable
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
await playTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.performer?.name || 'Unknown Artist',
|
|
album: track.album?.title || '',
|
|
artwork,
|
|
duration: track.duration,
|
|
quality,
|
|
bitDepth: track.maximum_bit_depth,
|
|
samplingRate: track.maximum_sampling_rate,
|
|
albumId: track.album?.id,
|
|
artistId: track.performer?.id
|
|
});
|
|
}
|
|
|
|
// Handle track play from album detail view
|
|
async function handleAlbumTrackPlay(track: Track) {
|
|
console.log('Playing album track:', track);
|
|
|
|
// ALWAYS create context when playing from an album
|
|
// The setting only affects menu options visibility, not implicit behavior
|
|
if (selectedAlbum?.tracks) {
|
|
const trackIndex = selectedAlbum.tracks.findIndex(t => t.id === track.id);
|
|
const trackIds = selectedAlbum.tracks.map(t => t.id);
|
|
|
|
console.log('[Album] Creating context with', trackIds.length, 'tracks, starting at', trackIndex);
|
|
await setPlaybackContext(
|
|
'album',
|
|
selectedAlbum.id,
|
|
selectedAlbum.title,
|
|
'qobuz',
|
|
trackIds,
|
|
trackIndex >= 0 ? trackIndex : 0
|
|
);
|
|
console.log('[Album] Context created - stack icon should appear');
|
|
} else {
|
|
console.log('[Album] No album tracks found, cannot create context');
|
|
}
|
|
|
|
const artwork = selectedAlbum?.artwork || '';
|
|
const quality = track.hires && track.bitDepth && track.samplingRate
|
|
? `${track.bitDepth}bit/${track.samplingRate}kHz`
|
|
: track.hires
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
// Build queue from album tracks before playing (filter blacklisted artists)
|
|
if (selectedAlbum?.tracks) {
|
|
console.log('[Album Queue] Building queue from', selectedAlbum.tracks.length, 'album tracks');
|
|
|
|
// Filter out blacklisted tracks
|
|
const playableTracks = selectedAlbum.tracks.filter(t => {
|
|
const artistId = t.artistId ?? selectedAlbum.artistId;
|
|
return !artistId || !isArtistBlacklisted(artistId);
|
|
});
|
|
|
|
const trackIndex = playableTracks.findIndex(t => t.id === track.id);
|
|
const queueTracks: BackendQueueTrack[] = playableTracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
|
|
album: selectedAlbum?.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: selectedAlbum?.id,
|
|
artist_id: t.artistId ?? selectedAlbum?.artistId
|
|
}));
|
|
|
|
console.log('[Album Queue] Mapped to', queueTracks.length, 'queue tracks (filtered), startIndex:', trackIndex);
|
|
console.log('[Album Queue] Track IDs:', queueTracks.map(t => t.id));
|
|
|
|
// Set the queue starting at the clicked track
|
|
await setQueue(queueTracks, trackIndex >= 0 ? trackIndex : 0, true);
|
|
|
|
console.log('[Album Queue] Queue set successfully');
|
|
}
|
|
|
|
// Play track using unified service
|
|
await playTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || selectedAlbum?.artist || 'Unknown Artist',
|
|
album: selectedAlbum?.title || '',
|
|
artwork,
|
|
duration: track.durationSeconds,
|
|
quality,
|
|
bitDepth: track.bitDepth,
|
|
samplingRate: track.samplingRate,
|
|
albumId: selectedAlbum?.id,
|
|
artistId: track.artistId
|
|
});
|
|
}
|
|
|
|
// Playback controls (delegating to playerStore)
|
|
function handleSeek(time: number) {
|
|
playerSeek(time);
|
|
}
|
|
|
|
function handleVolumeChange(newVolume: number) {
|
|
playerSetVolume(newVolume);
|
|
// Persist volume change to session
|
|
saveSessionVolume(newVolume / 100);
|
|
}
|
|
|
|
async function toggleShuffle() {
|
|
const result = await queueToggleShuffle();
|
|
if (result.success) {
|
|
showToast(result.enabled ? $t('toast.shuffleEnabled') : $t('toast.shuffleDisabled'), 'info');
|
|
// Persist playback mode to session
|
|
saveSessionPlaybackMode(result.enabled, repeatMode);
|
|
}
|
|
}
|
|
|
|
async function toggleRepeat() {
|
|
const result = await queueToggleRepeat();
|
|
if (result.success) {
|
|
const messages: Record<RepeatMode, string> = {
|
|
off: $t('toast.repeatOff'),
|
|
all: $t('toast.repeatAll'),
|
|
one: $t('toast.repeatOne')
|
|
};
|
|
showToast(messages[result.mode], 'info');
|
|
// Persist playback mode to session
|
|
saveSessionPlaybackMode(isShuffle, result.mode);
|
|
}
|
|
}
|
|
|
|
async function toggleFavorite() {
|
|
if (!currentTrack) return;
|
|
|
|
const result = await toggleTrackFavorite(currentTrack.id, isFavorite);
|
|
if (result.success) {
|
|
setIsFavorite(result.isFavorite);
|
|
showToast(result.isFavorite ? $t('toast.addedToFavorites') : $t('toast.removedFromFavorites'), 'success');
|
|
} else {
|
|
showToast($t('toast.failedUpdateFavorites'), 'error');
|
|
}
|
|
}
|
|
|
|
// Add to Playlist handler for Now Playing track
|
|
function openAddToPlaylistModal() {
|
|
if (!currentTrack) return;
|
|
userPlaylists = sidebarRef?.getPlaylists() ?? [];
|
|
openPlaylistModal('addTrack', [currentTrack.id]);
|
|
}
|
|
|
|
// Skip track handlers - wired to backend queue via queueStore
|
|
async function handleSkipBack() {
|
|
const playerState = getPlayerState();
|
|
if (!playerState.currentTrack || playerState.isSkipping) return;
|
|
// If more than 3 seconds in, restart track; otherwise go to previous
|
|
if (playerState.currentTime > 3) {
|
|
handleSeek(0);
|
|
return;
|
|
}
|
|
|
|
setIsSkipping(true);
|
|
try {
|
|
const prevTrack = await previousTrack();
|
|
if (prevTrack) {
|
|
await playQueueTrack(prevTrack);
|
|
} else {
|
|
// No previous track, just restart
|
|
handleSeek(0);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to go to previous track:', err);
|
|
showToast($t('toast.failedPreviousTrack'), 'error');
|
|
} finally {
|
|
setIsSkipping(false);
|
|
}
|
|
}
|
|
|
|
async function handleSkipForward() {
|
|
const playerState = getPlayerState();
|
|
if (!playerState.currentTrack || playerState.isSkipping) return;
|
|
|
|
setIsSkipping(true);
|
|
try {
|
|
const nextTrackResult = await nextTrack();
|
|
if (nextTrackResult) {
|
|
await playQueueTrack(nextTrackResult);
|
|
} else {
|
|
// No next track - stop playback
|
|
await stopPlayback();
|
|
setIsPlaying(false);
|
|
showToast($t('toast.queueEnded'), 'info');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to go to next track:', err);
|
|
showToast($t('toast.failedNextTrack'), 'error');
|
|
} finally {
|
|
setIsSkipping(false);
|
|
}
|
|
}
|
|
|
|
// Check if a track is available for playback (handles offline mode)
|
|
async function isTrackAvailable(track: BackendQueueTrack): Promise<boolean> {
|
|
// Always available when online
|
|
if (!offlineStatus.isOffline) return true;
|
|
|
|
// Local tracks are always available
|
|
if (isLocalTrack(track.id)) return true;
|
|
|
|
// Check if Qobuz track has a local copy
|
|
try {
|
|
const localIds = await invoke<number[]>('playlist_get_tracks_with_local_copies', {
|
|
trackIds: [track.id]
|
|
});
|
|
return localIds.includes(track.id);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Helper to play a track from the queue (with offline skip support)
|
|
async function playQueueTrack(track: BackendQueueTrack, skippedIds = new Set<number>()) {
|
|
const isLocal = isLocalTrack(track.id);
|
|
|
|
// In offline mode, check if track is available
|
|
if (offlineStatus.isOffline && !isLocal) {
|
|
const available = await isTrackAvailable(track);
|
|
if (!available) {
|
|
// Skip to next track (prevent infinite loop)
|
|
if (skippedIds.has(track.id)) {
|
|
// Already tried this track, stop to prevent infinite loop
|
|
setQueueEnded(true);
|
|
showToast($t('toast.noAvailableTracks'), 'info');
|
|
return;
|
|
}
|
|
skippedIds.add(track.id);
|
|
|
|
// Get next track and try to play it
|
|
const nextTrackResult = await nextTrack();
|
|
if (nextTrackResult) {
|
|
await playQueueTrack(nextTrackResult, skippedIds);
|
|
} else {
|
|
setQueueEnded(true);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Reset queue ended flag when playing a new track
|
|
setQueueEnded(false);
|
|
|
|
// Determine quality string from track data
|
|
const quality = isLocal
|
|
? 'Local'
|
|
: track.bit_depth && track.sample_rate
|
|
? `${track.bit_depth}bit/${track.sample_rate}kHz`
|
|
: track.hires
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
// Play track using unified service
|
|
await playTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
artwork: track.artwork_url || '',
|
|
duration: track.duration_secs,
|
|
quality,
|
|
bitDepth: track.bit_depth ?? undefined,
|
|
// Only convert Hz to kHz for local tracks. Qobuz tracks are already in kHz.
|
|
samplingRate: isLocal && track.sample_rate ? track.sample_rate / 1000 : track.sample_rate ?? undefined,
|
|
isLocal,
|
|
albumId: track.album_id ?? undefined,
|
|
artistId: track.artist_id ?? undefined
|
|
}, { isLocal, showLoadingToast: false });
|
|
}
|
|
|
|
// Play a specific track from the queue panel
|
|
async function handleQueueTrackPlay(trackId: string) {
|
|
try {
|
|
// Find the index in the queue
|
|
const queueState = await getBackendQueueState();
|
|
if (!queueState) {
|
|
showToast($t('toast.failedPlayTrack'), 'error');
|
|
return;
|
|
}
|
|
|
|
const allTracks = [queueState.current_track, ...queueState.upcoming].filter(Boolean) as BackendQueueTrack[];
|
|
const trackIndex = allTracks.findIndex(t => String(t.id) === trackId);
|
|
|
|
if (trackIndex >= 0) {
|
|
// If it's the current track (index 0), just ensure it's playing
|
|
if (trackIndex === 0 && queueState.current_index !== null) {
|
|
// Already current, nothing to do
|
|
return;
|
|
}
|
|
|
|
// Play by index (accounting for current track offset)
|
|
const actualIndex = queueState.current_index !== null
|
|
? queueState.current_index + trackIndex
|
|
: trackIndex;
|
|
|
|
const track = await playQueueIndex(actualIndex);
|
|
if (track) {
|
|
await playQueueTrack(track);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to play queue track:', err);
|
|
showToast($t('toast.failedPlayTrack'), 'error');
|
|
}
|
|
}
|
|
|
|
// Clear the queue
|
|
async function handleClearQueue() {
|
|
const success = await clearQueue();
|
|
if (success) {
|
|
showToast($t('toast.queueCleared'), 'info');
|
|
} else {
|
|
showToast($t('toast.failedClearQueue'), 'error');
|
|
}
|
|
}
|
|
|
|
// Reorder tracks in the queue
|
|
async function handleQueueReorder(fromIndex: number, toIndex: number) {
|
|
const success = await moveQueueTrack(fromIndex, toIndex);
|
|
if (!success) {
|
|
showToast($t('toast.failedReorderQueue'), 'error');
|
|
}
|
|
}
|
|
|
|
// Save current queue as a new playlist
|
|
function handleSaveQueueAsPlaylist() {
|
|
// Collect all track IDs from queue (current track + upcoming)
|
|
const trackIds: number[] = [];
|
|
|
|
// Add current track if present
|
|
if (currentTrack) {
|
|
trackIds.push(currentTrack.id);
|
|
}
|
|
|
|
// Add all upcoming tracks
|
|
for (const track of queue) {
|
|
const numericId = parseInt(track.id, 10);
|
|
if (!isNaN(numericId) && !trackIds.includes(numericId)) {
|
|
trackIds.push(numericId);
|
|
}
|
|
}
|
|
|
|
if (trackIds.length === 0) {
|
|
showToast($t('toast.queueEmpty'), 'info');
|
|
return;
|
|
}
|
|
|
|
// Open playlist modal in addTrack mode with queue tracks
|
|
openAddToPlaylist(trackIds);
|
|
// Close queue panel
|
|
closeQueue();
|
|
}
|
|
|
|
// Toggle infinite play mode (auto-refill queue with similar tracks)
|
|
function handleToggleInfinitePlay() {
|
|
infinitePlayEnabled = !infinitePlayEnabled;
|
|
// Persist to localStorage
|
|
try {
|
|
localStorage.setItem('qbz-infinite-play', JSON.stringify(infinitePlayEnabled));
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
showToast(infinitePlayEnabled ? $t('toast.infinitePlayEnabled') : $t('toast.infinitePlayDisabled'), 'info');
|
|
}
|
|
|
|
// Play a track from history
|
|
async function handlePlayHistoryTrack(trackId: string) {
|
|
try {
|
|
// Get the full queue state to find the track in history
|
|
const queueState = await getBackendQueueState();
|
|
if (!queueState) {
|
|
showToast($t('toast.failedPlayTrack'), 'error');
|
|
return;
|
|
}
|
|
|
|
// Find the track in history
|
|
const numericId = parseInt(trackId, 10);
|
|
const historyTrack = queueState.history.find(t => t.id === numericId);
|
|
if (!historyTrack) {
|
|
showToast($t('toast.trackNotInHistory'), 'error');
|
|
return;
|
|
}
|
|
|
|
// Play the track directly
|
|
await handleTrackPlay({
|
|
id: historyTrack.id,
|
|
title: historyTrack.title,
|
|
artist: historyTrack.artist,
|
|
album: historyTrack.album,
|
|
duration: historyTrack.duration_secs,
|
|
artwork: historyTrack.artwork_url || '',
|
|
quality: historyTrack.hires ? 'Hi-Res' : 'CD',
|
|
bitDepth: historyTrack.bit_depth || 16,
|
|
samplingRate: historyTrack.sample_rate || 44100
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to play history track:', err);
|
|
showToast($t('toast.failedPlayTrack'), 'error');
|
|
}
|
|
}
|
|
|
|
// Play all tracks from album (starting from first non-blacklisted track)
|
|
async function handlePlayAllAlbum() {
|
|
if (!selectedAlbum?.tracks?.length) return;
|
|
// Find first non-blacklisted track
|
|
const firstPlayableTrack = selectedAlbum.tracks.find(t => {
|
|
const artistId = t.artistId ?? selectedAlbum.artistId;
|
|
return !artistId || !isArtistBlacklisted(artistId);
|
|
});
|
|
if (!firstPlayableTrack) return;
|
|
await handleAlbumTrackPlay(firstPlayableTrack);
|
|
}
|
|
|
|
// Shuffle play all tracks from album
|
|
async function handleShuffleAlbum() {
|
|
if (!selectedAlbum?.tracks?.length) return;
|
|
|
|
// Filter out blacklisted tracks
|
|
const playableTracks = selectedAlbum.tracks.filter(t => {
|
|
const artistId = t.artistId ?? selectedAlbum.artistId;
|
|
return !artistId || !isArtistBlacklisted(artistId);
|
|
});
|
|
|
|
if (playableTracks.length === 0) return;
|
|
|
|
console.log('[Album Shuffle] Starting shuffle with', playableTracks.length, 'playable tracks');
|
|
|
|
// Set shuffle mode first
|
|
try {
|
|
await invoke('set_shuffle', { enabled: true });
|
|
isShuffle = true;
|
|
} catch (err) {
|
|
console.error('Failed to enable shuffle:', err);
|
|
}
|
|
|
|
// Pick a random track to start with
|
|
const randomIndex = Math.floor(Math.random() * playableTracks.length);
|
|
const randomTrack = playableTracks[randomIndex];
|
|
|
|
console.log('[Album Shuffle] Starting from random track index:', randomIndex, 'track:', randomTrack.title);
|
|
|
|
// Play from random track (queue will be shuffled by backend)
|
|
await handleAlbumTrackPlay(randomTrack);
|
|
showToast($t('toast.shuffleEnabled'), 'info');
|
|
}
|
|
|
|
// Add all album tracks next in queue (after current track)
|
|
async function handleAddAlbumToQueueNext() {
|
|
if (!selectedAlbum?.tracks?.length) return;
|
|
|
|
// Filter out blacklisted tracks
|
|
const playableTracks = selectedAlbum.tracks.filter(t => {
|
|
const artistId = t.artistId ?? selectedAlbum.artistId;
|
|
return !artistId || !isArtistBlacklisted(artistId);
|
|
});
|
|
|
|
if (playableTracks.length === 0) return;
|
|
|
|
const artwork = selectedAlbum.artwork || '';
|
|
// Add in reverse order so first track ends up right after current
|
|
for (let i = playableTracks.length - 1; i >= 0; i--) {
|
|
const t = playableTracks[i];
|
|
queueTrackNext({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
|
|
album: selectedAlbum?.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: selectedAlbum?.id,
|
|
artist_id: t.artistId ?? selectedAlbum?.artistId
|
|
});
|
|
}
|
|
showToast($t('toast.playingTracksNext', { values: { count: playableTracks.length } }), 'success');
|
|
}
|
|
|
|
// Add all album tracks to end of queue
|
|
async function handleAddAlbumToQueueLater() {
|
|
if (!selectedAlbum?.tracks?.length) return;
|
|
|
|
// Filter out blacklisted tracks
|
|
const playableTracks = selectedAlbum.tracks.filter(t => {
|
|
const artistId = t.artistId ?? selectedAlbum.artistId;
|
|
return !artistId || !isArtistBlacklisted(artistId);
|
|
});
|
|
|
|
if (playableTracks.length === 0) return;
|
|
|
|
const artwork = selectedAlbum.artwork || '';
|
|
const queueTracks: BackendQueueTrack[] = playableTracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist || selectedAlbum?.artist || 'Unknown Artist',
|
|
album: selectedAlbum?.title || '',
|
|
duration_secs: t.durationSeconds,
|
|
artwork_url: artwork || null,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bitDepth ?? null,
|
|
sample_rate: t.samplingRate ?? null,
|
|
is_local: false,
|
|
album_id: selectedAlbum?.id,
|
|
artist_id: t.artistId ?? selectedAlbum?.artistId
|
|
}));
|
|
|
|
const success = await addTracksToQueue(queueTracks);
|
|
if (success) {
|
|
showToast($t('toast.addedTracksToQueue', { values: { count: queueTracks.length } }), 'success');
|
|
} else {
|
|
showToast($t('toast.failedAddToQueue'), 'error');
|
|
}
|
|
}
|
|
|
|
async function addAlbumToPlaylistById(albumId: string) {
|
|
const album = await fetchAlbumDetail(albumId);
|
|
addAlbumToPlaylist(album);
|
|
}
|
|
|
|
function addAlbumToPlaylist(album: AlbumDetail | null) {
|
|
if (!album?.tracks?.length) return;
|
|
const trackIds = album.tracks.map(t => t.id);
|
|
openAddToPlaylist(trackIds);
|
|
}
|
|
|
|
// Share album Qobuz link
|
|
function shareAlbumQobuzLink() {
|
|
if (!selectedAlbum?.id) return;
|
|
const url = `https://play.qobuz.com/album/${selectedAlbum.id}`;
|
|
writeText(url);
|
|
showToast($t('toast.albumLinkCopied'), 'success');
|
|
}
|
|
|
|
// Share album via album.link
|
|
async function shareAlbumSonglink() {
|
|
if (!selectedAlbum?.id) return;
|
|
try {
|
|
showToast($t('toast.fetchingAlbumLink'), 'info');
|
|
const response = await invoke<{ pageUrl: string }>('share_album_songlink', {
|
|
upc: selectedAlbum.upc || null,
|
|
albumId: selectedAlbum.id,
|
|
title: selectedAlbum.title,
|
|
artist: selectedAlbum.artist
|
|
});
|
|
writeText(response.pageUrl);
|
|
showToast($t('toast.albumLinkCopiedSonglink'), 'success');
|
|
} catch (err) {
|
|
console.error('Failed to get Album.link:', err);
|
|
showToast($t('toast.albumLinkError', { values: { error: String(err) } }), 'error');
|
|
}
|
|
}
|
|
|
|
function handleAlbumTrackPlayNext(track: Track) {
|
|
queueTrackNext(buildAlbumQueueTrack(track));
|
|
}
|
|
|
|
function handleAlbumTrackPlayLater(track: Track) {
|
|
queueTrackLater(buildAlbumQueueTrack(track));
|
|
}
|
|
|
|
// Download handlers
|
|
async function handleTrackDownload(track: Track) {
|
|
try {
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || selectedAlbum?.artist || 'Unknown',
|
|
album: selectedAlbum?.title,
|
|
albumId: selectedAlbum?.id,
|
|
durationSecs: track.durationSeconds,
|
|
quality: track.quality || '-',
|
|
bitDepth: track.bitDepth,
|
|
sampleRate: track.samplingRate,
|
|
});
|
|
showToast($t('toast.preparingTrackOffline', { values: { title: track.title } }), 'info');
|
|
} catch (err) {
|
|
console.error('Failed to cache for offline:', err);
|
|
showToast($t('toast.failedPrepareOffline'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleTrackRemoveDownload(trackId: number) {
|
|
try {
|
|
await removeCachedTrack(trackId);
|
|
showToast($t('toast.removedFromOffline'), 'info');
|
|
} catch (err) {
|
|
console.error('Failed to remove from offline:', err);
|
|
showToast($t('toast.failedRemoveOffline'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleTrackOpenFolder(trackId: number) {
|
|
try {
|
|
await openTrackFolder(trackId);
|
|
} catch (err) {
|
|
console.error('Failed to open folder:', err);
|
|
showToast($t('toast.failedOpenFolder'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleTrackReDownload(track: Track | DisplayTrack) {
|
|
try {
|
|
// Re-download uses the same download function - backend handles overwriting
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || selectedAlbum?.artist || 'Unknown',
|
|
album: 'album' in track ? track.album : selectedAlbum?.title,
|
|
albumId: 'albumId' in track ? track.albumId : selectedAlbum?.id,
|
|
durationSecs: 'durationSeconds' in track ? track.durationSeconds : track.duration,
|
|
quality: 'quality' in track ? track.quality || '-' : '-',
|
|
bitDepth: 'bitDepth' in track ? track.bitDepth : undefined,
|
|
sampleRate: 'samplingRate' in track ? track.samplingRate : undefined,
|
|
});
|
|
showToast($t('toast.refreshingTrackOffline', { values: { title: track.title } }), 'info');
|
|
} catch (err) {
|
|
console.error('Failed to refresh offline copy:', err);
|
|
showToast($t('toast.failedRefreshOffline'), 'error');
|
|
}
|
|
}
|
|
|
|
function checkTrackDownloaded(trackId: number): boolean {
|
|
return getOfflineCacheState(trackId).status === 'ready';
|
|
}
|
|
|
|
async function handleDownloadAlbum() {
|
|
if (!selectedAlbum) return;
|
|
|
|
const tracksToDownload = selectedAlbum.tracks.filter(track => {
|
|
const status = getOfflineCacheState(track.id).status;
|
|
return status === 'none' || status === 'failed';
|
|
});
|
|
|
|
if (tracksToDownload.length === 0) {
|
|
showToast($t('toast.allTracksOffline'), 'info');
|
|
return;
|
|
}
|
|
|
|
showToast($t('toast.preparingTracksOffline', { values: { count: tracksToDownload.length, album: selectedAlbum.title } }), 'info');
|
|
|
|
for (const track of tracksToDownload) {
|
|
try {
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || selectedAlbum.artist || 'Unknown',
|
|
album: selectedAlbum.title,
|
|
albumId: selectedAlbum.id,
|
|
durationSecs: track.durationSeconds,
|
|
quality: track.quality || '-',
|
|
bitDepth: track.bitDepth,
|
|
sampleRate: track.samplingRate,
|
|
});
|
|
} catch (err) {
|
|
console.error(`Failed to queue "${track.title}" for offline:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleOpenAlbumFolder() {
|
|
if (!selectedAlbum) return;
|
|
|
|
try {
|
|
await openAlbumFolder(selectedAlbum.id);
|
|
} catch (err) {
|
|
console.error('Failed to open album folder:', err);
|
|
showToast($t('toast.failedOpenAlbumFolder'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleReDownloadAlbum() {
|
|
if (!selectedAlbum) return;
|
|
|
|
showToast($t('toast.refreshingAlbumOffline', { values: { album: selectedAlbum.title } }), 'info');
|
|
|
|
for (const track of selectedAlbum.tracks) {
|
|
try {
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || selectedAlbum.artist || 'Unknown',
|
|
album: selectedAlbum.title,
|
|
albumId: selectedAlbum.id,
|
|
durationSecs: track.durationSeconds,
|
|
quality: track.quality || '-',
|
|
bitDepth: track.bitDepth,
|
|
sampleRate: track.samplingRate,
|
|
});
|
|
} catch (err) {
|
|
console.error(`Failed to refresh "${track.title}" for offline:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function openAlbumFolderById(albumId: string) {
|
|
try {
|
|
await openAlbumFolder(albumId);
|
|
} catch (err) {
|
|
console.error('Failed to open album folder:', err);
|
|
showToast($t('toast.failedOpenAlbumFolder'), 'error');
|
|
}
|
|
}
|
|
|
|
async function reDownloadAlbumById(albumId: string) {
|
|
try {
|
|
const album = await invoke<QobuzAlbum>('get_album', { albumId });
|
|
if (!album || !album.tracks || album.tracks.data.length === 0) {
|
|
showToast($t('toast.failedLoadAlbumRefresh'), 'error');
|
|
return;
|
|
}
|
|
|
|
showToast($t('toast.refreshingAlbumOffline', { values: { album: album.title } }), 'info');
|
|
|
|
for (const track of album.tracks.data) {
|
|
try {
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.performer?.name || album.artist?.name || 'Unknown',
|
|
album: album.title,
|
|
albumId: album.id,
|
|
durationSecs: track.duration,
|
|
quality: track.hires ? 'Hi-Res' : '-',
|
|
bitDepth: track.maximum_bit_depth,
|
|
sampleRate: track.maximum_sampling_rate,
|
|
});
|
|
} catch (err) {
|
|
console.error(`Failed to refresh "${track.title}" for offline:`, err);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load album:', err);
|
|
showToast($t('toast.failedLoadAlbumRefresh'), 'error');
|
|
}
|
|
}
|
|
|
|
function getTrackOfflineCacheStatus(trackId: number) {
|
|
// Access downloadStateVersion to trigger reactivity
|
|
void downloadStateVersion;
|
|
return getOfflineCacheState(trackId);
|
|
}
|
|
|
|
async function handleDisplayTrackDownload(track: PlaylistTrack) {
|
|
try {
|
|
const quality = track.bitDepth && track.samplingRate
|
|
? `${track.bitDepth}bit/${track.samplingRate}kHz`
|
|
: track.hires
|
|
? 'Hi-Res'
|
|
: '-';
|
|
await cacheTrackForOffline({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || 'Unknown',
|
|
album: track.album,
|
|
albumId: track.albumId,
|
|
durationSecs: track.durationSeconds,
|
|
quality,
|
|
bitDepth: track.bitDepth,
|
|
sampleRate: track.samplingRate,
|
|
});
|
|
showToast($t('toast.preparingTrackOffline', { values: { title: track.title } }), 'info');
|
|
} catch (err) {
|
|
console.error('Failed to prepare for offline:', err);
|
|
showToast($t('toast.failedPrepareOffline'), 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle playback of DisplayTrack (used by ArtistDetailView, PlaylistDetailView, FavoritesView)
|
|
* This is fire-and-forget to match view callback signatures
|
|
*/
|
|
function handleDisplayTrackPlay(track: DisplayTrack): void {
|
|
console.log('Playing display track:', track);
|
|
|
|
// Determine quality string:
|
|
// - If we have exact bitDepth/samplingRate, show them
|
|
// - If hires flag is true but no exact values, show "Hi-Res"
|
|
// - Otherwise show "-" (unknown - will be updated when streaming returns quality info)
|
|
// TODO: Update quality when backend returns actual streaming quality
|
|
const quality = track.bitDepth && track.samplingRate
|
|
? `${track.bitDepth}bit/${track.samplingRate}kHz`
|
|
: track.hires
|
|
? 'Hi-Res'
|
|
: '-';
|
|
|
|
// Fire-and-forget async call
|
|
playTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist || 'Unknown Artist',
|
|
album: track.album || 'Playlist',
|
|
artwork: track.albumArt || '',
|
|
duration: track.durationSeconds,
|
|
quality,
|
|
bitDepth: track.bitDepth,
|
|
samplingRate: track.samplingRate,
|
|
albumId: track.albumId,
|
|
artistId: track.artistId
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Helper: Create context and play display track
|
|
*/
|
|
async function createContextAndPlayDisplayTrack(
|
|
track: DisplayTrack,
|
|
contextType: ContextType,
|
|
contextId: string,
|
|
contextLabel: string,
|
|
trackIds: number[],
|
|
startIndex: number
|
|
) {
|
|
// Create context
|
|
await setPlaybackContext(
|
|
contextType,
|
|
contextId,
|
|
contextLabel,
|
|
'qobuz',
|
|
trackIds,
|
|
startIndex
|
|
);
|
|
console.log(`[Context] Created for ${contextType}: ${contextLabel}, starting at index ${startIndex}`);
|
|
|
|
// Play track
|
|
handleDisplayTrackPlay(track);
|
|
}
|
|
|
|
async function handleLocalTrackPlay(track: LocalLibraryTrack) {
|
|
console.log('Playing local track:', track);
|
|
// DO NOT clear context - LocalLibraryView already sets it correctly
|
|
// await clearPlaybackContext();
|
|
|
|
const artwork = track.artwork_path ? convertFileSrc(track.artwork_path) : '';
|
|
const quality = track.bit_depth && track.sample_rate
|
|
? (track.bit_depth >= 24 || track.sample_rate > 48000
|
|
? `${track.bit_depth}bit/${track.sample_rate / 1000}kHz`
|
|
: track.format)
|
|
: track.format;
|
|
|
|
await playTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
artwork,
|
|
duration: track.duration_secs,
|
|
quality,
|
|
bitDepth: track.bit_depth,
|
|
samplingRate: track.sample_rate ? track.sample_rate / 1000 : undefined, // Convert Hz to kHz (44100 → 44.1) - NO ROUNDING
|
|
format: track.format,
|
|
isLocal: true
|
|
}, { isLocal: true });
|
|
}
|
|
|
|
// Handle setting queue from local library (tracks need different playback command)
|
|
function handleSetLocalQueue(trackIds: number[]) {
|
|
// Set local track IDs via queueStore
|
|
setLocalTrackIds(trackIds);
|
|
}
|
|
|
|
// Playlist Modal Functions
|
|
function openCreatePlaylist() {
|
|
userPlaylists = sidebarRef?.getPlaylists() ?? [];
|
|
openPlaylistModal('create', []);
|
|
}
|
|
|
|
function openAddToPlaylist(trackIds: number[], isLocal = false) {
|
|
userPlaylists = sidebarRef?.getPlaylists() ?? [];
|
|
openPlaylistModal('addTrack', trackIds, isLocal);
|
|
}
|
|
|
|
function handlePlaylistCreated(playlist?: import('$lib/types').Playlist) {
|
|
const trackCount = playlistModalTrackIds.length;
|
|
const isLocal = playlistModalTracksAreLocal;
|
|
|
|
if (playlistModalMode === 'addTrack') {
|
|
showToast($t('toast.tracksAddedToPlaylist'), 'success');
|
|
} else {
|
|
showToast($t('toast.playlistCreated'), 'success');
|
|
}
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
sidebarRef?.refreshLocalTrackCounts();
|
|
|
|
// If a newly created playlist is provided, ensure the sidebar has the correct count
|
|
// This handles API eventual consistency where tracks_count might be stale
|
|
if (playlist && playlist.id > 0 && trackCount > 0) {
|
|
// Small delay to let refreshPlaylists complete, then override with correct count
|
|
setTimeout(() => {
|
|
const qobuzCount = isLocal ? 0 : trackCount;
|
|
const localCount = isLocal ? trackCount : 0;
|
|
sidebarRef?.updatePlaylistCounts(playlist.id, qobuzCount, localCount);
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
function openImportPlaylist() {
|
|
openPlaylistImport();
|
|
}
|
|
|
|
function handlePlaylistImported(summary: { qobuz_playlist_id?: number | null }) {
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
if (summary.qobuz_playlist_id) {
|
|
selectPlaylist(summary.qobuz_playlist_id);
|
|
}
|
|
}
|
|
|
|
// Track Info Modal
|
|
function showTrackInfo(trackId: number) {
|
|
trackInfoTrackId = trackId;
|
|
isTrackInfoOpen = true;
|
|
}
|
|
|
|
// Album Credits Modal
|
|
function showAlbumCredits(albumId: string) {
|
|
albumCreditsAlbumId = albumId;
|
|
isAlbumCreditsOpen = true;
|
|
}
|
|
|
|
// Auth Handlers
|
|
async function handleStartOffline() {
|
|
// Enable manual offline mode and enter app without authentication
|
|
await setManualOffline(true);
|
|
setLoggedIn({
|
|
userName: 'Offline User',
|
|
subscription: 'Local Library Only'
|
|
});
|
|
navigateTo('library');
|
|
showToast($t('toast.offlineModeStarted'), 'info');
|
|
}
|
|
|
|
async function handleLoginSuccess(info: UserInfo) {
|
|
setLoggedIn(info);
|
|
showToast($t('toast.welcomeUser', { values: { name: info.userName } }), 'success');
|
|
|
|
// Load favorites now that login is confirmed (sync with Qobuz)
|
|
loadFavorites(); // Track favorites
|
|
loadAlbumFavorites(); // Album favorites
|
|
loadArtistFavorites(); // Artist favorites
|
|
|
|
// Refresh offline status now that we're logged in
|
|
await refreshOfflineStatus();
|
|
|
|
// Train recommendation scores in background (fire-and-forget)
|
|
trainScores().then(() => {
|
|
console.log('[Reco] Scores trained after login');
|
|
}).catch(err => {
|
|
console.debug('[Reco] Score training failed:', err);
|
|
});
|
|
|
|
// DISABLED: Restore previous session if available
|
|
// (Temporarily disabled due to ID conflicts between local and Qobuz tracks)
|
|
/*
|
|
try {
|
|
const session = await loadSessionState();
|
|
if (session && session.queue_tracks.length > 0) {
|
|
console.log('[Session] Restoring previous session...');
|
|
|
|
// Restore queue
|
|
const tracks: BackendQueueTrack[] = session.queue_tracks.map(t => ({
|
|
id: t.id,
|
|
title: t.title,
|
|
artist: t.artist,
|
|
album: t.album,
|
|
duration_secs: t.duration_secs,
|
|
artwork_url: t.artwork_url,
|
|
hires: t.hires ?? false,
|
|
bit_depth: t.bit_depth ?? null,
|
|
sample_rate: t.sample_rate ?? null,
|
|
is_local: t.is_local ?? false,
|
|
album_id: t.album_id ?? null,
|
|
artist_id: t.artist_id ?? null
|
|
}));
|
|
|
|
await setQueue(tracks, session.current_index ?? 0, true);
|
|
|
|
// Restore shuffle/repeat mode
|
|
if (session.shuffle_enabled) {
|
|
await invoke('set_shuffle', { enabled: true });
|
|
}
|
|
if (session.repeat_mode !== 'off') {
|
|
await invoke('set_repeat', { mode: session.repeat_mode });
|
|
}
|
|
|
|
// Restore volume
|
|
playerSetVolume(Math.round(session.volume * 100));
|
|
|
|
// If there was a track playing, restore it (paused)
|
|
if (session.current_index !== null && tracks[session.current_index]) {
|
|
const track = tracks[session.current_index];
|
|
showToast(`Restored: ${track.title}`, 'info');
|
|
|
|
// Fetch full track info from Qobuz to get albumId, artistId, and quality
|
|
try {
|
|
const fullTrack = await invoke<QobuzTrack>('get_track', { trackId: track.id });
|
|
const artwork = fullTrack.album?.image?.large || fullTrack.album?.image?.thumbnail || track.artwork_url || '';
|
|
const quality = fullTrack.hires_streamable
|
|
? `${fullTrack.maximum_bit_depth ?? 24}/${fullTrack.maximum_sampling_rate ?? 96}`
|
|
: 'CD';
|
|
|
|
setCurrentTrack({
|
|
id: track.id,
|
|
title: fullTrack.title || track.title,
|
|
artist: fullTrack.performer?.name || track.artist,
|
|
album: fullTrack.album?.title || track.album,
|
|
artwork,
|
|
duration: track.duration_secs,
|
|
quality,
|
|
bitDepth: fullTrack.maximum_bit_depth,
|
|
samplingRate: fullTrack.maximum_sampling_rate,
|
|
albumId: fullTrack.album?.id,
|
|
artistId: fullTrack.performer?.id,
|
|
});
|
|
} catch (fetchErr) {
|
|
console.warn('[Session] Failed to fetch track details, using cached data:', fetchErr);
|
|
// Fallback to cached data without albumId/artistId
|
|
const quality = track.hires
|
|
? `${track.bit_depth ?? 24}/${track.sample_rate ? track.sample_rate / 1000 : 96}`
|
|
: 'CD';
|
|
setCurrentTrack({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
artwork: track.artwork_url || '',
|
|
duration: track.duration_secs,
|
|
quality,
|
|
bitDepth: track.bit_depth ?? undefined,
|
|
samplingRate: track.sample_rate ?? undefined,
|
|
});
|
|
}
|
|
|
|
// Mark that this track needs to be loaded when user presses play
|
|
setPendingSessionRestore(track.id, session.current_position_secs);
|
|
console.log(`[Session] Track ${track.id} pending load, will resume at ${session.current_position_secs}s`);
|
|
}
|
|
|
|
console.log('[Session] Session restored successfully');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Session] Failed to restore session:', err);
|
|
}
|
|
*/
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await invoke('logout');
|
|
// Clear saved credentials from keyring
|
|
try {
|
|
await invoke('clear_saved_credentials');
|
|
console.log('Credentials cleared from keyring');
|
|
} catch (clearErr) {
|
|
console.error('Failed to clear credentials:', clearErr);
|
|
// Don't block logout if clearing fails
|
|
}
|
|
// Clear session state
|
|
await clearSession();
|
|
setLoggedOut();
|
|
currentTrack = null;
|
|
isPlaying = false;
|
|
showToast($t('toast.logoutSuccess'), 'info');
|
|
} catch (err) {
|
|
console.error('Logout error:', err);
|
|
showToast($t('toast.failedLogout'), 'error');
|
|
}
|
|
}
|
|
|
|
// Save session state before window closes
|
|
async function saveSessionBeforeClose() {
|
|
if (!isLoggedIn || !currentTrack) return;
|
|
|
|
try {
|
|
// Get current queue state from backend
|
|
const queueState = await getBackendQueueState();
|
|
if (!queueState) return;
|
|
|
|
// Build persisted queue tracks
|
|
const allTracks: PersistedQueueTrack[] = [];
|
|
if (queueState.current_track) {
|
|
allTracks.push({
|
|
id: queueState.current_track.id,
|
|
title: queueState.current_track.title,
|
|
artist: queueState.current_track.artist,
|
|
album: queueState.current_track.album,
|
|
duration_secs: queueState.current_track.duration_secs,
|
|
artwork_url: queueState.current_track.artwork_url
|
|
});
|
|
}
|
|
for (const track of queueState.upcoming) {
|
|
allTracks.push({
|
|
id: track.id,
|
|
title: track.title,
|
|
artist: track.artist,
|
|
album: track.album,
|
|
duration_secs: track.duration_secs,
|
|
artwork_url: track.artwork_url
|
|
});
|
|
}
|
|
|
|
await saveSessionState(
|
|
allTracks,
|
|
queueState.current_index,
|
|
Math.floor(currentTime),
|
|
volume / 100,
|
|
isShuffle,
|
|
repeatMode,
|
|
isPlaying
|
|
);
|
|
console.log('[Session] Session saved on close');
|
|
} catch (err) {
|
|
console.error('[Session] Failed to save session on close:', err);
|
|
}
|
|
}
|
|
|
|
// Keyboard Shortcuts
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (!isLoggedIn) return;
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case ' ':
|
|
e.preventDefault();
|
|
togglePlay();
|
|
break;
|
|
case 'ArrowLeft':
|
|
if (e.altKey) {
|
|
e.preventDefault();
|
|
navGoBack();
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
handleSkipBack();
|
|
}
|
|
break;
|
|
case 'ArrowRight':
|
|
if (e.altKey) {
|
|
e.preventDefault();
|
|
navGoForward();
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
handleSkipForward();
|
|
}
|
|
break;
|
|
case 'f':
|
|
if (!e.ctrlKey && !e.metaKey) {
|
|
toggleFocusMode();
|
|
}
|
|
break;
|
|
case 'q':
|
|
toggleQueue();
|
|
break;
|
|
case 'Escape':
|
|
handleUIEscape();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Playback state polling - managed by playerStore
|
|
// Start/stop polling based on whether there's a current track
|
|
$effect(() => {
|
|
if (currentTrack) {
|
|
startPolling();
|
|
} else {
|
|
stopPolling();
|
|
}
|
|
|
|
return () => {
|
|
stopPolling();
|
|
};
|
|
});
|
|
|
|
// Periodic full session save during playback (every 30 seconds)
|
|
let sessionSaveInterval: ReturnType<typeof setInterval> | null = null;
|
|
|
|
$effect(() => {
|
|
// Start periodic save when playing, stop when paused/stopped
|
|
if (isPlaying && currentTrack && isLoggedIn) {
|
|
if (!sessionSaveInterval) {
|
|
sessionSaveInterval = setInterval(() => {
|
|
saveSessionBeforeClose();
|
|
}, 30000); // Save every 30 seconds
|
|
}
|
|
} else {
|
|
if (sessionSaveInterval) {
|
|
clearInterval(sessionSaveInterval);
|
|
sessionSaveInterval = null;
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (sessionSaveInterval) {
|
|
clearInterval(sessionSaveInterval);
|
|
sessionSaveInterval = null;
|
|
}
|
|
};
|
|
});
|
|
|
|
// Download state update trigger
|
|
let downloadStateVersion = $state(0);
|
|
|
|
// Cache for album download statuses
|
|
const albumDownloadCache = new Map<string, boolean>();
|
|
|
|
async function checkAlbumFullyDownloaded(albumId: string): Promise<boolean> {
|
|
// Trigger reactivity with downloadStateVersion
|
|
void downloadStateVersion;
|
|
|
|
try {
|
|
const isDownloaded = await invoke<boolean>('check_album_fully_cached', { albumId });
|
|
albumDownloadCache.set(albumId, isDownloaded);
|
|
return isDownloaded;
|
|
} catch {
|
|
albumDownloadCache.set(albumId, false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getAlbumOfflineCacheStatus(albumId: string): boolean {
|
|
void downloadStateVersion;
|
|
return albumDownloadCache.get(albumId) || false;
|
|
}
|
|
|
|
onMount(() => {
|
|
// Bootstrap app (theme, mouse nav, Last.fm restore)
|
|
const { cleanup: cleanupBootstrap } = bootstrapApp();
|
|
|
|
// Keyboard navigation
|
|
document.addEventListener('keydown', handleKeydown);
|
|
|
|
// Session save on window close/hide
|
|
const handleBeforeUnload = () => {
|
|
saveSessionBeforeClose();
|
|
};
|
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
|
|
// Also save when visibility changes (app goes to background)
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
saveSessionBeforeClose();
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
|
|
// Initialize download states
|
|
initOfflineCacheStates();
|
|
startOfflineCacheEventListeners();
|
|
|
|
// Initialize playback context and preferences stores
|
|
initPlaybackContextStore();
|
|
initPlaybackPreferences();
|
|
initBlacklistStore();
|
|
|
|
// Load infinite play preference
|
|
try {
|
|
const stored = localStorage.getItem('qbz-infinite-play');
|
|
if (stored !== null) {
|
|
infinitePlayEnabled = JSON.parse(stored);
|
|
}
|
|
} catch {
|
|
// Ignore storage errors
|
|
}
|
|
|
|
// Set up callback for cast disconnect handoff
|
|
setOnAskContinueLocally(async (track, position) => {
|
|
// Ask user if they want to continue locally
|
|
const continueLocally = window.confirm(
|
|
`Continue playing "${track.title}" on this device?`
|
|
);
|
|
|
|
if (continueLocally) {
|
|
try {
|
|
// Start local playback
|
|
await playTrack(track, { showLoadingToast: false });
|
|
|
|
// Seek to saved position after a short delay
|
|
if (position > 5) {
|
|
setTimeout(async () => {
|
|
try {
|
|
await playerSeek(position);
|
|
console.log('[CastHandoff] Seeked to position:', position);
|
|
} catch (seekErr) {
|
|
console.log('[CastHandoff] Could not restore position:', seekErr);
|
|
}
|
|
}, 1000);
|
|
}
|
|
} catch (err) {
|
|
console.error('[CastHandoff] Failed to resume local playback:', err);
|
|
}
|
|
}
|
|
|
|
return continueLocally;
|
|
});
|
|
|
|
// Note: loadFavorites() is called in handleLoginSuccess after login is confirmed
|
|
// This prevents API calls before authentication is complete
|
|
|
|
// Subscribe to download state changes to trigger reactivity
|
|
const unsubscribeOfflineCache = subscribeOfflineCache(() => {
|
|
downloadStateVersion++;
|
|
});
|
|
|
|
// Subscribe to toast state changes
|
|
const unsubscribeToast = subscribeToast((newToast) => {
|
|
toast = newToast;
|
|
});
|
|
|
|
// Subscribe to UI state changes
|
|
const unsubscribeUI = subscribeUI(() => {
|
|
const uiState = getUIState();
|
|
// Close network sidebar when queue opens
|
|
if (uiState.isQueueOpen && !isQueueOpen) {
|
|
closeContentSidebar('network');
|
|
}
|
|
isQueueOpen = uiState.isQueueOpen;
|
|
isFullScreenOpen = uiState.isFullScreenOpen;
|
|
isFocusModeOpen = uiState.isFocusModeOpen;
|
|
isCastPickerOpen = uiState.isCastPickerOpen;
|
|
isPlaylistModalOpen = uiState.isPlaylistModalOpen;
|
|
playlistModalMode = uiState.playlistModalMode;
|
|
playlistModalTrackIds = uiState.playlistModalTrackIds;
|
|
playlistModalTracksAreLocal = uiState.playlistModalTracksAreLocal;
|
|
isPlaylistImportOpen = uiState.isPlaylistImportOpen;
|
|
});
|
|
|
|
// Subscribe to auth state changes
|
|
const unsubscribeAuth = subscribeAuth(() => {
|
|
const authState = getAuthState();
|
|
isLoggedIn = authState.isLoggedIn;
|
|
userInfo = authState.userInfo;
|
|
});
|
|
|
|
// Initialize and subscribe to sidebar state changes
|
|
initSidebarStore();
|
|
const unsubscribeSidebar = subscribeSidebar(() => {
|
|
sidebarExpanded = getIsExpanded();
|
|
});
|
|
|
|
// Initialize and subscribe to title bar state changes
|
|
initTitleBarStore();
|
|
const unsubscribeTitleBar = subscribeTitleBar(() => {
|
|
showTitleBar = shouldShowTitleBar();
|
|
});
|
|
|
|
// Subscribe to offline state changes
|
|
const unsubscribeOffline = subscribeOffline(() => {
|
|
offlineStatus = getOfflineStatus();
|
|
// Sync offline mode to queue store for track availability
|
|
setQueueOfflineMode(offlineStatus.isOffline);
|
|
});
|
|
|
|
// Subscribe to navigation state changes
|
|
const unsubscribeNav = subscribeNav(() => {
|
|
const navState = getNavigationState();
|
|
activeView = navState.activeView;
|
|
selectedPlaylistId = navState.selectedPlaylistId;
|
|
});
|
|
|
|
// Subscribe to player state changes
|
|
const unsubscribePlayer = subscribePlayer(() => {
|
|
const playerState = getPlayerState();
|
|
const wasPlaying = isPlaying;
|
|
currentTrack = playerState.currentTrack;
|
|
isPlaying = playerState.isPlaying;
|
|
currentTime = playerState.currentTime;
|
|
duration = playerState.duration;
|
|
volume = playerState.volume;
|
|
isFavorite = playerState.isFavorite;
|
|
|
|
// Save position during playback (debounced to every 5s)
|
|
if (isPlaying && currentTrack && currentTime > 0) {
|
|
debouncedSavePosition(Math.floor(currentTime));
|
|
}
|
|
|
|
// Flush position save immediately when pausing
|
|
if (wasPlaying && !isPlaying && currentTrack && currentTime > 0) {
|
|
flushPositionSave(Math.floor(currentTime));
|
|
}
|
|
|
|
// Sync state to MiniPlayer window (if open)
|
|
if (currentTrack) {
|
|
emitTo('miniplayer', 'miniplayer:track', {
|
|
id: currentTrack.id,
|
|
title: currentTrack.title,
|
|
artist: currentTrack.artist,
|
|
artwork: currentTrack.artwork,
|
|
isPlaying,
|
|
}).catch(() => {}); // Ignore if miniplayer not open
|
|
}
|
|
emitTo('miniplayer', 'miniplayer:playback', {
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
}).catch(() => {}); // Ignore if miniplayer not open
|
|
});
|
|
|
|
// Subscribe to queue state changes
|
|
const unsubscribeQueue = subscribeQueue(() => {
|
|
const queueState = getQueueState();
|
|
queue = queueState.queue;
|
|
queueTotalTracks = queueState.queueTotalTracks;
|
|
isShuffle = queueState.isShuffle;
|
|
repeatMode = queueState.repeatMode;
|
|
});
|
|
|
|
// Subscribe to lyrics state changes
|
|
const unsubscribeLyrics = subscribeLyrics(() => {
|
|
const state = getLyricsState();
|
|
lyricsStatus = state.status;
|
|
lyricsError = state.error;
|
|
lyricsLines = state.lines;
|
|
lyricsIsSynced = state.isSynced;
|
|
lyricsActiveIndex = state.activeIndex;
|
|
lyricsActiveProgress = state.activeProgress;
|
|
lyricsSidebarVisible = state.sidebarVisible;
|
|
// Close network sidebar when lyrics opens
|
|
if (state.sidebarVisible) {
|
|
closeContentSidebar('network');
|
|
}
|
|
});
|
|
|
|
// Subscribe to content sidebar for mutual exclusion (network closes lyrics/queue)
|
|
const unsubscribeContentSidebar = subscribeContentSidebar((active: ContentSidebarType) => {
|
|
if (active === 'network') {
|
|
hideLyricsSidebar();
|
|
closeQueue();
|
|
}
|
|
});
|
|
|
|
// Subscribe to cast state changes
|
|
const unsubscribeCast = subscribeCast(() => {
|
|
isCastConnected = isCasting();
|
|
});
|
|
|
|
// Start lyrics watcher for track changes
|
|
startLyricsWatching();
|
|
|
|
// Set up track ended callback for auto-advance
|
|
setOnTrackEnded(async () => {
|
|
if (!isAutoplayEnabled()) {
|
|
setQueueEnded(true);
|
|
await stopPlayback();
|
|
setIsPlaying(false);
|
|
return;
|
|
}
|
|
const nextTrackResult = await nextTrack();
|
|
if (nextTrackResult) {
|
|
await playQueueTrack(nextTrackResult);
|
|
} else {
|
|
// Queue ended - stop playback and clear player
|
|
setQueueEnded(true);
|
|
await stopPlayback();
|
|
setIsPlaying(false);
|
|
}
|
|
});
|
|
|
|
// Set up tray icon event listeners
|
|
let unlistenTrayPlayPause: UnlistenFn | null = null;
|
|
let unlistenTrayNext: UnlistenFn | null = null;
|
|
let unlistenTrayPrevious: UnlistenFn | null = null;
|
|
let unlistenMediaControls: UnlistenFn | null = null;
|
|
|
|
(async () => {
|
|
unlistenTrayPlayPause = await listen('tray:play_pause', () => {
|
|
console.log('[Tray] Play/Pause');
|
|
togglePlay();
|
|
});
|
|
unlistenTrayNext = await listen('tray:next', async () => {
|
|
console.log('[Tray] Next');
|
|
await handleSkipForward();
|
|
});
|
|
unlistenTrayPrevious = await listen('tray:previous', async () => {
|
|
console.log('[Tray] Previous');
|
|
await handleSkipBack();
|
|
});
|
|
|
|
unlistenMediaControls = await listen('media:control', async (event) => {
|
|
const payload = event.payload as MediaControlPayload;
|
|
if (!payload?.action) return;
|
|
|
|
const playerState = getPlayerState();
|
|
|
|
switch (payload.action) {
|
|
case 'play':
|
|
if (!playerState.isPlaying) {
|
|
await togglePlay();
|
|
}
|
|
break;
|
|
case 'pause':
|
|
if (playerState.isPlaying) {
|
|
await togglePlay();
|
|
}
|
|
break;
|
|
case 'toggle':
|
|
await togglePlay();
|
|
break;
|
|
case 'next':
|
|
await handleSkipForward();
|
|
break;
|
|
case 'previous':
|
|
await handleSkipBack();
|
|
break;
|
|
case 'stop':
|
|
await stopPlayback();
|
|
break;
|
|
case 'seek': {
|
|
const direction = payload.direction === 'backward' ? -1 : 1;
|
|
const target = playerState.currentTime + direction * MEDIA_SEEK_FALLBACK_SECS;
|
|
await playerSeek(target);
|
|
break;
|
|
}
|
|
case 'seek_by': {
|
|
if (typeof payload.offset_secs === 'number') {
|
|
await playerSeek(playerState.currentTime + payload.offset_secs);
|
|
}
|
|
break;
|
|
}
|
|
case 'set_position': {
|
|
if (typeof payload.position_secs === 'number') {
|
|
await playerSeek(payload.position_secs);
|
|
}
|
|
break;
|
|
}
|
|
case 'set_volume': {
|
|
if (typeof payload.volume === 'number') {
|
|
const normalized = Math.max(0, Math.min(1, payload.volume));
|
|
const newVolume = Math.round(normalized * 100);
|
|
// Only update if volume actually changed (prevents MPRIS feedback loop)
|
|
if (newVolume !== volume) {
|
|
await playerSetVolume(newVolume);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
})();
|
|
|
|
return () => {
|
|
// Clean up tray event listeners
|
|
unlistenTrayPlayPause?.();
|
|
unlistenTrayNext?.();
|
|
unlistenTrayPrevious?.();
|
|
unlistenMediaControls?.();
|
|
// Save session before cleanup
|
|
saveSessionBeforeClose();
|
|
cleanupBootstrap();
|
|
document.removeEventListener('keydown', handleKeydown);
|
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
stopOfflineCacheEventListeners();
|
|
unsubscribeOfflineCache();
|
|
unsubscribeToast();
|
|
unsubscribeUI();
|
|
unsubscribeAuth();
|
|
unsubscribeSidebar();
|
|
unsubscribeTitleBar();
|
|
unsubscribeOffline();
|
|
unsubscribeNav();
|
|
unsubscribePlayer();
|
|
unsubscribeQueue();
|
|
unsubscribeLyrics();
|
|
unsubscribeContentSidebar();
|
|
unsubscribeCast();
|
|
stopLyricsWatching();
|
|
stopActiveLineUpdates();
|
|
stopPolling();
|
|
cleanupPlayback();
|
|
};
|
|
});
|
|
|
|
// Sync queue state when opening queue panel (including history and remaining count)
|
|
$effect(() => {
|
|
if (isQueueOpen) {
|
|
syncQueueState();
|
|
updateQueueCounts();
|
|
}
|
|
});
|
|
|
|
// Update remaining count when track changes while queue is open
|
|
$effect(() => {
|
|
// Track the currentTrack to trigger on change
|
|
const trackId = currentTrack?.id;
|
|
if (isQueueOpen && trackId !== undefined) {
|
|
updateQueueCounts();
|
|
}
|
|
});
|
|
|
|
// Sync queue state when immersive player is open and track changes
|
|
$effect(() => {
|
|
const trackId = currentTrack?.id;
|
|
const immersiveOpen = isFullScreenOpen || isFocusModeOpen;
|
|
if (immersiveOpen && trackId !== undefined) {
|
|
syncQueueState();
|
|
updateQueueCounts(); // Also sync history for coverflow
|
|
}
|
|
});
|
|
|
|
// Helper function to fetch and update queue counts and history
|
|
async function updateQueueCounts() {
|
|
const state = await getBackendQueueState();
|
|
if (state) {
|
|
// Calculate remaining tracks: total - current_index - 1 (for current track)
|
|
if (state.current_index !== null && state.total_tracks > 0) {
|
|
queueRemainingTracks = state.total_tracks - state.current_index - 1;
|
|
} else {
|
|
queueRemainingTracks = state.total_tracks;
|
|
}
|
|
|
|
if (state.history) {
|
|
historyTracks = state.history.map(t => ({
|
|
id: String(t.id),
|
|
artwork: t.artwork_url || '',
|
|
title: t.title,
|
|
artist: t.artist,
|
|
duration: formatDuration(t.duration_secs),
|
|
trackId: t.id
|
|
}));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start/stop lyrics active line updates based on playback state and visibility
|
|
$effect(() => {
|
|
const lyricsVisible = lyricsSidebarVisible || isFocusModeOpen || isFullScreenOpen;
|
|
if (isPlaying && lyricsIsSynced && lyricsVisible) {
|
|
startActiveLineUpdates();
|
|
} else {
|
|
stopActiveLineUpdates();
|
|
}
|
|
// Cleanup on effect re-run or component unmount
|
|
return () => {
|
|
stopActiveLineUpdates();
|
|
};
|
|
});
|
|
|
|
// Derived values for NowPlayingBar
|
|
const currentQueueTrack = $derived<QueueTrack | null>(currentTrack ? {
|
|
id: String(currentTrack.id),
|
|
artwork: currentTrack.artwork,
|
|
title: currentTrack.title,
|
|
artist: currentTrack.artist,
|
|
duration: formatDuration(currentTrack.duration),
|
|
trackId: currentTrack.id // For favorite checking in QueuePanel
|
|
} : null);
|
|
</script>
|
|
|
|
{#if !isLoggedIn}
|
|
<LoginView onLoginSuccess={handleLoginSuccess} onStartOffline={handleStartOffline} />
|
|
{:else}
|
|
<div class="app" class:no-titlebar={!showTitleBar}>
|
|
<!-- Custom Title Bar (CSD) -->
|
|
{#if showTitleBar}
|
|
<TitleBar />
|
|
{/if}
|
|
|
|
<div class="app-body">
|
|
<!-- Sidebar -->
|
|
<Sidebar
|
|
bind:this={sidebarRef}
|
|
{activeView}
|
|
{selectedPlaylistId}
|
|
onNavigate={navigateTo}
|
|
onPlaylistSelect={selectPlaylist}
|
|
onCreatePlaylist={openCreatePlaylist}
|
|
onImportPlaylist={openImportPlaylist}
|
|
onPlaylistManagerClick={() => navigateTo('playlist-manager')}
|
|
onSettingsClick={() => navigateTo('settings')}
|
|
onAboutClick={() => isAboutModalOpen = true}
|
|
onLogout={handleLogout}
|
|
userName={userInfo?.userName || 'User'}
|
|
subscription={userInfo?.subscription || 'Qobuz™'}
|
|
isExpanded={sidebarExpanded}
|
|
onToggle={toggleSidebar}
|
|
showTitleBar={showTitleBar}
|
|
/>
|
|
|
|
<!-- Content Area (main + lyrics sidebar) -->
|
|
<div class="content-area">
|
|
<!-- Main Content -->
|
|
<main class="main-content">
|
|
{#if activeView === 'home'}
|
|
{#if offlineStatus.isOffline}
|
|
<OfflinePlaceholder
|
|
reason={offlineStatus.reason}
|
|
onGoToLibrary={() => navigateTo('library')}
|
|
/>
|
|
{:else}
|
|
<HomeView
|
|
userName={userInfo?.userName}
|
|
onAlbumClick={handleAlbumClick}
|
|
onAlbumPlay={playAlbumById}
|
|
onAlbumPlayNext={queueAlbumNextById}
|
|
onAlbumPlayLater={queueAlbumLaterById}
|
|
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onAlbumDownload={downloadAlbumById}
|
|
onOpenAlbumFolder={openAlbumFolderById}
|
|
onReDownloadAlbum={reDownloadAlbumById}
|
|
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
|
{downloadStateVersion}
|
|
onArtistClick={handleArtistClick}
|
|
onTrackPlay={handleDisplayTrackPlay}
|
|
onTrackPlayNext={queueQobuzTrackNext}
|
|
onTrackPlayLater={queueQobuzTrackLater}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onTrackShowInfo={showTrackInfo}
|
|
onTrackDownload={handleDisplayTrackDownload}
|
|
onTrackReDownload={handleDisplayTrackDownload}
|
|
onTrackRemoveDownload={handleTrackRemoveDownload}
|
|
checkTrackDownloaded={checkTrackDownloaded}
|
|
getTrackOfflineCacheStatus={getTrackOfflineCacheStatus}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
sidebarExpanded={sidebarExpanded}
|
|
/>
|
|
{/if}
|
|
{:else if activeView === 'search'}
|
|
{#if offlineStatus.isOffline}
|
|
<OfflinePlaceholder
|
|
reason={offlineStatus.reason}
|
|
onGoToLibrary={() => navigateTo('library')}
|
|
/>
|
|
{:else}
|
|
<SearchView
|
|
onAlbumClick={handleAlbumClick}
|
|
onAlbumPlay={playAlbumById}
|
|
onAlbumPlayNext={queueAlbumNextById}
|
|
onAlbumPlayLater={queueAlbumLaterById}
|
|
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onAlbumDownload={downloadAlbumById}
|
|
onOpenAlbumFolder={openAlbumFolderById}
|
|
onReDownloadAlbum={reDownloadAlbumById}
|
|
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
|
{downloadStateVersion}
|
|
onTrackPlay={handleTrackPlay}
|
|
onTrackPlayNext={queueQobuzTrackNext}
|
|
onTrackPlayLater={queueQobuzTrackLater}
|
|
onTrackAddFavorite={handleAddToFavorites}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onTrackShowInfo={showTrackInfo}
|
|
onTrackDownload={handleDisplayTrackDownload}
|
|
onTrackReDownload={handleDisplayTrackDownload}
|
|
onTrackRemoveDownload={handleTrackRemoveDownload}
|
|
checkTrackDownloaded={checkTrackDownloaded}
|
|
onArtistClick={handleArtistClick}
|
|
onPlaylistClick={selectPlaylist}
|
|
onPlaylistPlay={playPlaylistById}
|
|
onPlaylistPlayNext={queuePlaylistNextById}
|
|
onPlaylistPlayLater={queuePlaylistLaterById}
|
|
onPlaylistCopyToLibrary={copyPlaylistToLibraryById}
|
|
onPlaylistShareQobuz={sharePlaylistQobuzLinkById}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
/>
|
|
{/if}
|
|
{:else if activeView === 'settings'}
|
|
<SettingsView
|
|
onBack={navGoBack}
|
|
onLogout={handleLogout}
|
|
onBlacklistManagerClick={() => navigateTo('blacklist-manager')}
|
|
userName={userInfo?.userName}
|
|
subscription={userInfo?.subscription}
|
|
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
|
|
showTitleBar={showTitleBar}
|
|
/>
|
|
{:else if activeView === 'album' && selectedAlbum}
|
|
<AlbumDetailView
|
|
album={selectedAlbum}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
onBack={navGoBack}
|
|
onArtistClick={() => selectedAlbum?.artistId && handleArtistClick(selectedAlbum.artistId)}
|
|
onLabelClick={handleLabelClick}
|
|
onTrackPlay={handleAlbumTrackPlay}
|
|
onTrackPlayNext={handleAlbumTrackPlayNext}
|
|
onTrackPlayLater={handleAlbumTrackPlayLater}
|
|
onTrackAddFavorite={handleAddToFavorites}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onTrackShowInfo={showTrackInfo}
|
|
onPlayAll={handlePlayAllAlbum}
|
|
onShuffleAll={handleShuffleAlbum}
|
|
onPlayAllNext={handleAddAlbumToQueueNext}
|
|
onPlayAllLater={handleAddAlbumToQueueLater}
|
|
onAddTrackToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onAddAlbumToPlaylist={() => addAlbumToPlaylist(selectedAlbum)}
|
|
onTrackDownload={handleTrackDownload}
|
|
onTrackRemoveDownload={handleTrackRemoveDownload}
|
|
onTrackReDownload={handleTrackReDownload}
|
|
getTrackOfflineCacheStatus={getTrackOfflineCacheStatus}
|
|
onDownloadAlbum={handleDownloadAlbum}
|
|
onShareAlbumQobuz={shareAlbumQobuzLink}
|
|
onShareAlbumSonglink={shareAlbumSonglink}
|
|
onOpenAlbumFolder={handleOpenAlbumFolder}
|
|
onReDownloadAlbum={handleReDownloadAlbum}
|
|
{downloadStateVersion}
|
|
artistAlbums={albumArtistAlbums}
|
|
onRelatedAlbumClick={handleAlbumClick}
|
|
onRelatedAlbumPlay={playAlbumById}
|
|
onRelatedAlbumPlayNext={queueAlbumNextById}
|
|
onRelatedAlbumPlayLater={queueAlbumLaterById}
|
|
onRelatedAlbumDownload={downloadAlbumById}
|
|
onRelatedAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onRelatedAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onViewArtistDiscography={handleViewArtistDiscography}
|
|
checkRelatedAlbumDownloaded={checkAlbumFullyDownloaded}
|
|
onShowAlbumCredits={() => selectedAlbum && showAlbumCredits(selectedAlbum.id)}
|
|
/>
|
|
{:else if activeView === 'artist' && selectedArtist}
|
|
<ArtistDetailView
|
|
artist={selectedArtist}
|
|
onBack={navGoBack}
|
|
onAlbumClick={handleAlbumClick}
|
|
onAlbumPlay={playAlbumById}
|
|
onAlbumPlayNext={queueAlbumNextById}
|
|
onAlbumPlayLater={queueAlbumLaterById}
|
|
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onAlbumDownload={downloadAlbumById}
|
|
onOpenAlbumFolder={openAlbumFolderById}
|
|
onReDownloadAlbum={reDownloadAlbumById}
|
|
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
|
{downloadStateVersion}
|
|
onLoadMore={loadMoreArtistAlbums}
|
|
isLoadingMore={isArtistAlbumsLoading}
|
|
onTrackPlay={handleDisplayTrackPlay}
|
|
onTrackPlayNext={queueQobuzTrackNext}
|
|
onTrackPlayLater={queueQobuzTrackLater}
|
|
onTrackAddFavorite={handleAddToFavorites}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onPlaylistClick={selectPlaylist}
|
|
onLabelClick={handleLabelClick}
|
|
onMusicianClick={handleMusicianClick}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
/>
|
|
{:else if activeView === 'musician' && selectedMusician}
|
|
<MusicianPageView
|
|
musician={selectedMusician}
|
|
onBack={navGoBack}
|
|
onAlbumClick={handleAlbumClick}
|
|
onArtistClick={handleArtistClick}
|
|
/>
|
|
{:else if activeView === 'label' && selectedLabel}
|
|
<LabelView
|
|
labelId={selectedLabel.id}
|
|
labelName={selectedLabel.name}
|
|
onBack={navGoBack}
|
|
onAlbumClick={handleAlbumClick}
|
|
onAlbumPlay={playAlbumById}
|
|
onAlbumPlayNext={queueAlbumNextById}
|
|
onAlbumPlayLater={queueAlbumLaterById}
|
|
onAddAlbumToPlaylist={addAlbumToPlaylistById}
|
|
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onAlbumDownload={downloadAlbumById}
|
|
onOpenAlbumFolder={openAlbumFolderById}
|
|
onReDownloadAlbum={reDownloadAlbumById}
|
|
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
|
{downloadStateVersion}
|
|
/>
|
|
{:else if activeView === 'library' || activeView === 'library-album'}
|
|
<LocalLibraryView
|
|
onTrackPlay={handleLocalTrackPlay}
|
|
onTrackPlayNext={queueLocalTrackNext}
|
|
onTrackPlayLater={queueLocalTrackLater}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId], true)}
|
|
onSetLocalQueue={handleSetLocalQueue}
|
|
onQobuzArtistClick={handleArtistClick}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
/>
|
|
{:else if activeView === 'playlist' && selectedPlaylistId}
|
|
<PlaylistDetailView
|
|
playlistId={selectedPlaylistId}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
onBack={navGoBack}
|
|
onTrackPlay={handleDisplayTrackPlay}
|
|
onTrackPlayNext={queuePlaylistTrackNext}
|
|
onTrackPlayLater={queuePlaylistTrackLater}
|
|
onTrackAddFavorite={handleAddToFavorites}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onTrackShowInfo={showTrackInfo}
|
|
onTrackDownload={handleDisplayTrackDownload}
|
|
onTrackRemoveDownload={handleTrackRemoveDownload}
|
|
onTrackReDownload={handleDisplayTrackDownload}
|
|
getTrackOfflineCacheStatus={getTrackOfflineCacheStatus}
|
|
{downloadStateVersion}
|
|
onLocalTrackPlay={handleLocalTrackPlay}
|
|
onLocalTrackPlayNext={queueLocalTrackNext}
|
|
onLocalTrackPlayLater={queueLocalTrackLater}
|
|
onSetLocalQueue={handleSetLocalQueue}
|
|
onPlaylistCountUpdate={(playlistId, qobuzCount, localCount) =>
|
|
sidebarRef?.updatePlaylistCounts(playlistId, qobuzCount, localCount)
|
|
}
|
|
onPlaylistUpdated={() => {
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
sidebarRef?.refreshLocalTrackCounts();
|
|
}}
|
|
onPlaylistDeleted={() => {
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
navGoBack();
|
|
}}
|
|
/>
|
|
{:else if isFavoritesView(activeView)}
|
|
{#if offlineStatus.isOffline}
|
|
<OfflinePlaceholder
|
|
reason={offlineStatus.reason}
|
|
onGoToLibrary={() => navigateTo('library')}
|
|
/>
|
|
{:else}
|
|
<FavoritesView
|
|
onAlbumClick={handleAlbumClick}
|
|
onAlbumPlay={playAlbumById}
|
|
onAlbumPlayNext={queueAlbumNextById}
|
|
onAlbumPlayLater={queueAlbumLaterById}
|
|
onAlbumShareQobuz={shareAlbumQobuzLinkById}
|
|
onAlbumShareSonglink={shareAlbumSonglinkById}
|
|
onAlbumDownload={downloadAlbumById}
|
|
onOpenAlbumFolder={openAlbumFolderById}
|
|
onReDownloadAlbum={reDownloadAlbumById}
|
|
checkAlbumFullyDownloaded={checkAlbumFullyDownloaded}
|
|
{downloadStateVersion}
|
|
onTrackPlay={handleDisplayTrackPlay}
|
|
onArtistClick={handleArtistClick}
|
|
onTrackPlayNext={queuePlaylistTrackNext}
|
|
onTrackPlayLater={queuePlaylistTrackLater}
|
|
onTrackAddFavorite={handleAddToFavorites}
|
|
onTrackAddToPlaylist={(trackId) => openAddToPlaylist([trackId])}
|
|
onTrackShareQobuz={shareQobuzTrackLink}
|
|
onTrackShareSonglink={(track) => shareSonglinkTrack(track.id, track.isrc)}
|
|
onTrackGoToAlbum={handleAlbumClick}
|
|
onTrackGoToArtist={handleArtistClick}
|
|
onTrackShowInfo={showTrackInfo}
|
|
onTrackDownload={handleDisplayTrackDownload}
|
|
onTrackRemoveDownload={handleTrackRemoveDownload}
|
|
onTrackReDownload={handleDisplayTrackDownload}
|
|
getTrackOfflineCacheStatus={getTrackOfflineCacheStatus}
|
|
onPlaylistSelect={selectPlaylist}
|
|
onPlaylistPlay={playPlaylistById}
|
|
onPlaylistPlayNext={queuePlaylistNextById}
|
|
onPlaylistPlayLater={queuePlaylistLaterById}
|
|
onPlaylistRemoveFavorite={removePlaylistFavoriteById}
|
|
onPlaylistShareQobuz={sharePlaylistQobuzLinkById}
|
|
selectedTab={getFavoritesTabFromView(activeView) ?? favoritesDefaultTab}
|
|
onTabNavigate={(tab) => navigateToFavorites(tab)}
|
|
activeTrackId={currentTrack?.id ?? null}
|
|
isPlaybackActive={isPlaying}
|
|
/>
|
|
{/if}
|
|
{:else if activeView === 'playlist-manager'}
|
|
<PlaylistManagerView
|
|
onBack={navGoBack}
|
|
onPlaylistSelect={selectPlaylist}
|
|
onPlaylistsChanged={() => {
|
|
sidebarRef?.refreshPlaylists();
|
|
sidebarRef?.refreshPlaylistSettings();
|
|
sidebarRef?.refreshLocalTrackCounts();
|
|
}}
|
|
/>
|
|
{:else if activeView === 'blacklist-manager'}
|
|
<BlacklistManagerView
|
|
onBack={navGoBack}
|
|
onArtistSelect={handleArtistClick}
|
|
/>
|
|
{/if}
|
|
</main>
|
|
|
|
<!-- Lyrics Sidebar -->
|
|
{#if lyricsSidebarVisible}
|
|
<LyricsSidebar
|
|
title={currentTrack?.title}
|
|
artist={currentTrack?.artist}
|
|
lines={lyricsLines.map(l => ({ text: l.text }))}
|
|
activeIndex={lyricsActiveIndex}
|
|
activeProgress={lyricsActiveProgress}
|
|
isSynced={lyricsIsSynced}
|
|
isLoading={lyricsStatus === 'loading'}
|
|
error={lyricsStatus === 'error' ? lyricsError : (lyricsStatus === 'not_found' ? 'No lyrics found' : null)}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div><!-- end app-body -->
|
|
|
|
<!-- Now Playing Bar -->
|
|
{#if currentTrack}
|
|
<NowPlayingBar
|
|
artwork={currentTrack.artwork}
|
|
trackTitle={currentTrack.title}
|
|
artist={currentTrack.artist}
|
|
album={currentTrack.album}
|
|
quality={currentTrack.quality}
|
|
bitDepth={currentTrack.bitDepth}
|
|
samplingRate={currentTrack.samplingRate}
|
|
originalBitDepth={currentTrack.originalBitDepth}
|
|
originalSamplingRate={currentTrack.originalSamplingRate}
|
|
format={currentTrack.format}
|
|
{isPlaying}
|
|
onTogglePlay={togglePlay}
|
|
onSkipBack={handleSkipBack}
|
|
onSkipForward={handleSkipForward}
|
|
{currentTime}
|
|
{duration}
|
|
onSeek={handleSeek}
|
|
{volume}
|
|
onVolumeChange={handleVolumeChange}
|
|
{isShuffle}
|
|
onToggleShuffle={toggleShuffle}
|
|
{repeatMode}
|
|
onToggleRepeat={toggleRepeat}
|
|
{isFavorite}
|
|
onToggleFavorite={toggleFavorite}
|
|
onAddToPlaylist={openAddToPlaylistModal}
|
|
onOpenQueue={toggleQueue}
|
|
onOpenFullScreen={openFullScreen}
|
|
onOpenMiniPlayer={enterMiniplayerMode}
|
|
onCast={openCastPicker}
|
|
{isCastConnected}
|
|
onToggleLyrics={toggleLyricsSidebar}
|
|
lyricsActive={lyricsSidebarVisible}
|
|
onArtistClick={() => {
|
|
if (currentTrack?.isLocal) {
|
|
showToast($t('toast.localTrackSearch'), 'info');
|
|
} else if (currentTrack?.artistId) {
|
|
handleArtistClick(currentTrack.artistId);
|
|
}
|
|
}}
|
|
onAlbumClick={() => {
|
|
if (currentTrack?.isLocal) {
|
|
navigateTo('library');
|
|
} else if (currentTrack?.albumId) {
|
|
handleAlbumClick(currentTrack.albumId);
|
|
}
|
|
}}
|
|
onContextClick={handleContextNavigation}
|
|
queueOpen={isQueueOpen}
|
|
onTrackClick={() => {
|
|
if (currentTrack && !currentTrack.isLocal) {
|
|
trackInfoTrackId = currentTrack.id;
|
|
isTrackInfoOpen = true;
|
|
}
|
|
}}
|
|
/>
|
|
{:else}
|
|
<NowPlayingBar
|
|
onOpenQueue={toggleQueue}
|
|
onOpenFullScreen={openFullScreen}
|
|
onOpenMiniPlayer={enterMiniplayerMode}
|
|
onCast={openCastPicker}
|
|
{isCastConnected}
|
|
queueOpen={isQueueOpen}
|
|
{volume}
|
|
onVolumeChange={handleVolumeChange}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Queue Panel -->
|
|
<QueuePanel
|
|
isOpen={isQueueOpen}
|
|
onClose={closeQueue}
|
|
currentTrack={currentQueueTrack ?? undefined}
|
|
upcomingTracks={queue}
|
|
{queueTotalTracks}
|
|
{queueRemainingTracks}
|
|
{historyTracks}
|
|
isRadioMode={getCurrentContext()?.type === 'radio'}
|
|
onPlayTrack={handleQueueTrackPlay}
|
|
onPlayHistoryTrack={handlePlayHistoryTrack}
|
|
onClearQueue={handleClearQueue}
|
|
onSaveAsPlaylist={handleSaveQueueAsPlaylist}
|
|
onReorderTrack={handleQueueReorder}
|
|
onToggleInfinitePlay={handleToggleInfinitePlay}
|
|
{infinitePlayEnabled}
|
|
{isPlaying}
|
|
/>
|
|
|
|
<!-- Immersive Player (replaces ExpandedPlayer + FocusMode) -->
|
|
{#if currentTrack}
|
|
<ImmersivePlayer
|
|
isOpen={isFullScreenOpen || isFocusModeOpen}
|
|
onClose={() => {
|
|
if (isFullScreenOpen) closeFullScreen();
|
|
if (isFocusModeOpen) closeFocusMode();
|
|
}}
|
|
artwork={currentTrack.artwork}
|
|
trackTitle={currentTrack.title}
|
|
artist={currentTrack.artist}
|
|
album={currentTrack.album}
|
|
trackId={currentTrack.id}
|
|
artistId={currentTrack.artistId}
|
|
quality={currentTrack.quality}
|
|
bitDepth={currentTrack.bitDepth}
|
|
samplingRate={currentTrack.samplingRate}
|
|
originalBitDepth={currentTrack.originalBitDepth}
|
|
originalSamplingRate={currentTrack.originalSamplingRate}
|
|
format={currentTrack.format}
|
|
{isPlaying}
|
|
onTogglePlay={togglePlay}
|
|
onSkipBack={handleSkipBack}
|
|
onSkipForward={handleSkipForward}
|
|
{currentTime}
|
|
{duration}
|
|
onSeek={handleSeek}
|
|
{volume}
|
|
onVolumeChange={handleVolumeChange}
|
|
{isShuffle}
|
|
onToggleShuffle={toggleShuffle}
|
|
{repeatMode}
|
|
onToggleRepeat={toggleRepeat}
|
|
{isFavorite}
|
|
onToggleFavorite={toggleFavorite}
|
|
lyricsLines={lyricsLines}
|
|
lyricsActiveIndex={lyricsActiveIndex}
|
|
lyricsActiveProgress={lyricsActiveProgress}
|
|
lyricsSynced={lyricsIsSynced}
|
|
lyricsLoading={lyricsStatus === 'loading'}
|
|
lyricsError={lyricsStatus === 'error' ? lyricsError : (lyricsStatus === 'not_found' ? 'No lyrics found' : null)}
|
|
enableCredits={true}
|
|
enableSuggestions={true}
|
|
queueTracks={[
|
|
...historyTracks,
|
|
...(currentQueueTrack ? [currentQueueTrack] : []),
|
|
...queue
|
|
]}
|
|
queueCurrentIndex={historyTracks.length}
|
|
onQueuePlayTrack={(index) => {
|
|
const historyLen = historyTracks.length;
|
|
if (index < historyLen) {
|
|
// Playing from history
|
|
handlePlayHistoryTrack(historyTracks[index]?.id ?? '');
|
|
} else if (index > historyLen) {
|
|
// Playing from upcoming queue
|
|
const queueIndex = index - historyLen - 1;
|
|
handleQueueTrackPlay(queue[queueIndex]?.id?.toString() ?? '');
|
|
}
|
|
// index === historyLen is current track, do nothing
|
|
}}
|
|
onQueueClear={handleClearQueue}
|
|
{historyTracks}
|
|
onPlayHistoryTrack={handlePlayHistoryTrack}
|
|
isInfinitePlay={infinitePlayEnabled}
|
|
onToggleInfinitePlay={handleToggleInfinitePlay}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Toast -->
|
|
{#if toast}
|
|
<Toast
|
|
message={toast.message}
|
|
type={toast.type}
|
|
persistent={toast.persistent}
|
|
onClose={hideToast}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Playlist Modal -->
|
|
<PlaylistModal
|
|
isOpen={isPlaylistModalOpen}
|
|
mode={playlistModalMode}
|
|
trackIds={playlistModalTrackIds}
|
|
isLocalTracks={playlistModalTracksAreLocal}
|
|
{userPlaylists}
|
|
onClose={closePlaylistModal}
|
|
onSuccess={handlePlaylistCreated}
|
|
/>
|
|
|
|
<!-- Playlist Import Modal -->
|
|
<PlaylistImportModal
|
|
isOpen={isPlaylistImportOpen}
|
|
onClose={closePlaylistImport}
|
|
onSuccess={handlePlaylistImported}
|
|
/>
|
|
|
|
<!-- About Modal -->
|
|
<AboutModal
|
|
isOpen={isAboutModalOpen}
|
|
onClose={() => isAboutModalOpen = false}
|
|
/>
|
|
|
|
{#if updateRelease}
|
|
<UpdateAvailableModal
|
|
isOpen={isUpdateModalOpen}
|
|
currentVersion={updatesCurrentVersion}
|
|
newVersion={updateRelease.version}
|
|
onClose={handleUpdateClose}
|
|
onVisitReleasePage={handleUpdateVisit}
|
|
/>
|
|
|
|
<UpdateReminderModal
|
|
isOpen={isReminderModalOpen}
|
|
onClose={handleReminderClose}
|
|
onRemindLater={handleReminderLater}
|
|
onIgnoreRelease={handleReminderIgnoreRelease}
|
|
onDisableAllUpdates={handleReminderDisableUpdates}
|
|
/>
|
|
{/if}
|
|
|
|
{#if whatsNewRelease}
|
|
<WhatsNewModal
|
|
isOpen={isWhatsNewModalOpen}
|
|
release={whatsNewRelease}
|
|
{showTitleBar}
|
|
onClose={handleWhatsNewClose}
|
|
/>
|
|
{/if}
|
|
|
|
<FlatpakWelcomeModal
|
|
isOpen={isFlatpakWelcomeOpen}
|
|
onClose={handleFlatpakWelcomeClose}
|
|
/>
|
|
|
|
<!-- Track Info Modal -->
|
|
<TrackInfoModal
|
|
isOpen={isTrackInfoOpen}
|
|
trackId={trackInfoTrackId}
|
|
onClose={() => {
|
|
isTrackInfoOpen = false;
|
|
trackInfoTrackId = null;
|
|
}}
|
|
onArtistClick={handleArtistClick}
|
|
onLabelClick={handleLabelClick}
|
|
onMusicianClick={handleMusicianClick}
|
|
/>
|
|
|
|
<!-- Album Credits Modal -->
|
|
<AlbumCreditsModal
|
|
isOpen={isAlbumCreditsOpen}
|
|
albumId={albumCreditsAlbumId}
|
|
onClose={() => {
|
|
isAlbumCreditsOpen = false;
|
|
albumCreditsAlbumId = null;
|
|
}}
|
|
onTrackPlay={(trackCredits) => {
|
|
// Find the corresponding track in the selected album and play it
|
|
if (selectedAlbum?.tracks) {
|
|
const track = selectedAlbum.tracks.find(t => t.id === trackCredits.id);
|
|
if (track) {
|
|
handleAlbumTrackPlay(track);
|
|
}
|
|
}
|
|
}}
|
|
onLabelClick={handleLabelClick}
|
|
onMusicianClick={handleMusicianClick}
|
|
/>
|
|
|
|
<!-- Musician Modal (for confidence level 0-1) -->
|
|
{#if musicianModalData}
|
|
<MusicianModal
|
|
musician={musicianModalData}
|
|
onClose={closeMusicianModal}
|
|
onNavigateToArtist={handleArtistClick}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Cast Picker -->
|
|
<CastPicker
|
|
isOpen={isCastPickerOpen}
|
|
onClose={closeCastPicker}
|
|
/>
|
|
|
|
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
background-color: var(--bg-primary);
|
|
}
|
|
|
|
.app-body {
|
|
display: flex;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.content-area {
|
|
display: flex;
|
|
flex: 1;
|
|
min-width: 0;
|
|
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
|
|
overflow: hidden;
|
|
position: relative;
|
|
}
|
|
|
|
.main-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
height: calc(100vh - 136px); /* 104px NowPlayingBar + 32px TitleBar */
|
|
overflow: hidden; /* Views handle their own scrolling */
|
|
padding-right: 8px; /* Gap between scrollbar and window edge */
|
|
background-color: var(--bg-primary, #0f0f0f);
|
|
}
|
|
|
|
/* Adjust heights when title bar is hidden */
|
|
.app.no-titlebar .content-area,
|
|
.app.no-titlebar .main-content {
|
|
height: calc(100vh - 104px); /* Only 104px NowPlayingBar, no title bar */
|
|
}
|
|
|
|
</style>
|