svelte-i18n requires { values: { key: value } } format for variable
interpolation, not { key: value } directly. Fixed across all toast
and greeting calls.
3768 lines
116 KiB
Svelte
3768 lines
116 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import ViewTransition from '../ViewTransition.svelte';
|
|
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
|
import { writeText as copyToClipboard } from '@tauri-apps/plugin-clipboard-manager';
|
|
import { ArrowLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, Sun, Moon, SunMoon, HelpCircle } from 'lucide-svelte';
|
|
import Toggle from '../Toggle.svelte';
|
|
import Dropdown from '../Dropdown.svelte';
|
|
import DeviceDropdown from '../DeviceDropdown.svelte';
|
|
import AlsaUtilsHelpModal from '../AlsaUtilsHelpModal.svelte';
|
|
import VolumeSlider from '../VolumeSlider.svelte';
|
|
import UpdateCheckResultModal from '../updates/UpdateCheckResultModal.svelte';
|
|
import WhatsNewModal from '../updates/WhatsNewModal.svelte';
|
|
import {
|
|
getOfflineCacheStats,
|
|
clearOfflineCache,
|
|
type OfflineCacheStats
|
|
} from '$lib/stores/offlineCacheState';
|
|
import { notifyDownloadSettingsChanged } from '$lib/stores/downloadSettingsStore';
|
|
import { clearCache as clearLyricsCache } from '$lib/stores/lyricsStore';
|
|
import {
|
|
getToastsEnabled,
|
|
setToastsEnabled,
|
|
loadToastsPreference
|
|
} from '$lib/stores/toastStore';
|
|
import {
|
|
getSystemNotificationsEnabled,
|
|
setSystemNotificationsEnabled,
|
|
loadSystemNotificationsPreference
|
|
} from '$lib/services/playbackService';
|
|
import { setLocale, locale, t } from '$lib/i18n';
|
|
import { get } from 'svelte/store';
|
|
import MigrationModal from '../MigrationModal.svelte';
|
|
import { getDevicePrettyName } from '$lib/utils/audioDeviceNames';
|
|
import { ZOOM_OPTIONS, findZoomOption, getZoomLevelFromOption } from '$lib/utils/zoom';
|
|
import { getZoom, setZoom, subscribeZoom } from '$lib/stores/zoomStore';
|
|
import {
|
|
subscribe as subscribeOffline,
|
|
getStatus as getOfflineStatus,
|
|
getSettings as getOfflineSettings,
|
|
setManualOffline,
|
|
setShowPartialPlaylists,
|
|
setAllowCastWhileOffline,
|
|
setAllowImmediateScrobbling,
|
|
setAllowAccumulatedScrobbling,
|
|
setShowNetworkFoldersInManualOffline,
|
|
checkNetwork,
|
|
type OfflineStatus,
|
|
type OfflineSettings
|
|
} from '$lib/stores/offlineStore';
|
|
import { showToast } from '$lib/stores/toastStore';
|
|
import {
|
|
subscribe as subscribeTitleBar,
|
|
getHideTitleBar,
|
|
setHideTitleBar
|
|
} from '$lib/stores/titleBarStore';
|
|
import {
|
|
getPlaybackPreferences,
|
|
setAutoplayMode,
|
|
setShowContextIcon,
|
|
type AutoplayMode
|
|
} from '$lib/stores/playbackPreferencesStore';
|
|
import {
|
|
subscribe as subscribeUpdates,
|
|
checkForUpdates,
|
|
fetchReleaseForVersion,
|
|
getCurrentVersion as getUpdatesCurrentVersion,
|
|
getPreferences as getUpdatePreferences,
|
|
initUpdatesStore,
|
|
setCheckOnLaunch,
|
|
setShowWhatsNewOnLaunch,
|
|
type ReleaseInfo,
|
|
type UpdateCheckStatus,
|
|
type UpdatePreferences
|
|
} from '$lib/stores/updatesStore';
|
|
import { openReleasePageAndAcknowledge } from '$lib/services/updatesService';
|
|
|
|
interface Props {
|
|
onBack?: () => void;
|
|
onLogout?: () => void;
|
|
userName?: string;
|
|
userEmail?: string;
|
|
subscription?: string;
|
|
subscriptionValidUntil?: string | null;
|
|
showTitleBar?: boolean;
|
|
}
|
|
|
|
interface CacheStats {
|
|
cached_tracks: number;
|
|
current_size_bytes: number;
|
|
max_size_bytes: number;
|
|
fetching_count: number;
|
|
}
|
|
|
|
interface PipewireSink {
|
|
name: string;
|
|
description: string;
|
|
volume: number | null;
|
|
is_default: boolean;
|
|
}
|
|
|
|
interface HardwareAudioStatus {
|
|
hardware_sample_rate: number | null;
|
|
hardware_format: string | null;
|
|
is_active: boolean;
|
|
}
|
|
|
|
let {
|
|
onBack,
|
|
onLogout,
|
|
userName = 'User',
|
|
userEmail = '',
|
|
subscription = 'Qobuz™',
|
|
subscriptionValidUntil = null,
|
|
showTitleBar = true
|
|
}: Props = $props();
|
|
|
|
// Cache state (memory cache)
|
|
let cacheStats = $state<CacheStats | null>(null);
|
|
let isClearing = $state(false);
|
|
|
|
// Download cache state (offline storage)
|
|
let downloadStats = $state<OfflineCacheStats | null>(null);
|
|
let isClearingDownloads = $state(false);
|
|
let isRepairingDownloads = $state(false);
|
|
|
|
// Lyrics cache state
|
|
let isClearingLyrics = $state(false);
|
|
let lyricsCacheStats = $state<{ entries: number; sizeBytes: number } | null>(null);
|
|
let isClearingMusicBrainz = $state(false);
|
|
let musicBrainzCacheStats = $state<{ recordings: number; artists: number; releases: number; relations: number } | null>(null);
|
|
let isClearingVectorStore = $state(false);
|
|
let vectorStoreStats = $state<{ artist_count: number; vector_count: number; entry_count: number } | null>(null);
|
|
|
|
// Artwork cache state (local library thumbnails)
|
|
let isClearingArtwork = $state(false);
|
|
let artworkCacheStats = $state<{ artwork_cache_bytes: number; thumbnails_cache_bytes: number; artwork_file_count: number; thumbnail_file_count: number } | null>(null);
|
|
let isClearingAllCaches = $state(false);
|
|
|
|
// Migration state
|
|
let showMigrationModal = $state(false);
|
|
let legacyTracksCount = $state(0);
|
|
|
|
// ALSA Utils help modal
|
|
let showAlsaUtilsHelpModal = $state(false);
|
|
|
|
// Offline mode state
|
|
let offlineStatus = $state<OfflineStatus>(getOfflineStatus());
|
|
let offlineSettings = $state<OfflineSettings>(getOfflineSettings());
|
|
|
|
// Flatpak detection state
|
|
let isFlatpak = $state(false);
|
|
let flatpakHelpText = $state('');
|
|
let isCheckingNetwork = $state(false);
|
|
|
|
// Updates state
|
|
let updatePreferences = $state<UpdatePreferences>(getUpdatePreferences());
|
|
let updatesCurrentVersion = $state(getUpdatesCurrentVersion());
|
|
let isCheckingUpdates = $state(false);
|
|
let isUpdateResultOpen = $state(false);
|
|
let updateResultStatus = $state<UpdateCheckStatus>('no_updates');
|
|
let updateResultRelease = $state<ReleaseInfo | null>(null);
|
|
let isSettingsWhatsNewOpen = $state(false);
|
|
let settingsWhatsNewRelease = $state<ReleaseInfo | null>(null);
|
|
|
|
// Section navigation
|
|
let settingsViewEl: HTMLDivElement;
|
|
let audioSection: HTMLElement;
|
|
let playbackSection: HTMLElement;
|
|
let offlineModeSection: HTMLElement;
|
|
let appearanceSection: HTMLElement;
|
|
let downloadsSection: HTMLElement;
|
|
let librarySection: HTMLElement;
|
|
let integrationsSection: HTMLElement;
|
|
let updatesSection: HTMLElement;
|
|
let storageSection: HTMLElement;
|
|
let flatpakSection: HTMLElement;
|
|
let activeSection = $state('audio');
|
|
|
|
// Collapsible sections state (closed by default)
|
|
let offlineLibraryCollapsed = $state(true);
|
|
let storageCollapsed = $state(true);
|
|
|
|
// Navigation section IDs with translation keys
|
|
const navSectionIds = [
|
|
{ id: 'audio', labelKey: 'settings.audio.title' },
|
|
{ id: 'playback', labelKey: 'settings.playback.title' },
|
|
{ id: 'offline', labelKey: 'offline.title' },
|
|
{ id: 'appearance', labelKey: 'settings.appearance.title' },
|
|
{ id: 'downloads', labelKey: 'settings.offlineLibrary.title' },
|
|
{ id: 'library', labelKey: 'settings.library.title' },
|
|
{ id: 'integrations', labelKey: 'settings.integrations.title' },
|
|
{ id: 'updates', labelKey: 'nav.updates' },
|
|
{ id: 'storage', labelKey: 'settings.storage.title' },
|
|
];
|
|
|
|
// Navigation section definitions (dynamic: includes Flatpak only when running in Flatpak)
|
|
// NOTE: If adding new sections, add them BEFORE Flatpak. Flatpak must always be last.
|
|
const navSectionDefs = $derived(
|
|
isFlatpak
|
|
? [...navSectionIds, { id: 'flatpak', labelKey: 'nav.flatpak' }]
|
|
: navSectionIds
|
|
);
|
|
|
|
// Get section element by id (resolved at call time, not definition time)
|
|
function getSectionEl(id: string): HTMLElement | undefined {
|
|
switch (id) {
|
|
case 'audio': return audioSection;
|
|
case 'playback': return playbackSection;
|
|
case 'offline': return offlineModeSection;
|
|
case 'appearance': return appearanceSection;
|
|
case 'downloads': return downloadsSection;
|
|
case 'library': return librarySection;
|
|
case 'integrations': return integrationsSection;
|
|
case 'updates': return updatesSection;
|
|
case 'storage': return storageSection;
|
|
case 'flatpak': return flatpakSection;
|
|
default: return undefined;
|
|
}
|
|
}
|
|
|
|
function scrollToSection(id: string) {
|
|
const el = getSectionEl(id);
|
|
if (!el) return;
|
|
activeSection = id;
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
async function handleUpdateCheckOnLaunchToggle(enabled: boolean): Promise<void> {
|
|
await setCheckOnLaunch(enabled);
|
|
}
|
|
|
|
async function handleShowWhatsNewToggle(enabled: boolean): Promise<void> {
|
|
await setShowWhatsNewOnLaunch(enabled);
|
|
}
|
|
|
|
async function handleManualUpdateCheck(): Promise<void> {
|
|
if (isCheckingUpdates) return;
|
|
isCheckingUpdates = true;
|
|
try {
|
|
const result = await checkForUpdates('manual');
|
|
updateResultStatus = result.status;
|
|
updateResultRelease = result.release;
|
|
isUpdateResultOpen = true;
|
|
} finally {
|
|
isCheckingUpdates = false;
|
|
}
|
|
}
|
|
|
|
function handleCloseUpdateResult(): void {
|
|
isUpdateResultOpen = false;
|
|
}
|
|
|
|
function handleVisitReleaseFromResult(): void {
|
|
if (!updateResultRelease) return;
|
|
void openReleasePageAndAcknowledge(updateResultRelease);
|
|
isUpdateResultOpen = false;
|
|
}
|
|
|
|
async function handleShowCurrentChangelog(): Promise<void> {
|
|
const version = updatesCurrentVersion || getUpdatesCurrentVersion();
|
|
if (!version) return;
|
|
const release = await fetchReleaseForVersion(version);
|
|
if (!release) return;
|
|
settingsWhatsNewRelease = release;
|
|
isSettingsWhatsNewOpen = true;
|
|
}
|
|
|
|
function handleCloseSettingsWhatsNew(): void {
|
|
isSettingsWhatsNewOpen = false;
|
|
settingsWhatsNewRelease = null;
|
|
}
|
|
|
|
// Audio device state - use PipeWire sinks directly for friendly names
|
|
let pipewireSinks = $state<PipewireSink[]>([]);
|
|
let hardwareStatus = $state<HardwareAudioStatus | null>(null);
|
|
|
|
// Map of description -> sink name (for looking up sink name when user selects)
|
|
const sinkDescriptionToName = $derived.by(() => {
|
|
const map = new Map<string, string>();
|
|
for (const sink of pipewireSinks) {
|
|
map.set(sink.description, sink.name);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
// Map of sink name -> description (for displaying current selection)
|
|
const sinkNameToDescription = $derived.by(() => {
|
|
const map = new Map<string, string>();
|
|
for (const sink of pipewireSinks) {
|
|
map.set(sink.name, sink.description);
|
|
}
|
|
return map;
|
|
});
|
|
|
|
// Options for dropdown - use PipeWire descriptions directly (already friendly names)
|
|
let audioDeviceOptions = $derived(['System Default', ...pipewireSinks.map(s => s.description)]);
|
|
|
|
// Theme metadata with type classification
|
|
type ThemeType = 'dark' | 'light';
|
|
interface ThemeInfo {
|
|
value: string; // data-theme value
|
|
type: ThemeType; // dark or light
|
|
}
|
|
|
|
const themes: Record<string, ThemeInfo> = {
|
|
// Dark themes
|
|
'Dark': { value: '', type: 'dark' },
|
|
'OLED Black': { value: 'oled', type: 'dark' },
|
|
'Warm': { value: 'warm', type: 'dark' },
|
|
'Nord': { value: 'nord', type: 'dark' },
|
|
'Dracula': { value: 'dracula', type: 'dark' },
|
|
'Tokyo Night': { value: 'tokyo-night', type: 'dark' },
|
|
'Catppuccin Mocha': { value: 'catppuccin-mocha', type: 'dark' },
|
|
'Breeze Dark': { value: 'breeze-dark', type: 'dark' },
|
|
'Adwaita Dark': { value: 'adwaita-dark', type: 'dark' },
|
|
'Alucard': { value: 'alucard', type: 'light' },
|
|
'Aurora': { value: 'aurora', type: 'dark' },
|
|
'Ikari': { value: 'ikari', type: 'dark' },
|
|
'Ayanami': { value: 'ayanami', type: 'dark' },
|
|
'Iscariot': { value: 'iscariot', type: 'dark' },
|
|
'Rumi': { value: 'rumi', type: 'dark' },
|
|
'Zoey': { value: 'zoey', type: 'dark' },
|
|
'Mira': { value: 'mira', type: 'dark' },
|
|
// Light themes
|
|
'Light': { value: 'light', type: 'light' },
|
|
'Rose Pine Dawn': { value: 'rose-pine-dawn', type: 'light' },
|
|
'Breeze Light': { value: 'breeze-light', type: 'light' },
|
|
'Adwaita Light': { value: 'adwaita-light', type: 'light' },
|
|
'Duotone Snow': { value: 'duotone-snow', type: 'light' },
|
|
'Snow Storm': { value: 'snow-storm', type: 'light' },
|
|
'Frost': { value: 'frost', type: 'light' },
|
|
'Langley': { value: 'langley', type: 'light' },
|
|
'Kurosaki': { value: 'kurosaki', type: 'light' },
|
|
};
|
|
|
|
// Generate maps from themes object for compatibility
|
|
const themeMap: Record<string, string> = Object.fromEntries(
|
|
Object.entries(themes).map(([name, info]) => [name, info.value])
|
|
);
|
|
|
|
const themeReverseMap: Record<string, string> = Object.fromEntries(
|
|
Object.entries(themes).map(([name, info]) => [info.value, name])
|
|
);
|
|
|
|
// Theme filter state: 'all' | 'dark' | 'light'
|
|
type ThemeFilter = 'all' | 'dark' | 'light';
|
|
let themeFilter = $state<ThemeFilter>('all');
|
|
|
|
// Filtered theme options based on current filter
|
|
const filteredThemeOptions = $derived(
|
|
themeFilter === 'all'
|
|
? Object.keys(themes)
|
|
: Object.entries(themes)
|
|
.filter(([_, info]) => info.type === themeFilter)
|
|
.map(([name]) => name)
|
|
);
|
|
|
|
function cycleThemeFilter() {
|
|
if (themeFilter === 'all') themeFilter = 'dark';
|
|
else if (themeFilter === 'dark') themeFilter = 'light';
|
|
else themeFilter = 'all';
|
|
}
|
|
|
|
// Language mapping: display name -> locale code
|
|
const languageToLocale: Record<string, string | null> = {
|
|
'Auto': null,
|
|
'English': 'en',
|
|
'Español': 'es'
|
|
};
|
|
|
|
const localeToLanguage: Record<string, string> = {
|
|
'en': 'English',
|
|
'es': 'Español'
|
|
};
|
|
|
|
// Available languages (only those with translations)
|
|
const availableLanguages = ['Auto', 'English', 'Español'];
|
|
|
|
// Audio settings
|
|
let streamingQuality = $state('Hi-Res+');
|
|
let outputDevice = $state('System Default');
|
|
let exclusiveMode = $state(false);
|
|
let dacPassthrough = $state(false);
|
|
let selectedBackend = $state<string>('Auto');
|
|
let selectedAlsaPlugin = $state<string>('hw (Direct Hardware)');
|
|
let alsaHardwareVolume = $state(false);
|
|
let streamFirstTrack = $state(false);
|
|
let streamBufferSeconds = $state(3);
|
|
let streamingOnly = $state(false);
|
|
let limitQualityToDevice = $state(true); // Enabled by default for bit-perfect
|
|
|
|
// Backend system state
|
|
let availableBackends = $state<BackendInfo[]>([]);
|
|
let backendDevices = $state<AudioDevice[]>([]);
|
|
let alsaPlugins = $state<AlsaPluginInfo[]>([]);
|
|
let isLoadingDevices = $state(false);
|
|
|
|
// Backend selector options (derived)
|
|
// TEST: Re-enable ALSA Direct to verify if CPAL can actually open hw: devices
|
|
let backendOptions = $derived(['Auto', ...availableBackends.filter(b => b.is_available).map(b => b.name)]);
|
|
|
|
// Helper to check if a device name looks like raw ALSA (needs translation)
|
|
function needsTranslation(name: string): boolean {
|
|
// PipeWire device names start with "alsa_output." - those already have friendly names
|
|
if (name.startsWith('alsa_output.')) {
|
|
return false;
|
|
}
|
|
|
|
// Everything else from ALSA needs translation
|
|
return true;
|
|
}
|
|
|
|
// Device options based on selected backend (derived)
|
|
// For ALSA: use description from aplay -L if available, otherwise translate
|
|
// For PipeWire/PulseAudio: names are already friendly
|
|
let deviceOptions = $derived.by(() => {
|
|
// First pass: generate display names
|
|
const displayNames = backendDevices.map(d => {
|
|
if (d.description && selectedBackend === 'ALSA Direct') {
|
|
return d.description;
|
|
}
|
|
return needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name;
|
|
});
|
|
|
|
// Second pass: check for duplicates and make unique if needed
|
|
const counts = new Map<string, number>();
|
|
const uniqueNames = displayNames.map((name, idx) => {
|
|
const count = counts.get(name) || 0;
|
|
counts.set(name, count + 1);
|
|
|
|
// If duplicate, append device ID to make it unique
|
|
if (displayNames.filter(n => n === name).length > 1) {
|
|
const device = backendDevices[idx];
|
|
return `${name} [${device.name}]`;
|
|
}
|
|
return name;
|
|
});
|
|
|
|
return ['System Default', ...uniqueNames];
|
|
});
|
|
|
|
// Map display name -> device for lookup when user selects
|
|
let deviceByDisplayName = $derived.by(() => {
|
|
const map = new Map<string, AudioDevice>();
|
|
|
|
// Use same logic as deviceOptions to generate unique names
|
|
const displayNames = backendDevices.map(d => {
|
|
if (d.description && selectedBackend === 'ALSA Direct') {
|
|
return d.description;
|
|
}
|
|
return needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name;
|
|
});
|
|
|
|
backendDevices.forEach((device, idx) => {
|
|
let displayName = displayNames[idx];
|
|
|
|
// If duplicate, append device ID
|
|
if (displayNames.filter(n => n === displayName).length > 1) {
|
|
displayName = `${displayName} [${device.name}]`;
|
|
}
|
|
|
|
map.set(displayName, device);
|
|
});
|
|
|
|
return map;
|
|
});
|
|
|
|
// Device options for grouped dropdown (works for both ALSA and PipeWire)
|
|
let groupedDeviceOptions = $derived.by(() => {
|
|
// System Default is always first
|
|
const options: {
|
|
value: string;
|
|
id: string;
|
|
isDefault?: boolean;
|
|
sampleRates?: number[];
|
|
deviceBus?: string;
|
|
isHardware?: boolean;
|
|
}[] = [
|
|
{ value: 'System Default', id: 'system-default', isDefault: true }
|
|
];
|
|
|
|
// Generate unique display names (same logic as deviceOptions)
|
|
const displayNames = backendDevices.map(d => {
|
|
if (d.description && selectedBackend === 'ALSA Direct') {
|
|
return d.description;
|
|
}
|
|
return needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name;
|
|
});
|
|
|
|
backendDevices.forEach((device, idx) => {
|
|
let displayName = displayNames[idx];
|
|
|
|
// If duplicate, append device ID to make unique
|
|
if (displayNames.filter(n => n === displayName).length > 1) {
|
|
displayName = `${displayName} [${device.name}]`;
|
|
}
|
|
|
|
options.push({
|
|
value: displayName,
|
|
id: device.id,
|
|
isDefault: device.is_default,
|
|
sampleRates: device.supported_sample_rates ?? undefined,
|
|
deviceBus: device.device_bus ?? undefined,
|
|
isHardware: device.is_hardware
|
|
});
|
|
});
|
|
|
|
return options;
|
|
});
|
|
|
|
// ALSA plugin options (derived)
|
|
let alsaPluginOptions = $derived(alsaPlugins.map(p => p.name));
|
|
|
|
// Show ALSA plugin selector only when ALSA backend is selected (derived)
|
|
let showAlsaPluginSelector = $derived(selectedBackend === 'ALSA Direct');
|
|
|
|
// Show hardware volume control only for ALSA Direct + Hw plugin (bit-perfect)
|
|
let showAlsaHardwareVolume = $derived(
|
|
selectedBackend === 'ALSA Direct' && selectedAlsaPlugin === 'hw (Direct Hardware)'
|
|
);
|
|
|
|
// Smart toggle states - auto-disable incompatible features
|
|
let exclusiveModeDisabled = $derived(selectedBackend === 'PipeWire' || selectedBackend === 'Auto' || selectedBackend === 'PulseAudio');
|
|
let exclusiveModeTooltipOverride = $derived(
|
|
exclusiveModeDisabled
|
|
? 'Exclusive mode is only available with ALSA Direct backend. PipeWire and PulseAudio are multiplexed audio servers and cannot provide true exclusive access.'
|
|
: null
|
|
);
|
|
let dacPassthroughDisabled = $derived(selectedBackend !== 'PipeWire');
|
|
let dacPassthroughTooltipOverride = $derived(
|
|
dacPassthroughDisabled
|
|
? 'DAC Passthrough uses pw-metadata to force PipeWire sample rates. It is only compatible with the PipeWire backend.'
|
|
: null
|
|
);
|
|
let gaplessDisabled = $derived(dacPassthrough);
|
|
let gaplessTooltipOverride = $derived(
|
|
gaplessDisabled
|
|
? 'Gapless playback is disabled when DAC Passthrough is enabled. Bit-perfect audio requires recreating streams for each sample rate change.'
|
|
: null
|
|
);
|
|
|
|
// Playback settings
|
|
let autoplayMode = $state<AutoplayMode>('continue');
|
|
let showContextIcon = $state(true);
|
|
let gaplessPlayback = $state(true);
|
|
let crossfade = $state(0);
|
|
let normalizeVolume = $state(false);
|
|
|
|
// UI scale settings
|
|
const zoomOptions = [...ZOOM_OPTIONS];
|
|
let zoomLevel = $state('100%');
|
|
|
|
// Appearance settings
|
|
let theme = $state('Dark');
|
|
let toastsEnabled = $state(true);
|
|
let systemNotificationsEnabled = $state(true);
|
|
let language = $state('Auto');
|
|
|
|
// Title bar settings
|
|
let hideTitleBar = $state(getHideTitleBar());
|
|
|
|
// Tray settings
|
|
let enableTray = $state(true);
|
|
let minimizeToTray = $state(false);
|
|
let closeToTray = $state(false);
|
|
|
|
// Library settings
|
|
let fetchQobuzArtistImages = $state(true);
|
|
let showQobuzDownloadsInLibrary = $state(false);
|
|
|
|
// Last.fm integration state
|
|
let lastfmConnected = $state(false);
|
|
let lastfmUsername = $state('');
|
|
let scrobbling = $state(true);
|
|
let lastfmApiKey = $state('');
|
|
let lastfmApiSecret = $state('');
|
|
let lastfmAuthToken = $state('');
|
|
let lastfmConnecting = $state(false);
|
|
let showLastfmConfig = $state(false);
|
|
let hasEmbeddedCredentials = $state(false);
|
|
|
|
// MusicBrainz integration state
|
|
let musicbrainzEnabled = $state(true);
|
|
|
|
// ListenBrainz integration state
|
|
let listenbrainzConnected = $state(false);
|
|
let listenbrainzUsername = $state('');
|
|
let listenbrainzEnabled = $state(true);
|
|
let listenbrainzToken = $state('');
|
|
let listenbrainzConnecting = $state(false);
|
|
let showListenBrainzConfig = $state(false);
|
|
|
|
// Load saved settings on mount
|
|
onMount(() => {
|
|
// Load theme
|
|
const savedTheme = localStorage.getItem('qbz-theme') || '';
|
|
theme = themeReverseMap[savedTheme] || 'Dark';
|
|
applyTheme(savedTheme);
|
|
|
|
// Load streaming quality preference
|
|
const savedQuality = localStorage.getItem('qbz-streaming-quality');
|
|
if (savedQuality) {
|
|
streamingQuality = savedQuality;
|
|
}
|
|
|
|
// Load language setting from i18n locale
|
|
const currentLocale = get(locale);
|
|
if (currentLocale && localeToLanguage[currentLocale]) {
|
|
language = localeToLanguage[currentLocale];
|
|
} else {
|
|
language = 'Auto';
|
|
}
|
|
|
|
const updateZoomLevel = (value: number) => {
|
|
const match = findZoomOption(value);
|
|
if (match) {
|
|
zoomLevel = match;
|
|
}
|
|
};
|
|
|
|
updateZoomLevel(getZoom());
|
|
const unsubscribeZoom = subscribeZoom(updateZoomLevel);
|
|
|
|
// Load library settings
|
|
const savedFetchArtistImages = localStorage.getItem('qbz-fetch-artist-images');
|
|
if (savedFetchArtistImages !== null) {
|
|
fetchQobuzArtistImages = savedFetchArtistImages === 'true';
|
|
}
|
|
|
|
// Load download settings
|
|
loadDownloadSettings();
|
|
|
|
// Load cache stats
|
|
loadCacheStats();
|
|
|
|
// Load download cache stats
|
|
loadDownloadStats();
|
|
|
|
// Load lyrics cache stats
|
|
loadLyricsCacheStats();
|
|
|
|
// Load MusicBrainz cache stats
|
|
loadMusicBrainzCacheStats();
|
|
|
|
// Load vector store stats
|
|
loadVectorStoreStats();
|
|
|
|
// Load artwork cache stats
|
|
loadArtworkCacheStats();
|
|
|
|
// Load audio devices first (includes PipeWire sinks), then settings
|
|
// Also load backends and ALSA plugins
|
|
Promise.all([
|
|
loadAudioDevices(),
|
|
loadBackends(),
|
|
loadAlsaPlugins()
|
|
]).then(() => loadAudioSettings());
|
|
|
|
// Load Last.fm state
|
|
loadLastfmState();
|
|
|
|
// Load MusicBrainz state
|
|
loadMusicBrainzState();
|
|
|
|
// Load ListenBrainz state
|
|
loadListenBrainzState();
|
|
|
|
// Load notification preferences
|
|
loadToastsPreference();
|
|
toastsEnabled = getToastsEnabled();
|
|
loadSystemNotificationsPreference();
|
|
systemNotificationsEnabled = getSystemNotificationsEnabled();
|
|
|
|
// Load playback preferences
|
|
loadPlaybackPreferences();
|
|
|
|
// Load tray settings
|
|
loadTraySettings();
|
|
|
|
// Initialize updates preferences/version state
|
|
initUpdatesStore();
|
|
const unsubscribeUpdates = subscribeUpdates(() => {
|
|
updatePreferences = getUpdatePreferences();
|
|
updatesCurrentVersion = getUpdatesCurrentVersion();
|
|
});
|
|
|
|
// Detect if running in Flatpak
|
|
loadFlatpakStatus();
|
|
|
|
// Check for legacy cached files
|
|
checkLegacyCachedFiles();
|
|
|
|
// Subscribe to offline state changes
|
|
const unsubscribeOffline = subscribeOffline(() => {
|
|
offlineStatus = getOfflineStatus();
|
|
offlineSettings = getOfflineSettings();
|
|
});
|
|
|
|
// Subscribe to title bar state changes
|
|
const unsubscribeTitleBar = subscribeTitleBar(() => {
|
|
hideTitleBar = getHideTitleBar();
|
|
});
|
|
|
|
// Scroll tracking for navigation
|
|
const handleScroll = () => {
|
|
if (!settingsViewEl) return;
|
|
const offset = 60; // Account for sticky nav height
|
|
|
|
// Find which section is currently in view
|
|
for (const def of navSectionDefs) {
|
|
const el = getSectionEl(def.id);
|
|
if (!el) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
const containerRect = settingsViewEl.getBoundingClientRect();
|
|
const relativeTop = rect.top - containerRect.top;
|
|
|
|
if (relativeTop <= offset + 50 && relativeTop + rect.height > offset) {
|
|
if (activeSection !== def.id) {
|
|
activeSection = def.id;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
settingsViewEl?.addEventListener('scroll', handleScroll);
|
|
|
|
return () => {
|
|
unsubscribeOffline();
|
|
unsubscribeZoom();
|
|
unsubscribeTitleBar();
|
|
unsubscribeUpdates();
|
|
settingsViewEl?.removeEventListener('scroll', handleScroll);
|
|
};
|
|
});
|
|
|
|
|
|
async function loadLastfmState() {
|
|
try {
|
|
// Check if embedded (build-time) credentials are available
|
|
hasEmbeddedCredentials = await invoke<boolean>('lastfm_has_embedded_credentials');
|
|
|
|
// Load saved credentials from localStorage (for user-provided keys)
|
|
const savedApiKey = localStorage.getItem('qbz-lastfm-api-key');
|
|
const savedApiSecret = localStorage.getItem('qbz-lastfm-api-secret');
|
|
const savedSessionKey = localStorage.getItem('qbz-lastfm-session-key');
|
|
const savedUsername = localStorage.getItem('qbz-lastfm-username');
|
|
const savedScrobbling = localStorage.getItem('qbz-lastfm-scrobbling');
|
|
|
|
// If we have user-provided credentials, set them
|
|
if (savedApiKey && savedApiSecret) {
|
|
lastfmApiKey = savedApiKey;
|
|
lastfmApiSecret = savedApiSecret;
|
|
await invoke('lastfm_set_credentials', {
|
|
apiKey: savedApiKey,
|
|
apiSecret: savedApiSecret
|
|
});
|
|
}
|
|
|
|
// Restore session if available
|
|
if (savedSessionKey && savedUsername) {
|
|
await invoke('lastfm_set_session', { sessionKey: savedSessionKey });
|
|
lastfmConnected = true;
|
|
lastfmUsername = savedUsername;
|
|
}
|
|
|
|
if (savedScrobbling !== null) {
|
|
scrobbling = savedScrobbling === 'true';
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load Last.fm state:', err);
|
|
}
|
|
}
|
|
|
|
async function handleLastfmConnect() {
|
|
// If we don't have credentials (embedded or user-provided), show config
|
|
const hasCredentials = hasEmbeddedCredentials || (lastfmApiKey && lastfmApiSecret);
|
|
if (!hasCredentials) {
|
|
showLastfmConfig = true;
|
|
return;
|
|
}
|
|
|
|
lastfmConnecting = true;
|
|
try {
|
|
// If user provided credentials, save and set them
|
|
if (lastfmApiKey && lastfmApiSecret) {
|
|
localStorage.setItem('qbz-lastfm-api-key', lastfmApiKey);
|
|
localStorage.setItem('qbz-lastfm-api-secret', lastfmApiSecret);
|
|
await invoke('lastfm_set_credentials', {
|
|
apiKey: lastfmApiKey,
|
|
apiSecret: lastfmApiSecret
|
|
});
|
|
}
|
|
|
|
// Get auth URL and token
|
|
const [token, url] = await invoke<[string, string]>('lastfm_get_auth_url');
|
|
lastfmAuthToken = token;
|
|
|
|
// Open browser for authorization using Tauri's native opener
|
|
try {
|
|
await invoke('lastfm_open_auth_url', { url });
|
|
} catch {
|
|
// Fallback to window.open if native opener fails
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
// Show the "I've Authorized" button
|
|
showLastfmConfig = true;
|
|
} catch (err) {
|
|
console.error('Failed to start Last.fm auth:', err);
|
|
alert(`Last.fm error: ${err}`);
|
|
} finally {
|
|
lastfmConnecting = false;
|
|
}
|
|
}
|
|
|
|
async function handleLastfmCompleteAuth() {
|
|
if (!lastfmAuthToken) {
|
|
alert('Please start the authorization first');
|
|
return;
|
|
}
|
|
|
|
lastfmConnecting = true;
|
|
try {
|
|
const session = await invoke<{ name: string; key: string }>('lastfm_authenticate', {
|
|
token: lastfmAuthToken
|
|
});
|
|
|
|
lastfmConnected = true;
|
|
lastfmUsername = session.name;
|
|
showLastfmConfig = false;
|
|
lastfmAuthToken = '';
|
|
|
|
// Save session
|
|
localStorage.setItem('qbz-lastfm-session-key', session.key);
|
|
localStorage.setItem('qbz-lastfm-username', session.name);
|
|
} catch (err) {
|
|
console.error('Failed to complete Last.fm auth:', err);
|
|
alert(`Authorization failed: ${err}`);
|
|
} finally {
|
|
lastfmConnecting = false;
|
|
}
|
|
}
|
|
|
|
async function handleLastfmDisconnect() {
|
|
try {
|
|
await invoke('lastfm_disconnect');
|
|
lastfmConnected = false;
|
|
lastfmUsername = '';
|
|
|
|
// Clear saved session
|
|
localStorage.removeItem('qbz-lastfm-session-key');
|
|
localStorage.removeItem('qbz-lastfm-username');
|
|
} catch (err) {
|
|
console.error('Failed to disconnect Last.fm:', err);
|
|
}
|
|
}
|
|
|
|
function handleScrobblingChange(enabled: boolean) {
|
|
scrobbling = enabled;
|
|
localStorage.setItem('qbz-lastfm-scrobbling', String(enabled));
|
|
}
|
|
|
|
async function loadMusicBrainzState() {
|
|
try {
|
|
musicbrainzEnabled = await invoke<boolean>('musicbrainz_is_enabled');
|
|
} catch (err) {
|
|
console.error('Failed to load MusicBrainz state:', err);
|
|
}
|
|
}
|
|
|
|
async function handleMusicBrainzChange(enabled: boolean) {
|
|
try {
|
|
await invoke('musicbrainz_set_enabled', { enabled });
|
|
musicbrainzEnabled = enabled;
|
|
} catch (err) {
|
|
console.error('Failed to update MusicBrainz setting:', err);
|
|
}
|
|
}
|
|
|
|
// ListenBrainz functions
|
|
async function loadListenBrainzState() {
|
|
try {
|
|
const status = await invoke<{
|
|
connected: boolean;
|
|
userName: string | null;
|
|
enabled: boolean;
|
|
}>('listenbrainz_get_status');
|
|
listenbrainzConnected = status.connected;
|
|
listenbrainzUsername = status.userName || '';
|
|
listenbrainzEnabled = status.enabled;
|
|
} catch (err) {
|
|
console.error('Failed to load ListenBrainz state:', err);
|
|
}
|
|
}
|
|
|
|
async function handleListenBrainzConnect() {
|
|
if (!listenbrainzToken.trim()) {
|
|
showListenBrainzConfig = true;
|
|
return;
|
|
}
|
|
|
|
listenbrainzConnecting = true;
|
|
try {
|
|
const userInfo = await invoke<{ user_name: string }>('listenbrainz_connect', {
|
|
token: listenbrainzToken.trim()
|
|
});
|
|
listenbrainzConnected = true;
|
|
listenbrainzUsername = userInfo.user_name;
|
|
listenbrainzToken = '';
|
|
showListenBrainzConfig = false;
|
|
} catch (err) {
|
|
console.error('Failed to connect to ListenBrainz:', err);
|
|
alert(`ListenBrainz error: ${err}`);
|
|
} finally {
|
|
listenbrainzConnecting = false;
|
|
}
|
|
}
|
|
|
|
async function handleListenBrainzDisconnect() {
|
|
try {
|
|
await invoke('listenbrainz_disconnect');
|
|
listenbrainzConnected = false;
|
|
listenbrainzUsername = '';
|
|
} catch (err) {
|
|
console.error('Failed to disconnect ListenBrainz:', err);
|
|
}
|
|
}
|
|
|
|
async function handleListenBrainzEnabledChange(enabled: boolean) {
|
|
try {
|
|
await invoke('listenbrainz_set_enabled', { enabled });
|
|
listenbrainzEnabled = enabled;
|
|
} catch (err) {
|
|
console.error('Failed to update ListenBrainz setting:', err);
|
|
}
|
|
}
|
|
|
|
async function handleShowDownloadsChange(enabled: boolean) {
|
|
try {
|
|
await invoke('set_show_downloads_in_library', { show: enabled });
|
|
showQobuzDownloadsInLibrary = enabled;
|
|
// Notify LocalLibraryView to refresh
|
|
notifyDownloadSettingsChanged();
|
|
} catch (e) {
|
|
console.error('Failed to update show downloads setting:', e);
|
|
}
|
|
}
|
|
|
|
async function handleQualityChange(quality: string) {
|
|
const previousQuality = streamingQuality;
|
|
streamingQuality = quality;
|
|
localStorage.setItem('qbz-streaming-quality', quality);
|
|
|
|
// Clear playback cache when quality changes (issue #34)
|
|
// This ensures cached tracks don't play at wrong quality
|
|
// Important for users with hardware sample rate limitations
|
|
if (previousQuality !== quality) {
|
|
try {
|
|
await invoke('clear_cache');
|
|
await loadCacheStats();
|
|
showToast($t('settings.audio.qualityChangedCacheCleared'), 'success');
|
|
} catch (err) {
|
|
console.error('Failed to clear cache after quality change:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Offline mode handlers
|
|
async function handleManualOfflineChange(enabled: boolean) {
|
|
// If enabling offline mode, just do it directly
|
|
if (enabled) {
|
|
try {
|
|
await setManualOffline(true);
|
|
} catch (error) {
|
|
console.error('Failed to enable manual offline mode:', error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// If disabling offline mode, verify network connectivity first
|
|
isCheckingNetwork = true;
|
|
try {
|
|
const hasNetwork = await checkNetwork();
|
|
if (hasNetwork) {
|
|
await setManualOffline(false);
|
|
} else {
|
|
showToast($t('offline.noNetworkToast'), 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to disable manual offline mode:', error);
|
|
showToast($t('offline.noNetworkToast'), 'error');
|
|
} finally {
|
|
isCheckingNetwork = false;
|
|
}
|
|
}
|
|
|
|
async function handleShowPartialPlaylistsChange(enabled: boolean) {
|
|
try {
|
|
await setShowPartialPlaylists(enabled);
|
|
} catch (error) {
|
|
console.error('Failed to set show partial playlists:', error);
|
|
}
|
|
}
|
|
|
|
async function handleAllowCastChange(enabled: boolean) {
|
|
try {
|
|
await setAllowCastWhileOffline(enabled);
|
|
} catch (error) {
|
|
console.error('Failed to set allow cast while offline:', error);
|
|
}
|
|
}
|
|
|
|
async function handleShowNetworkFoldersChange(enabled: boolean) {
|
|
try {
|
|
await setShowNetworkFoldersInManualOffline(enabled);
|
|
} catch (error) {
|
|
console.error('Failed to set show network folders in manual offline:', error);
|
|
}
|
|
}
|
|
|
|
async function handleAllowImmediateScrobblingChange(enabled: boolean) {
|
|
try {
|
|
await setAllowImmediateScrobbling(enabled);
|
|
// Mutually exclusive: if turning on, turn off the other
|
|
if (enabled && offlineSettings.allowAccumulatedScrobbling) {
|
|
await setAllowAccumulatedScrobbling(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to set allow immediate scrobbling:', error);
|
|
}
|
|
}
|
|
|
|
async function handleAllowAccumulatedScrobblingChange(enabled: boolean) {
|
|
try {
|
|
await setAllowAccumulatedScrobbling(enabled);
|
|
// Mutually exclusive: if turning on, turn off the other
|
|
if (enabled && offlineSettings.allowImmediateScrobbling) {
|
|
await setAllowImmediateScrobbling(false);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to set allow accumulated scrobbling:', error);
|
|
}
|
|
}
|
|
|
|
async function handleLanguageChange(lang: string) {
|
|
language = lang;
|
|
const localeCode = languageToLocale[lang];
|
|
if (localeCode) {
|
|
// Set specific locale
|
|
await setLocale(localeCode);
|
|
// Clear artist cache to force refetch in new language
|
|
try {
|
|
await invoke('clear_artist_cache');
|
|
console.log('Artist cache cleared after language change');
|
|
} catch (error) {
|
|
console.error('Failed to clear artist cache:', error);
|
|
}
|
|
} else {
|
|
// 'Auto' - use browser locale, defaulting to 'en'
|
|
const browserLocale = navigator.language.split('-')[0];
|
|
const supportedLocale = ['en', 'es'].includes(browserLocale) ? browserLocale : 'en';
|
|
await setLocale(supportedLocale);
|
|
// Clear the stored locale so it uses browser detection on next load
|
|
localStorage.removeItem('qbz-locale');
|
|
// Also clear artist cache
|
|
try {
|
|
await invoke('clear_artist_cache');
|
|
console.log('Artist cache cleared after language change');
|
|
} catch (error) {
|
|
console.error('Failed to clear artist cache:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
interface AudioSettings {
|
|
output_device: string | null;
|
|
exclusive_mode: boolean;
|
|
dac_passthrough: boolean;
|
|
preferred_sample_rate: number | null;
|
|
backend_type: 'PipeWire' | 'Alsa' | 'Pulse' | null;
|
|
alsa_plugin: 'Hw' | 'PlugHw' | 'Pcm' | null;
|
|
alsa_hardware_volume: boolean;
|
|
stream_first_track: boolean;
|
|
stream_buffer_seconds: number;
|
|
streaming_only: boolean;
|
|
limit_quality_to_device: boolean;
|
|
device_max_sample_rate: number | null;
|
|
}
|
|
|
|
interface BackendInfo {
|
|
backend_type: 'PipeWire' | 'Alsa' | 'Pulse';
|
|
name: string;
|
|
description: string;
|
|
is_available: boolean;
|
|
}
|
|
|
|
interface AudioDevice {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
is_default: boolean;
|
|
max_sample_rate: number | null;
|
|
supported_sample_rates: number[] | null;
|
|
device_bus: string | null; // "usb", "pci", "bluetooth", etc.
|
|
is_hardware: boolean;
|
|
}
|
|
|
|
interface AlsaPluginInfo {
|
|
plugin: 'Hw' | 'PlugHw' | 'Pcm';
|
|
name: string;
|
|
description: string;
|
|
}
|
|
|
|
// Helper to get the current selected device sink name (or null for system default)
|
|
function getCurrentDeviceSinkName(): string | null {
|
|
if (outputDevice === 'System Default') {
|
|
return null;
|
|
}
|
|
return sinkDescriptionToName.get(outputDevice) ?? null;
|
|
}
|
|
|
|
async function loadAudioDevices() {
|
|
try {
|
|
// Load PipeWire sinks - these have friendly descriptions already
|
|
const sinks = await invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[]);
|
|
pipewireSinks = sinks;
|
|
|
|
// Load hardware audio status
|
|
const hwStatus = await invoke<HardwareAudioStatus>('get_hardware_audio_status').catch(() => null);
|
|
hardwareStatus = hwStatus;
|
|
|
|
console.log('[Audio] PipeWire sinks:', sinks.map(s => ({ name: s.name, desc: s.description })));
|
|
console.log('[Audio] Hardware status:', hwStatus);
|
|
} catch (err) {
|
|
console.error('Failed to load audio devices:', err);
|
|
}
|
|
}
|
|
|
|
async function loadBackends() {
|
|
try {
|
|
const backends = await invoke<BackendInfo[]>('get_available_backends');
|
|
availableBackends = backends;
|
|
console.log('[Audio] Available backends:', backends);
|
|
} catch (err) {
|
|
console.error('Failed to load backends:', err);
|
|
}
|
|
}
|
|
|
|
async function loadAlsaPlugins() {
|
|
try {
|
|
const plugins = await invoke<AlsaPluginInfo[]>('get_alsa_plugins');
|
|
alsaPlugins = plugins;
|
|
console.log('[Audio] ALSA plugins:', plugins);
|
|
} catch (err) {
|
|
console.error('Failed to load ALSA plugins:', err);
|
|
}
|
|
}
|
|
|
|
async function loadBackendDevices(backendType: 'PipeWire' | 'Alsa' | 'Pulse') {
|
|
isLoadingDevices = true;
|
|
try {
|
|
const devices = await invoke<AudioDevice[]>('get_devices_for_backend', { backendType });
|
|
backendDevices = devices;
|
|
console.log(`[Audio] Devices for ${backendType}:`, devices);
|
|
} catch (err) {
|
|
console.error(`Failed to load devices for ${backendType}:`, err);
|
|
backendDevices = [];
|
|
} finally {
|
|
isLoadingDevices = false;
|
|
}
|
|
}
|
|
|
|
async function loadFlatpakStatus() {
|
|
try {
|
|
isFlatpak = await invoke<boolean>('is_running_in_flatpak');
|
|
if (isFlatpak) {
|
|
flatpakHelpText = await invoke<string>('get_flatpak_help_text');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to check Flatpak status:', err);
|
|
}
|
|
}
|
|
|
|
async function loadAudioSettings() {
|
|
try {
|
|
const settings = await invoke<AudioSettings>('get_audio_settings');
|
|
// Convert stored device name to description for display
|
|
if (settings.output_device) {
|
|
// Look up the friendly description from the device name
|
|
const description = sinkNameToDescription.get(settings.output_device);
|
|
outputDevice = description ?? settings.output_device;
|
|
} else {
|
|
outputDevice = 'System Default';
|
|
}
|
|
exclusiveMode = settings.exclusive_mode;
|
|
dacPassthrough = settings.dac_passthrough;
|
|
|
|
// Load backend and plugin settings
|
|
if (settings.backend_type) {
|
|
const backend = availableBackends.find(b => b.backend_type === settings.backend_type);
|
|
const backendName = backend?.name ?? 'Auto';
|
|
|
|
// TEST: Allow ALSA Direct to load for testing
|
|
selectedBackend = backendName;
|
|
// Load devices for selected backend
|
|
await loadBackendDevices(settings.backend_type);
|
|
|
|
// Set selected device from backend devices
|
|
if (settings.output_device) {
|
|
const device = backendDevices.find(d => d.id === settings.output_device);
|
|
if (device) {
|
|
// Use description from aplay -L if available (ALSA), otherwise translate
|
|
outputDevice = (device.description && settings.backend_type === 'Alsa')
|
|
? device.description
|
|
: (needsTranslation(device.name) ? getDevicePrettyName(device.name) : device.name);
|
|
} else {
|
|
outputDevice = 'System Default';
|
|
}
|
|
}
|
|
} else {
|
|
selectedBackend = 'Auto';
|
|
// Auto mode: always use System Default (no device selection)
|
|
// Device names from one backend (e.g., PipeWire) don't work in another (e.g., ALSA)
|
|
backendDevices = [];
|
|
outputDevice = 'System Default';
|
|
}
|
|
|
|
if (settings.alsa_plugin) {
|
|
const plugin = alsaPlugins.find(p => p.plugin === settings.alsa_plugin);
|
|
selectedAlsaPlugin = plugin?.name ?? 'hw (Direct Hardware)';
|
|
} else {
|
|
selectedAlsaPlugin = 'hw (Direct Hardware)';
|
|
}
|
|
|
|
alsaHardwareVolume = settings.alsa_hardware_volume ?? false;
|
|
streamFirstTrack = settings.stream_first_track ?? false;
|
|
streamBufferSeconds = settings.stream_buffer_seconds ?? 3;
|
|
streamingOnly = settings.streaming_only ?? false;
|
|
limitQualityToDevice = settings.limit_quality_to_device ?? true;
|
|
|
|
// Validate mutual exclusion: DAC Passthrough disables Gapless + Crossfade
|
|
if (dacPassthrough) {
|
|
if (gaplessPlayback) {
|
|
console.warn('DAC Passthrough and Gapless both enabled - disabling Gapless');
|
|
gaplessPlayback = false;
|
|
}
|
|
if (crossfade > 0) {
|
|
console.warn('DAC Passthrough and Crossfade both enabled - disabling Crossfade');
|
|
crossfade = 0;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load audio settings:', err);
|
|
}
|
|
}
|
|
|
|
async function handleOutputDeviceChange(description: string) {
|
|
outputDevice = description;
|
|
|
|
// Convert description back to device name for storage
|
|
const deviceName = sinkDescriptionToName.get(description);
|
|
const deviceToStore = description === 'System Default' ? null : deviceName;
|
|
|
|
// Try to find max sample rate from backendDevices if available
|
|
// This enables quality limiting for PipeWire mode when possible
|
|
const matchingDevice = backendDevices.find(d =>
|
|
d.name === deviceName || d.description === description
|
|
);
|
|
const maxSampleRate = matchingDevice?.max_sample_rate ?? null;
|
|
|
|
try {
|
|
// Save the preference
|
|
await invoke('set_audio_output_device', { device: deviceToStore });
|
|
// Store device's max sample rate for quality limiting
|
|
await invoke('set_audio_device_max_sample_rate', { rate: maxSampleRate });
|
|
|
|
// Reinitialize audio with the selected device
|
|
// CRITICAL: Pass the actual CPAL device name, not null
|
|
// CPAL can now find this device because we're using CPAL names
|
|
await invoke('reinit_audio_device', { device: deviceToStore });
|
|
|
|
console.log('[Audio] Output device changed:', description, '(device:', deviceName ?? 'default', ', max_rate:', maxSampleRate ?? 'unknown', ')');
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change audio output device:', err);
|
|
}
|
|
}
|
|
|
|
async function handleExclusiveModeChange(enabled: boolean) {
|
|
exclusiveMode = enabled;
|
|
try {
|
|
await invoke('set_audio_exclusive_mode', { enabled });
|
|
|
|
// Reinitialize audio with currently selected device
|
|
const deviceName = getCurrentDeviceSinkName();
|
|
await invoke('reinit_audio_device', { device: deviceName });
|
|
console.log('[Audio] Exclusive mode changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change exclusive mode:', err);
|
|
}
|
|
}
|
|
|
|
async function handleDacPassthroughChange(enabled: boolean) {
|
|
dacPassthrough = enabled;
|
|
|
|
// Auto-disable incompatible playback settings
|
|
if (enabled) {
|
|
gaplessPlayback = false;
|
|
crossfade = 0;
|
|
console.log('[Audio] DAC passthrough enabled: disabled gapless playback and crossfade');
|
|
}
|
|
|
|
try {
|
|
await invoke('set_audio_dac_passthrough', { enabled });
|
|
|
|
// Reinitialize audio with currently selected device
|
|
const deviceName = getCurrentDeviceSinkName();
|
|
await invoke('reinit_audio_device', { device: deviceName });
|
|
console.log('[Audio] DAC passthrough changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change DAC passthrough:', err);
|
|
}
|
|
}
|
|
|
|
async function handleBackendChange(backendName: string) {
|
|
selectedBackend = backendName;
|
|
|
|
// Map UI name to backend type
|
|
const backend = availableBackends.find(b => b.name === backendName);
|
|
const backendType = backendName === 'Auto' ? null : backend?.backend_type ?? null;
|
|
|
|
// Auto-disable incompatible features
|
|
// DAC Passthrough only works with PipeWire
|
|
if (backendName !== 'PipeWire') {
|
|
if (dacPassthrough) {
|
|
dacPassthrough = false;
|
|
await invoke('set_audio_dac_passthrough', { enabled: false });
|
|
console.log('[Audio] Disabled DAC Passthrough (only compatible with PipeWire)');
|
|
}
|
|
}
|
|
|
|
// Exclusive mode only works with ALSA Direct
|
|
if (backendName !== 'ALSA Direct') {
|
|
if (exclusiveMode) {
|
|
exclusiveMode = false;
|
|
await invoke('set_audio_exclusive_mode', { enabled: false });
|
|
console.log('[Audio] Disabled exclusive mode (only compatible with ALSA Direct)');
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Save backend preference
|
|
await invoke('set_audio_backend_type', { backendType });
|
|
console.log('[Audio] Backend changed:', backendName, '(type:', backendType ?? 'auto', ')');
|
|
|
|
// Load devices for new backend
|
|
if (backendType) {
|
|
await loadBackendDevices(backendType);
|
|
} else {
|
|
// Auto mode: no device selection (System Default only)
|
|
// Device IDs from different backends are incompatible
|
|
backendDevices = [];
|
|
}
|
|
|
|
// Reset to default device when switching backends (always)
|
|
outputDevice = 'System Default';
|
|
await invoke('set_audio_output_device', { device: null });
|
|
|
|
// Reinitialize audio - this will recreate the stream completely
|
|
await invoke('reinit_audio_device', { device: null });
|
|
|
|
// Stop current playback to prevent stuck/dead streams
|
|
try {
|
|
await invoke('stop_playback');
|
|
console.log('[Audio] Stopped playback after backend change');
|
|
} catch (err) {
|
|
// Ignore error if nothing was playing
|
|
console.log('[Audio] No playback to stop');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change backend:', err);
|
|
}
|
|
}
|
|
|
|
async function handleAlsaPluginChange(pluginName: string) {
|
|
selectedAlsaPlugin = pluginName;
|
|
|
|
// Map UI name to plugin type
|
|
const pluginInfo = alsaPlugins.find(p => p.name === pluginName);
|
|
const plugin = pluginInfo?.plugin ?? null;
|
|
|
|
try {
|
|
await invoke('set_audio_alsa_plugin', { plugin });
|
|
console.log('[Audio] ALSA plugin changed:', pluginName, '(type:', plugin ?? 'none', ')');
|
|
|
|
// Reinitialize audio if ALSA backend is active
|
|
if (selectedBackend === 'ALSA Direct') {
|
|
const deviceName = getCurrentDeviceSinkName();
|
|
await invoke('reinit_audio_device', { device: deviceName });
|
|
}
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change ALSA plugin:', err);
|
|
}
|
|
}
|
|
|
|
async function handleAlsaHardwareVolumeChange(enabled: boolean) {
|
|
alsaHardwareVolume = enabled;
|
|
try {
|
|
await invoke('set_audio_alsa_hardware_volume', { enabled });
|
|
console.log('[Audio] ALSA hardware volume changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change ALSA hardware volume:', err);
|
|
}
|
|
}
|
|
|
|
async function handleStreamFirstTrackChange(enabled: boolean) {
|
|
streamFirstTrack = enabled;
|
|
try {
|
|
await invoke('set_audio_stream_first_track', { enabled });
|
|
console.log('[Audio] Stream first track changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change stream first track:', err);
|
|
}
|
|
}
|
|
|
|
async function handleStreamBufferSecondsChange(seconds: number) {
|
|
// Clamp to valid range
|
|
const clamped = Math.max(1, Math.min(10, Math.round(seconds)));
|
|
streamBufferSeconds = clamped;
|
|
try {
|
|
await invoke('set_audio_stream_buffer_seconds', { seconds: clamped });
|
|
console.log('[Audio] Stream buffer seconds changed:', clamped);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change stream buffer seconds:', err);
|
|
}
|
|
}
|
|
|
|
async function handleStreamingOnlyChange(enabled: boolean) {
|
|
streamingOnly = enabled;
|
|
try {
|
|
await invoke('set_audio_streaming_only', { enabled });
|
|
console.log('[Audio] Streaming-only mode changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change streaming-only mode:', err);
|
|
}
|
|
}
|
|
|
|
async function handleLimitQualityToDeviceChange(enabled: boolean) {
|
|
limitQualityToDevice = enabled;
|
|
try {
|
|
await invoke('set_audio_limit_quality_to_device', { enabled });
|
|
console.log('[Audio] Limit quality to device changed:', enabled);
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change limit quality to device:', err);
|
|
}
|
|
}
|
|
|
|
async function handleBackendDeviceChange(deviceName: string) {
|
|
outputDevice = deviceName;
|
|
|
|
// Get device ID from backendDevices using display name mapping
|
|
const device = deviceByDisplayName.get(deviceName);
|
|
const deviceId = deviceName === 'System Default' ? null : device?.id ?? null;
|
|
const maxSampleRate = device?.max_sample_rate ?? null;
|
|
|
|
try {
|
|
await invoke('set_audio_output_device', { device: deviceId });
|
|
// Store device's max sample rate for quality limiting
|
|
await invoke('set_audio_device_max_sample_rate', { rate: maxSampleRate });
|
|
await invoke('reinit_audio_device', { device: deviceId });
|
|
console.log('[Audio] Backend device changed:', deviceName, '(id:', deviceId ?? 'default', ', max_rate:', maxSampleRate ?? 'unknown', ')');
|
|
|
|
// Stop current playback to prevent stuck/dead streams
|
|
try {
|
|
await invoke('stop_playback');
|
|
console.log('[Audio] Stopped playback after device change');
|
|
} catch (err) {
|
|
console.log('[Audio] No playback to stop');
|
|
}
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to change backend device:', err);
|
|
}
|
|
}
|
|
|
|
async function handleGaplessPlaybackChange(enabled: boolean) {
|
|
gaplessPlayback = enabled;
|
|
|
|
// Auto-disable DAC passthrough if incompatible
|
|
if (enabled && dacPassthrough) {
|
|
dacPassthrough = false;
|
|
console.log('[Audio] Gapless playback enabled: disabled DAC passthrough');
|
|
try {
|
|
await invoke('set_audio_dac_passthrough', { enabled: false });
|
|
|
|
// Reinitialize audio with currently selected device
|
|
const deviceName = getCurrentDeviceSinkName();
|
|
await invoke('reinit_audio_device', { device: deviceName });
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to disable DAC passthrough:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleCrossfadeChange(value: number) {
|
|
crossfade = value;
|
|
|
|
// Auto-disable DAC passthrough if crossfade > 0
|
|
if (value > 0 && dacPassthrough) {
|
|
dacPassthrough = false;
|
|
console.log('[Audio] Crossfade enabled: disabled DAC passthrough');
|
|
try {
|
|
await invoke('set_audio_dac_passthrough', { enabled: false });
|
|
|
|
// Reinitialize audio with currently selected device
|
|
const deviceName = getCurrentDeviceSinkName();
|
|
await invoke('reinit_audio_device', { device: deviceName });
|
|
} catch (err) {
|
|
console.error('[Audio] Failed to disable DAC passthrough:', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadCacheStats() {
|
|
try {
|
|
cacheStats = await invoke<CacheStats>('get_cache_stats');
|
|
} catch (err) {
|
|
console.error('Failed to load cache stats:', err);
|
|
}
|
|
}
|
|
|
|
async function loadLyricsCacheStats() {
|
|
try {
|
|
const stats = await invoke<{ entries: number; sizeBytes: number }>('lyrics_get_cache_stats');
|
|
lyricsCacheStats = stats;
|
|
} catch (err) {
|
|
console.error('Failed to load lyrics cache stats:', err);
|
|
lyricsCacheStats = null;
|
|
}
|
|
}
|
|
|
|
async function loadDownloadStats() {
|
|
try {
|
|
downloadStats = await getOfflineCacheStats();
|
|
} catch (err) {
|
|
console.error('Failed to load download stats:', err);
|
|
}
|
|
}
|
|
|
|
async function loadDownloadSettings() {
|
|
try {
|
|
const settings = await invoke<{download_root: string, show_in_library: boolean}>('get_download_settings');
|
|
showQobuzDownloadsInLibrary = settings.show_in_library;
|
|
} catch (err) {
|
|
console.error('Failed to load download settings:', err);
|
|
}
|
|
}
|
|
|
|
async function loadPlaybackPreferences() {
|
|
console.log('[Settings] Loading playback preferences...');
|
|
try {
|
|
const prefs = await getPlaybackPreferences();
|
|
console.log('[Settings] Loaded preferences:', prefs);
|
|
autoplayMode = prefs.autoplay_mode;
|
|
showContextIcon = prefs.show_context_icon;
|
|
console.log('[Settings] Set autoplayMode to:', autoplayMode);
|
|
console.log('[Settings] Set showContextIcon to:', showContextIcon);
|
|
} catch (err) {
|
|
console.error('Failed to load playback preferences:', err);
|
|
}
|
|
}
|
|
|
|
interface TraySettings {
|
|
enable_tray: boolean;
|
|
minimize_to_tray: boolean;
|
|
close_to_tray: boolean;
|
|
}
|
|
|
|
async function loadTraySettings() {
|
|
try {
|
|
const settings = await invoke<TraySettings>('get_tray_settings');
|
|
enableTray = settings.enable_tray;
|
|
minimizeToTray = settings.minimize_to_tray;
|
|
closeToTray = settings.close_to_tray;
|
|
} catch (err) {
|
|
console.error('Failed to load tray settings:', err);
|
|
}
|
|
}
|
|
|
|
async function handleEnableTrayChange(value: boolean) {
|
|
try {
|
|
await invoke('set_enable_tray', { value });
|
|
enableTray = value;
|
|
showToast($t('settings.appearance.tray.enableTrayDesc'), 'info');
|
|
} catch (err) {
|
|
console.error('Failed to set enable tray:', err);
|
|
showToast($t('toast.failedSaveTray'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleMinimizeToTrayChange(value: boolean) {
|
|
try {
|
|
await invoke('set_minimize_to_tray', { value });
|
|
minimizeToTray = value;
|
|
} catch (err) {
|
|
console.error('Failed to set minimize to tray:', err);
|
|
showToast($t('toast.failedSaveTray'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleCloseToTrayChange(value: boolean) {
|
|
try {
|
|
await invoke('set_close_to_tray', { value });
|
|
closeToTray = value;
|
|
} catch (err) {
|
|
console.error('Failed to set close to tray:', err);
|
|
showToast($t('toast.failedSaveTray'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleAutoplayModeChange(mode: AutoplayMode) {
|
|
console.log('[Settings] Changing autoplay mode to:', mode);
|
|
try {
|
|
await setAutoplayMode(mode);
|
|
autoplayMode = mode;
|
|
console.log('[Settings] Autoplay mode saved successfully');
|
|
} catch (err) {
|
|
console.error('[Settings] Failed to set autoplay mode:', err);
|
|
showToast($t('toast.failedSaveAutoplay'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleShowContextIconChange(show: boolean) {
|
|
console.log('[Settings] Changing show context icon to:', show);
|
|
try {
|
|
await setShowContextIcon(show);
|
|
showContextIcon = show;
|
|
console.log('[Settings] Show context icon saved successfully');
|
|
} catch (err) {
|
|
console.error('[Settings] Failed to set show context icon:', err);
|
|
showToast($t('toast.failedSaveIconVisibility'), 'error');
|
|
}
|
|
}
|
|
|
|
async function checkLegacyCachedFiles() {
|
|
try {
|
|
const result = await invoke<{has_legacy_files: boolean, total_tracks: number}>('detect_legacy_cached_files');
|
|
if (result.has_legacy_files && result.total_tracks > 0) {
|
|
legacyTracksCount = result.total_tracks;
|
|
showMigrationModal = true;
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to check for legacy cached files:', err);
|
|
}
|
|
}
|
|
|
|
function closeMigrationModal() {
|
|
showMigrationModal = false;
|
|
// Refresh stats after migration
|
|
loadDownloadStats();
|
|
}
|
|
|
|
async function handleRepairDownloads() {
|
|
if (isRepairingDownloads) return;
|
|
isRepairingDownloads = true;
|
|
try {
|
|
const report = await invoke<{
|
|
total_downloads: number;
|
|
added_tracks: number;
|
|
repaired_tracks: number;
|
|
skipped_tracks: number;
|
|
failed_tracks: string[];
|
|
}>('library_backfill_downloads');
|
|
|
|
const message = `Repair complete!\n\nAdded: ${report.added_tracks}\nRepaired: ${report.repaired_tracks}\nSkipped: ${report.skipped_tracks}\nFailed: ${report.failed_tracks.length}`;
|
|
|
|
showToast(message, 'success');
|
|
|
|
// Trigger library reload
|
|
notifyDownloadSettingsChanged();
|
|
} catch (err) {
|
|
console.error('Failed to repair downloads:', err);
|
|
showToast($t('toast.failedRepairOffline', { values: { error: String(err) } }), 'error');
|
|
} finally {
|
|
isRepairingDownloads = false;
|
|
}
|
|
}
|
|
|
|
async function handleClearDownloads() {
|
|
if (isClearingDownloads) return;
|
|
isClearingDownloads = true;
|
|
try {
|
|
await clearOfflineCache();
|
|
await loadDownloadStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear download cache:', err);
|
|
} finally {
|
|
isClearingDownloads = false;
|
|
}
|
|
}
|
|
|
|
async function handleOpenCacheFolder() {
|
|
try {
|
|
await invoke('open_offline_cache_folder');
|
|
} catch (err) {
|
|
console.error('Failed to open cache folder:', err);
|
|
showToast($t('toast.failedOpenCacheFolder'), 'error');
|
|
}
|
|
}
|
|
|
|
async function handleClearCache() {
|
|
if (isClearing) return;
|
|
isClearing = true;
|
|
try {
|
|
await invoke('clear_cache');
|
|
await loadCacheStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear cache:', err);
|
|
} finally {
|
|
isClearing = false;
|
|
}
|
|
}
|
|
|
|
async function handleClearLyricsCache() {
|
|
if (isClearingLyrics) return;
|
|
isClearingLyrics = true;
|
|
try {
|
|
await clearLyricsCache();
|
|
console.log('Lyrics cache cleared');
|
|
await loadLyricsCacheStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear lyrics cache:', err);
|
|
} finally {
|
|
isClearingLyrics = false;
|
|
}
|
|
}
|
|
|
|
async function handleClearMusicBrainzCache() {
|
|
if (isClearingMusicBrainz) return;
|
|
isClearingMusicBrainz = true;
|
|
try {
|
|
await invoke('musicbrainz_clear_cache');
|
|
console.log('MusicBrainz cache cleared');
|
|
await loadMusicBrainzCacheStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear MusicBrainz cache:', err);
|
|
} finally {
|
|
isClearingMusicBrainz = false;
|
|
}
|
|
}
|
|
|
|
async function loadMusicBrainzCacheStats() {
|
|
try {
|
|
musicBrainzCacheStats = await invoke('musicbrainz_get_cache_stats');
|
|
} catch (err) {
|
|
console.error('Failed to load MusicBrainz cache stats:', err);
|
|
musicBrainzCacheStats = null;
|
|
}
|
|
}
|
|
|
|
async function loadVectorStoreStats() {
|
|
try {
|
|
vectorStoreStats = await invoke('get_vector_store_stats');
|
|
} catch (err) {
|
|
console.error('Failed to load vector store stats:', err);
|
|
vectorStoreStats = null;
|
|
}
|
|
}
|
|
|
|
async function handleClearVectorStore() {
|
|
if (isClearingVectorStore) return;
|
|
isClearingVectorStore = true;
|
|
try {
|
|
await invoke('clear_vector_store');
|
|
console.log('Artist vector store cleared');
|
|
await loadVectorStoreStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear vector store:', err);
|
|
} finally {
|
|
isClearingVectorStore = false;
|
|
}
|
|
}
|
|
|
|
async function loadArtworkCacheStats() {
|
|
try {
|
|
artworkCacheStats = await invoke('library_get_cache_stats');
|
|
} catch (err) {
|
|
console.error('Failed to load artwork cache stats:', err);
|
|
artworkCacheStats = null;
|
|
}
|
|
}
|
|
|
|
async function handleClearArtworkCache() {
|
|
if (isClearingArtwork) return;
|
|
isClearingArtwork = true;
|
|
try {
|
|
// Clear both legacy artwork cache and new thumbnails cache
|
|
await invoke('library_clear_artwork_cache');
|
|
await invoke('library_clear_thumbnails_cache');
|
|
console.log('Artwork caches cleared');
|
|
await loadArtworkCacheStats();
|
|
} catch (err) {
|
|
console.error('Failed to clear artwork cache:', err);
|
|
} finally {
|
|
isClearingArtwork = false;
|
|
}
|
|
}
|
|
|
|
async function handleClearAllCaches() {
|
|
if (isClearingAllCaches) return;
|
|
isClearingAllCaches = true;
|
|
try {
|
|
// Clear all caches in parallel
|
|
await Promise.all([
|
|
invoke('clear_cache'),
|
|
clearLyricsCache(),
|
|
invoke('musicbrainz_clear_cache'),
|
|
invoke('clear_vector_store'),
|
|
invoke('library_clear_artwork_cache'),
|
|
invoke('library_clear_thumbnails_cache')
|
|
]);
|
|
console.log('All caches cleared');
|
|
// Reload all stats
|
|
await Promise.all([
|
|
loadCacheStats(),
|
|
loadLyricsCacheStats(),
|
|
loadMusicBrainzCacheStats(),
|
|
loadVectorStoreStats(),
|
|
loadArtworkCacheStats()
|
|
]);
|
|
} catch (err) {
|
|
console.error('Failed to clear all caches:', err);
|
|
} finally {
|
|
isClearingAllCaches = false;
|
|
}
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
|
}
|
|
|
|
function applyTheme(themeValue: string) {
|
|
if (themeValue) {
|
|
document.documentElement.setAttribute('data-theme', themeValue);
|
|
} else {
|
|
document.documentElement.removeAttribute('data-theme');
|
|
}
|
|
}
|
|
|
|
function handleThemeChange(newTheme: string) {
|
|
theme = newTheme;
|
|
const themeValue = themeMap[newTheme] || '';
|
|
applyTheme(themeValue);
|
|
localStorage.setItem('qbz-theme', themeValue);
|
|
}
|
|
|
|
async function handleZoomChange(value: string) {
|
|
zoomLevel = value;
|
|
const zoom = setZoom(getZoomLevelFromOption(value));
|
|
try {
|
|
await getCurrentWebview().setZoom(zoom);
|
|
} catch (err) {
|
|
console.warn('Failed to set zoom:', err);
|
|
}
|
|
}
|
|
|
|
// Flatpak copyable command state
|
|
let copiedCommands = $state<Record<string, boolean>>({});
|
|
|
|
async function copyCommand(key: string, command: string) {
|
|
try {
|
|
await copyToClipboard(command);
|
|
copiedCommands[key] = true;
|
|
setTimeout(() => { copiedCommands[key] = false; }, 1200);
|
|
} catch {
|
|
try {
|
|
await navigator.clipboard.writeText(command);
|
|
copiedCommands[key] = true;
|
|
setTimeout(() => { copiedCommands[key] = false; }, 1200);
|
|
} catch {}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<ViewTransition duration={200} distance={12} direction="up">
|
|
<div class="settings-view" bind:this={settingsViewEl}>
|
|
<!-- Loading Overlay for Device Enumeration -->
|
|
{#if isLoadingDevices}
|
|
<div class="loading-overlay">
|
|
<div class="loading-content">
|
|
<Loader2 size={48} class="spinner" />
|
|
<p>Loading audio devices...</p>
|
|
<p class="loading-subtitle">Parsing hardware information</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Header -->
|
|
<div class="header">
|
|
{#if onBack}
|
|
<button class="back-btn" onclick={onBack}>
|
|
<ArrowLeft size={16} />
|
|
<span>{$t('actions.back')}</span>
|
|
</button>
|
|
{/if}
|
|
<h1 class="title">{$t('settings.title')}</h1>
|
|
</div>
|
|
|
|
<!-- Account Section (compact) -->
|
|
<section class="section account-section">
|
|
<div class="account-card-compact">
|
|
<div class="avatar-small">{userName.charAt(0).toUpperCase()}</div>
|
|
<div class="account-info-compact">
|
|
<span class="username-compact">{userName}</span>
|
|
<span class="separator">·</span>
|
|
<span class="subscription-text">{subscription}</span>
|
|
</div>
|
|
<button class="logout-btn-compact" onclick={onLogout}>{$t('settings.account.logout')}</button>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Settings Navigation -->
|
|
<nav class="settings-nav">
|
|
{#each navSectionDefs as section}
|
|
<button
|
|
class="nav-link"
|
|
class:active={activeSection === section.id}
|
|
onclick={() => scrollToSection(section.id)}
|
|
>
|
|
{$t(section.labelKey)}
|
|
</button>
|
|
{/each}
|
|
</nav>
|
|
|
|
<!-- Audio Section -->
|
|
<section class="section" bind:this={audioSection}>
|
|
<h3 class="section-title">{$t('settings.audio.title')}</h3>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.audio.streamingQuality')}</span>
|
|
<span class="setting-desc">{$t('settings.audio.streamingQualityDesc')}</span>
|
|
</div>
|
|
<Dropdown
|
|
value={streamingQuality}
|
|
options={['MP3', 'CD Quality', 'Hi-Res', 'Hi-Res+']}
|
|
onchange={handleQualityChange}
|
|
/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Limit Quality to Device</span>
|
|
<span class="setting-desc">When enabled, overrides the quality setting above if your audio device doesn't support the selected sample rate. Prevents resampling to guarantee bit-perfect playback.</span>
|
|
</div>
|
|
<Toggle enabled={limitQualityToDevice} onchange={handleLimitQualityToDeviceChange} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Audio Backend</span>
|
|
<span class="setting-desc">Choose audio system: Auto (recommended), PipeWire (modern), ALSA Direct (bit-perfect, exclusive), or PulseAudio (legacy).</span>
|
|
</div>
|
|
<Dropdown
|
|
value={selectedBackend}
|
|
options={backendOptions}
|
|
onchange={handleBackendChange}
|
|
wide
|
|
expandLeft
|
|
compact
|
|
/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.audio.outputDevice')}</span>
|
|
<span class="setting-desc">Select your preferred audio output device. Devices shown are from the selected backend.</span>
|
|
</div>
|
|
{#if isLoadingDevices}
|
|
<span class="loading-text">Loading devices...</span>
|
|
{:else if selectedBackend === 'ALSA Direct'}
|
|
<div class="dropdown-with-help">
|
|
<DeviceDropdown
|
|
value={outputDevice}
|
|
devices={groupedDeviceOptions}
|
|
onchange={handleBackendDeviceChange}
|
|
backend="alsa"
|
|
wide
|
|
expandLeft
|
|
/>
|
|
<button
|
|
class="help-icon-btn"
|
|
onclick={() => showAlsaUtilsHelpModal = true}
|
|
title="Help with bit-perfect device detection"
|
|
>
|
|
<HelpCircle size={16} />
|
|
</button>
|
|
</div>
|
|
{:else if selectedBackend === 'PipeWire'}
|
|
<DeviceDropdown
|
|
value={outputDevice}
|
|
devices={groupedDeviceOptions}
|
|
onchange={handleBackendDeviceChange}
|
|
backend="pipewire"
|
|
wide
|
|
expandLeft
|
|
/>
|
|
{:else}
|
|
<Dropdown
|
|
value={outputDevice}
|
|
options={deviceOptions}
|
|
onchange={handleBackendDeviceChange}
|
|
wide
|
|
expandLeft
|
|
compact
|
|
/>
|
|
{/if}
|
|
</div>
|
|
{#if showAlsaPluginSelector}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">ALSA Plugin</span>
|
|
<span class="setting-desc">hw: Bit-perfect, exclusive. plughw: Auto-convert. pcm: Most compatible.</span>
|
|
</div>
|
|
<Dropdown
|
|
value={selectedAlsaPlugin}
|
|
options={alsaPluginOptions}
|
|
onchange={handleAlsaPluginChange}
|
|
wide
|
|
expandLeft
|
|
compact
|
|
/>
|
|
</div>
|
|
{/if}
|
|
{#if showAlsaHardwareVolume}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Enable Hardware Volume Control</span>
|
|
<span class="setting-desc">Experimental: Controls DAC volume via ALSA mixer. Some DACs don't support this - disable for maximum compatibility. If it fails, playback continues normally.</span>
|
|
</div>
|
|
<Toggle enabled={alsaHardwareVolume} onchange={handleAlsaHardwareVolumeChange} />
|
|
</div>
|
|
{/if}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.audio.exclusiveMode')}</span>
|
|
<span class="setting-desc">{exclusiveModeTooltipOverride ?? $t('settings.audio.exclusiveModeDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={exclusiveMode} onchange={handleExclusiveModeChange} disabled={exclusiveModeDisabled} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.audio.dacPassthrough')}</span>
|
|
<span class="setting-desc">{dacPassthroughTooltipOverride ?? $t('settings.audio.dacPassthroughDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={dacPassthrough} onchange={handleDacPassthroughChange} disabled={dacPassthroughDisabled} />
|
|
</div>
|
|
{#if isFlatpak && selectedBackend === 'PipeWire' && dacPassthrough}
|
|
<div class="flatpak-warning">
|
|
<div class="warning-icon">⚠️</div>
|
|
<div class="warning-content">
|
|
<strong>Flatpak Limitation:</strong> PipeWire cannot guarantee bit-perfect playback in sandboxed environments due to daemon access restrictions.
|
|
<br />
|
|
<strong>Recommended:</strong> Switch to ALSA Direct backend for true bit-perfect audio.
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Stream Uncached Tracks</span>
|
|
<span class="setting-desc">Start playback faster when track is not in cache. Seeking may be limited during initial buffering.</span>
|
|
</div>
|
|
<Toggle enabled={streamFirstTrack} onchange={handleStreamFirstTrackChange} />
|
|
</div>
|
|
{#if streamFirstTrack}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Initial Buffer Size</span>
|
|
<span class="setting-desc">Seconds to buffer before starting playback ({streamBufferSeconds}s)</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min="1"
|
|
max="10"
|
|
step="1"
|
|
value={streamBufferSeconds}
|
|
oninput={(e) => handleStreamBufferSecondsChange(parseInt(e.currentTarget.value))}
|
|
class="buffer-slider"
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Streaming Only</span>
|
|
<span class="setting-desc">Disables temporary cache. Not recommended without a fast connection. Offline library tracks always play first.</span>
|
|
</div>
|
|
<Toggle enabled={streamingOnly} onchange={handleStreamingOnlyChange} />
|
|
</div>
|
|
<div class="setting-row last">
|
|
<span class="setting-label">{$t('settings.audio.currentSampleRate')}</span>
|
|
<span class="setting-value" class:muted={!hardwareStatus?.is_active}>
|
|
{#if hardwareStatus?.is_active && hardwareStatus.hardware_sample_rate}
|
|
{(hardwareStatus.hardware_sample_rate / 1000).toFixed(1)} kHz
|
|
{#if hardwareStatus.hardware_format}
|
|
<span class="format-detail">({hardwareStatus.hardware_format})</span>
|
|
{/if}
|
|
{:else}
|
|
{$t('settings.audio.noActivePlayback')}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Playback Section -->
|
|
<section class="section" bind:this={playbackSection}>
|
|
<h3 class="section-title">{$t('settings.playback.title')}</h3>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.playback.autoplayBehavior')}</span>
|
|
<span class="setting-desc">{$t('settings.playback.autoplayBehaviorDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={autoplayMode === 'continue'} onchange={(enabled) => handleAutoplayModeChange(enabled ? 'continue' : 'track_only')} />
|
|
</div>
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.playback.showContextIcon')}</span>
|
|
<span class="setting-desc">{$t('settings.playback.showContextIconTooltip')}</span>
|
|
</div>
|
|
<Toggle enabled={showContextIcon} onchange={handleShowContextIconChange} />
|
|
</div>
|
|
<!-- Gapless, Crossfade, Normalize Volume hidden until properly implemented (see issue #29) -->
|
|
<!-- <div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.playback.gapless')}</span>
|
|
{#if gaplessTooltipOverride}
|
|
<span class="setting-desc">{gaplessTooltipOverride}</span>
|
|
{/if}
|
|
</div>
|
|
<Toggle enabled={gaplessPlayback} onchange={handleGaplessPlaybackChange} disabled={gaplessDisabled} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.playback.crossfade')}</span>
|
|
<div class="slider-container">
|
|
<VolumeSlider value={crossfade} onchange={handleCrossfadeChange} max={12} showValue />
|
|
</div>
|
|
</div>
|
|
<div class="setting-row last">
|
|
<span class="setting-label">{$t('settings.playback.normalizeVolume')}</span>
|
|
<Toggle enabled={normalizeVolume} onchange={(v) => (normalizeVolume = v)} />
|
|
</div> -->
|
|
</section>
|
|
|
|
<!-- Offline Mode Section -->
|
|
<section class="section" bind:this={offlineModeSection}>
|
|
<h3 class="section-title">{$t('offline.title')}</h3>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.status')}</span>
|
|
<span class="setting-desc status-indicator" class:offline={offlineStatus.isOffline}>
|
|
{#if offlineStatus.isOffline}
|
|
{#if offlineStatus.reason === 'no_network'}
|
|
{$t('offline.noNetwork')}
|
|
{:else if offlineStatus.reason === 'not_logged_in'}
|
|
{$t('offline.notLoggedIn')}
|
|
{:else if offlineStatus.reason === 'manual_override'}
|
|
{$t('offline.manualMode')}
|
|
{:else}
|
|
{$t('offline.offlineReason')}
|
|
{/if}
|
|
{:else}
|
|
{$t('offline.online')}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="setting-row" class:last={!offlineSettings.manualOfflineMode}>
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.enableManual')}</span>
|
|
<span class="setting-desc">{$t('offline.enableManualDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.manualOfflineMode} onchange={handleManualOfflineChange} />
|
|
</div>
|
|
<!-- Temporarily disabled: Show Partial Playlists -->
|
|
<!-- <div class="setting-row" class:last={!offlineSettings.manualOfflineMode}>
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.showPartialPlaylists')}</span>
|
|
<span class="setting-desc">{$t('offline.showPartialPlaylistsDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.showPartialPlaylists} onchange={handleShowPartialPlaylistsChange} />
|
|
</div> -->
|
|
|
|
<!-- Manual offline mode specific settings -->
|
|
{#if offlineSettings.manualOfflineMode}
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.allowCast')}</span>
|
|
<span class="setting-desc">{$t('offline.allowCastDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.allowCastWhileOffline} onchange={handleAllowCastChange} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.allowImmediateScrobbling')}</span>
|
|
<span class="setting-desc">{$t('offline.allowImmediateScrobblingDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.allowImmediateScrobbling} onchange={handleAllowImmediateScrobblingChange} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.allowAccumulatedScrobbling')}</span>
|
|
<span class="setting-desc">{$t('offline.allowAccumulatedScrobblingDesc')}</span>
|
|
<small class="setting-note">{$t('offline.scrobbleTimeLimit')}</small>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.allowAccumulatedScrobbling} onchange={handleAllowAccumulatedScrobblingChange} />
|
|
</div>
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('offline.showNetworkFolders')}</span>
|
|
<span class="setting-desc">{$t('offline.showNetworkFoldersDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={offlineSettings.showNetworkFoldersInManualOffline} onchange={handleShowNetworkFoldersChange} />
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Appearance Section -->
|
|
<section class="section" bind:this={appearanceSection}>
|
|
<h3 class="section-title">{$t('settings.appearance.title')}</h3>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.appearance.theme')}</span>
|
|
<div class="theme-selector">
|
|
<button
|
|
class="theme-filter-btn"
|
|
onclick={cycleThemeFilter}
|
|
title={themeFilter === 'all' ? 'All themes' : themeFilter === 'dark' ? 'Dark themes' : 'Light themes'}
|
|
>
|
|
{#if themeFilter === 'all'}
|
|
<SunMoon size={16} />
|
|
{:else if themeFilter === 'dark'}
|
|
<Moon size={16} />
|
|
{:else}
|
|
<Sun size={16} />
|
|
{/if}
|
|
</button>
|
|
<Dropdown
|
|
value={theme}
|
|
options={filteredThemeOptions}
|
|
onchange={handleThemeChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.appearance.language')}</span>
|
|
<Dropdown
|
|
value={language}
|
|
options={availableLanguages}
|
|
onchange={handleLanguageChange}
|
|
/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.appearance.uiScale')}</span>
|
|
<Dropdown
|
|
value={zoomLevel}
|
|
options={zoomOptions}
|
|
onchange={handleZoomChange}
|
|
/>
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.appearance.inAppToasts')}</span>
|
|
<Toggle enabled={toastsEnabled} onchange={(v) => { toastsEnabled = v; setToastsEnabled(v); }} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.appearance.systemNotifications')}</span>
|
|
<Toggle enabled={systemNotificationsEnabled} onchange={(v) => { systemNotificationsEnabled = v; setSystemNotificationsEnabled(v); }} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.appearance.hideTitleBar')}</span>
|
|
<span class="setting-desc">{$t('settings.appearance.hideTitleBarDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={hideTitleBar} onchange={(v) => setHideTitleBar(v)} />
|
|
</div>
|
|
|
|
<!-- System Tray subsection -->
|
|
<h4 class="subsection-title">{$t('settings.appearance.tray.title')}</h4>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.appearance.tray.enableTray')}</span>
|
|
<span class="setting-desc">{$t('settings.appearance.tray.enableTrayDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={enableTray} onchange={(v) => handleEnableTrayChange(v)} />
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.appearance.tray.minimizeToTray')}</span>
|
|
<span class="setting-desc">{$t('settings.appearance.tray.minimizeToTrayDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={minimizeToTray} onchange={(v) => handleMinimizeToTrayChange(v)} disabled={!enableTray} />
|
|
</div>
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.appearance.tray.closeToTray')}</span>
|
|
<span class="setting-desc">{$t('settings.appearance.tray.closeToTrayDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={closeToTray} onchange={(v) => handleCloseToTrayChange(v)} disabled={!enableTray} />
|
|
</div>
|
|
</section>
|
|
|
|
|
|
<!-- Offline Library Section -->
|
|
<section class="section collapsible-section" bind:this={downloadsSection}>
|
|
<button class="section-title-btn" onclick={() => offlineLibraryCollapsed = !offlineLibraryCollapsed}>
|
|
<h3 class="section-title">{$t('settings.offlineLibrary.title')}</h3>
|
|
<span class="section-summary">Cached tracks for offline playback</span>
|
|
{#if offlineLibraryCollapsed}
|
|
<ChevronDown size={16} />
|
|
{:else}
|
|
<ChevronUp size={16} />
|
|
{/if}
|
|
</button>
|
|
{#if !offlineLibraryCollapsed}
|
|
<p class="section-note">{$t('settings.offlineLibrary.disclaimer')}</p>
|
|
<div class="setting-row">
|
|
<span class="setting-label">{$t('settings.offlineLibrary.cachedTracks')}</span>
|
|
<span class="setting-value">
|
|
{#if downloadStats}
|
|
{downloadStats.readyTracks} tracks ({formatBytes(downloadStats.totalSizeBytes)})
|
|
{:else}
|
|
Loading...
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-with-description">
|
|
<span class="setting-label">Repair Offline Library</span>
|
|
<span class="setting-description">Fix offline markers lost during library scans</span>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleRepairDownloads}
|
|
disabled={isRepairingDownloads || !downloadStats || downloadStats.readyTracks === 0}
|
|
>
|
|
{isRepairingDownloads ? 'Repairing...' : 'Repair'}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">Clear Offline Library</span>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearDownloads}
|
|
disabled={isClearingDownloads || !downloadStats || downloadStats.readyTracks === 0}
|
|
>
|
|
{isClearingDownloads ? 'Clearing...' : 'Clear All'}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row last">
|
|
<div class="setting-with-description">
|
|
<span class="setting-label">Manage Offline Cache</span>
|
|
<span class="setting-description">Open the cache folder in your file manager</span>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleOpenCacheFolder}
|
|
>
|
|
Open Folder
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Library Section -->
|
|
<section class="section" bind:this={librarySection}>
|
|
<h3 class="section-title">{$t('settings.library.title')}</h3>
|
|
<div class="setting-row last">
|
|
<div class="setting-with-description">
|
|
<span class="setting-label">{$t('settings.library.fetchArtistImages')}</span>
|
|
<span class="setting-description">{$t('settings.library.fetchArtistImagesDesc')}</span>
|
|
</div>
|
|
<Toggle enabled={fetchQobuzArtistImages} onchange={(v) => {
|
|
fetchQobuzArtistImages = v;
|
|
localStorage.setItem('qbz-fetch-artist-images', String(v));
|
|
}} />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Integrations Section -->
|
|
<section class="section" bind:this={integrationsSection}>
|
|
<h3 class="section-title">{$t('settings.integrations.title')}</h3>
|
|
|
|
{#if lastfmConnected}
|
|
<div class="setting-row">
|
|
<div class="lastfm-connected">
|
|
<span class="setting-label">{$t('settings.integrations.lastfm')}</span>
|
|
<span class="lastfm-username">{$t('settings.integrations.connectedAs', { values: { username: lastfmUsername }})}</span>
|
|
</div>
|
|
<button
|
|
class="connect-btn connected"
|
|
onclick={handleLastfmDisconnect}
|
|
>
|
|
{$t('settings.integrations.disconnect')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row last">
|
|
<span class="setting-label">{$t('settings.integrations.scrobbling')}</span>
|
|
<Toggle enabled={scrobbling} onchange={handleScrobblingChange} />
|
|
</div>
|
|
{:else}
|
|
<div class="setting-row" class:last={!showLastfmConfig && !lastfmAuthToken}>
|
|
<span class="setting-label">{$t('settings.integrations.lastfm')}</span>
|
|
<button
|
|
class="connect-btn"
|
|
onclick={handleLastfmConnect}
|
|
disabled={lastfmConnecting}
|
|
>
|
|
{lastfmConnecting ? 'Connecting...' : $t('settings.integrations.connect')}
|
|
</button>
|
|
</div>
|
|
|
|
{#if lastfmAuthToken}
|
|
<!-- Waiting for user to authorize in browser -->
|
|
<div class="lastfm-config">
|
|
<p class="auth-info">
|
|
A browser window has been opened. Please authorize QBZ on Last.fm, then click the button below.
|
|
</p>
|
|
<button
|
|
class="auth-complete-btn"
|
|
onclick={handleLastfmCompleteAuth}
|
|
disabled={lastfmConnecting}
|
|
>
|
|
{lastfmConnecting ? 'Verifying...' : 'I\'ve Authorized'}
|
|
</button>
|
|
<button
|
|
class="auth-cancel-btn"
|
|
onclick={() => { lastfmAuthToken = ''; showLastfmConfig = false; }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
{:else if showLastfmConfig && !hasEmbeddedCredentials}
|
|
<!-- No embedded credentials, user needs to provide their own -->
|
|
<div class="lastfm-config">
|
|
<p class="config-info">
|
|
QBZ needs Last.fm API credentials to enable scrobbling.
|
|
<a href="https://www.last.fm/api/account/create" target="_blank" rel="noopener">
|
|
Create an API account
|
|
</a> and enter your credentials below.
|
|
</p>
|
|
<div class="config-field">
|
|
<label for="lastfm-api-key">API Key</label>
|
|
<input
|
|
id="lastfm-api-key"
|
|
type="text"
|
|
bind:value={lastfmApiKey}
|
|
placeholder="Enter your API key"
|
|
/>
|
|
</div>
|
|
<div class="config-field">
|
|
<label for="lastfm-api-secret">Shared Secret</label>
|
|
<input
|
|
id="lastfm-api-secret"
|
|
type="password"
|
|
bind:value={lastfmApiSecret}
|
|
placeholder="Enter your shared secret"
|
|
/>
|
|
</div>
|
|
<button
|
|
class="auth-start-btn"
|
|
onclick={handleLastfmConnect}
|
|
disabled={!lastfmApiKey || !lastfmApiSecret || lastfmConnecting}
|
|
>
|
|
{lastfmConnecting ? 'Opening...' : 'Authorize with Last.fm'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- ListenBrainz Integration -->
|
|
{#if listenbrainzConnected}
|
|
<div class="setting-row">
|
|
<div class="lastfm-connected">
|
|
<span class="setting-label">ListenBrainz</span>
|
|
<span class="lastfm-username">{$t('settings.integrations.connectedAs', { values: { username: listenbrainzUsername }})}</span>
|
|
</div>
|
|
<button
|
|
class="connect-btn connected"
|
|
onclick={handleListenBrainzDisconnect}
|
|
>
|
|
{$t('settings.integrations.disconnect')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<span class="setting-label">Scrobbling</span>
|
|
<Toggle enabled={listenbrainzEnabled} onchange={handleListenBrainzEnabledChange} />
|
|
</div>
|
|
{:else}
|
|
<div class="setting-row" class:last={!showListenBrainzConfig}>
|
|
<span class="setting-label">ListenBrainz</span>
|
|
<button
|
|
class="connect-btn"
|
|
onclick={() => showListenBrainzConfig = !showListenBrainzConfig}
|
|
disabled={listenbrainzConnecting}
|
|
>
|
|
{listenbrainzConnecting ? 'Connecting...' : $t('settings.integrations.connect')}
|
|
</button>
|
|
</div>
|
|
|
|
{#if showListenBrainzConfig}
|
|
<div class="lastfm-config">
|
|
<p class="config-info">
|
|
Get your personal token from
|
|
<a href="https://listenbrainz.org/settings/" target="_blank" rel="noopener">
|
|
listenbrainz.org/settings
|
|
</a>
|
|
</p>
|
|
<div class="config-field">
|
|
<label for="listenbrainz-token">User Token</label>
|
|
<input
|
|
id="listenbrainz-token"
|
|
type="password"
|
|
bind:value={listenbrainzToken}
|
|
placeholder="Paste your ListenBrainz token"
|
|
/>
|
|
</div>
|
|
<button
|
|
class="auth-start-btn"
|
|
onclick={handleListenBrainzConnect}
|
|
disabled={!listenbrainzToken.trim() || listenbrainzConnecting}
|
|
>
|
|
{listenbrainzConnecting ? 'Connecting...' : 'Connect'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- MusicBrainz Integration -->
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">MusicBrainz</span>
|
|
<small class="setting-note">
|
|
Enable artist relationships and enhanced metadata from MusicBrainz.
|
|
</small>
|
|
</div>
|
|
<Toggle enabled={musicbrainzEnabled} onchange={handleMusicBrainzChange} />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Updates Section -->
|
|
<section class="section" bind:this={updatesSection}>
|
|
<h3 class="section-title">Updates</h3>
|
|
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Check for new releases on launch</span>
|
|
</div>
|
|
<Toggle
|
|
enabled={updatePreferences.checkOnLaunch}
|
|
onchange={handleUpdateCheckOnLaunchToggle}
|
|
/>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Check for updates now</span>
|
|
</div>
|
|
<button
|
|
class="connect-btn updates-check-btn"
|
|
onclick={handleManualUpdateCheck}
|
|
disabled={isCheckingUpdates}
|
|
type="button"
|
|
>
|
|
{#if isCheckingUpdates}
|
|
<Loader2 size={14} class="spin" />
|
|
<span>Checking...</span>
|
|
{:else}
|
|
<span>Check</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Show what's new on new version launch</span>
|
|
</div>
|
|
<Toggle
|
|
enabled={updatePreferences.showWhatsNewOnLaunch}
|
|
onchange={handleShowWhatsNewToggle}
|
|
/>
|
|
</div>
|
|
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Show changelog for current version</span>
|
|
{#if updatesCurrentVersion}
|
|
<small class="setting-note">Current version: v{updatesCurrentVersion}</small>
|
|
{/if}
|
|
</div>
|
|
<button
|
|
class="connect-btn"
|
|
onclick={handleShowCurrentChangelog}
|
|
type="button"
|
|
>
|
|
Show
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{#if isUpdateResultOpen}
|
|
<UpdateCheckResultModal
|
|
isOpen={isUpdateResultOpen}
|
|
status={updateResultStatus}
|
|
newVersion={updateResultRelease?.version ?? ''}
|
|
onClose={handleCloseUpdateResult}
|
|
onVisitReleasePage={handleVisitReleaseFromResult}
|
|
/>
|
|
{/if}
|
|
|
|
{#if settingsWhatsNewRelease}
|
|
<WhatsNewModal
|
|
isOpen={isSettingsWhatsNewOpen}
|
|
release={settingsWhatsNewRelease}
|
|
showTitleBar={showTitleBar}
|
|
onClose={handleCloseSettingsWhatsNew}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Storage Section (Memory Cache) -->
|
|
<section class="section collapsible-section" bind:this={storageSection}>
|
|
<button class="section-title-btn" onclick={() => storageCollapsed = !storageCollapsed}>
|
|
<h3 class="section-title">{$t('settings.storage.title')}</h3>
|
|
<span class="section-summary">Queue, lyrics, artwork, and metadata caches</span>
|
|
{#if storageCollapsed}
|
|
<ChevronDown size={16} />
|
|
{:else}
|
|
<ChevronUp size={16} />
|
|
{/if}
|
|
</button>
|
|
{#if !storageCollapsed}
|
|
<p class="section-note">{$t('settings.storage.queueCacheNote')}</p>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.storage.clearCache')}</span>
|
|
<small class="setting-note">
|
|
{#if cacheStats}
|
|
{$t('settings.storage.queueCacheStats', {
|
|
values: {
|
|
tracks: cacheStats.cached_tracks,
|
|
used: formatBytes(cacheStats.current_size_bytes),
|
|
max: formatBytes(cacheStats.max_size_bytes)
|
|
}
|
|
})}
|
|
{:else}
|
|
{$t('actions.loading')}
|
|
{/if}
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearCache}
|
|
disabled={isClearing || !cacheStats || cacheStats.current_size_bytes === 0}
|
|
>
|
|
{isClearing ? $t('settings.storage.clearing') : $t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">{$t('settings.lyrics.clearLyrics')}</span>
|
|
<small class="setting-note">
|
|
{#if lyricsCacheStats}
|
|
{$t('settings.lyrics.cacheStats', {
|
|
values: {
|
|
entries: lyricsCacheStats.entries,
|
|
size: formatBytes(lyricsCacheStats.sizeBytes)
|
|
}
|
|
})}
|
|
{:else}
|
|
-
|
|
{/if}
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearLyricsCache}
|
|
disabled={isClearingLyrics}
|
|
>
|
|
{isClearingLyrics ? $t('settings.storage.clearing') : $t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">MusicBrainz Cache</span>
|
|
<small class="setting-note">
|
|
{#if musicBrainzCacheStats}
|
|
{musicBrainzCacheStats.artists} artists, {musicBrainzCacheStats.relations} relations, {musicBrainzCacheStats.recordings} recordings
|
|
{:else}
|
|
-
|
|
{/if}
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearMusicBrainzCache}
|
|
disabled={isClearingMusicBrainz || !musicBrainzCacheStats || (musicBrainzCacheStats.artists === 0 && musicBrainzCacheStats.relations === 0 && musicBrainzCacheStats.recordings === 0)}
|
|
>
|
|
{isClearingMusicBrainz ? $t('settings.storage.clearing') : $t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Artist Vectors (Suggestions)</span>
|
|
<small class="setting-note">
|
|
{#if vectorStoreStats}
|
|
{vectorStoreStats.artist_count} artists, {vectorStoreStats.entry_count} relations
|
|
{:else}
|
|
-
|
|
{/if}
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearVectorStore}
|
|
disabled={isClearingVectorStore || !vectorStoreStats || vectorStoreStats.entry_count === 0}
|
|
>
|
|
{isClearingVectorStore ? $t('settings.storage.clearing') : $t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Artwork Thumbnails</span>
|
|
<small class="setting-note">
|
|
{#if artworkCacheStats}
|
|
{@const thumbCount = artworkCacheStats.thumbnail_file_count ?? 0}
|
|
{@const thumbBytes = artworkCacheStats.thumbnails_cache_bytes ?? 0}
|
|
{@const legacyCount = artworkCacheStats.artwork_file_count ?? 0}
|
|
{@const legacyBytes = artworkCacheStats.artwork_cache_bytes ?? 0}
|
|
{#if thumbCount > 0 || legacyCount > 0}
|
|
{#if thumbCount > 0}{thumbCount} thumbnails ({formatBytes(thumbBytes)}){/if}{#if thumbCount > 0 && legacyCount > 0}, {/if}{#if legacyCount > 0}{legacyCount} legacy files ({formatBytes(legacyBytes)}){/if}
|
|
{:else}
|
|
No cached artwork
|
|
{/if}
|
|
{:else}
|
|
-
|
|
{/if}
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearArtworkCache}
|
|
disabled={isClearingArtwork || !artworkCacheStats || ((artworkCacheStats.thumbnails_cache_bytes ?? 0) === 0 && (artworkCacheStats.artwork_cache_bytes ?? 0) === 0)}
|
|
>
|
|
{isClearingArtwork ? $t('settings.storage.clearing') : $t('actions.clear')}
|
|
</button>
|
|
</div>
|
|
<div class="setting-row last">
|
|
<div class="setting-info">
|
|
<span class="setting-label">Clear All Caches</span>
|
|
<small class="setting-note">
|
|
Clears all cached data above
|
|
</small>
|
|
</div>
|
|
<button
|
|
class="clear-btn"
|
|
onclick={handleClearAllCaches}
|
|
disabled={isClearingAllCaches}
|
|
>
|
|
{isClearingAllCaches ? $t('settings.storage.clearing') : 'Clear All'}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Flatpak Section (only shown when running in Flatpak) -->
|
|
<!-- NOTE: Keep this section LAST. If adding new settings sections, add them BEFORE this one. -->
|
|
{#if isFlatpak}
|
|
<section class="section flatpak-section" id="flatpak" bind:this={flatpakSection}>
|
|
<h3 class="section-title">Flatpak Sandbox</h3>
|
|
<div class="flatpak-info">
|
|
<p class="flatpak-intro">
|
|
QBZ is running inside a Flatpak sandbox. For offline libraries on NAS, network mounts, or external disks, direct filesystem access is required.
|
|
</p>
|
|
<div class="flatpak-guide">
|
|
<h4>Grant Filesystem Access</h4>
|
|
<p>Use <strong>Flatseal</strong> (GUI) or run this command for each folder you want to add:</p>
|
|
<div class="copyable-command">
|
|
<pre class="code-block">flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz</pre>
|
|
<button class="copy-btn" onclick={() => copyCommand('fs-basic', 'flatpak override --user --filesystem=/path/to/your/music com.blitzfc.qbz')}>
|
|
{copiedCommands['fs-basic'] ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<h4>Examples</h4>
|
|
<div class="copyable-command">
|
|
<pre class="code-block"># CIFS / Samba mount
|
|
flatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz
|
|
|
|
# SSHFS mount
|
|
flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz
|
|
|
|
# Custom folder (edit as needed)
|
|
flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
|
|
<button class="copy-btn" onclick={() => copyCommand('fs-examples', `# CIFS / Samba mount\nflatpak override --user --filesystem=/mnt/nas com.blitzfc.qbz\n\n# SSHFS mount\nflatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz\n\n# Custom folder (edit as needed)\nflatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz`)}>
|
|
{copiedCommands['fs-examples'] ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<p class="flatpak-note">
|
|
<strong>Note:</strong> This setting is persistent and survives reboots and updates.<br />
|
|
<strong>Tip:</strong> You can repeat the command for as many folders as you need.
|
|
</p>
|
|
</div>
|
|
<div class="flatpak-guide" style="margin-top:2em;">
|
|
<h4>Chromecast & DLNA Device Discovery</h4>
|
|
<p>
|
|
To detect Chromecast and DLNA devices on your network, you must grant network sharing permissions to the app:
|
|
</p>
|
|
<div class="copyable-command">
|
|
<pre class="code-block">flatpak override --user --share=network com.blitzfc.qbz</pre>
|
|
<button class="copy-btn" onclick={() => copyCommand('network', 'flatpak override --user --share=network com.blitzfc.qbz')}>
|
|
{copiedCommands['network'] ? 'Copied!' : 'Copy'}
|
|
</button>
|
|
</div>
|
|
<p class="flatpak-note">
|
|
<strong>Note:</strong> Without this, device discovery will not work.<br />
|
|
You only need to do this once.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
</ViewTransition>
|
|
|
|
{#if isCheckingNetwork}
|
|
<div class="network-check-overlay" aria-busy="true" aria-label={$t('offline.checkingNetwork')}>
|
|
<div class="network-check-spinner"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
.settings-view {
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: 0 32px 24px 18px;
|
|
padding-right: 24px; /* Less padding on right for scrollbar */
|
|
}
|
|
|
|
/* Scrollbar styling */
|
|
.settings-view::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.settings-view::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.settings-view::-webkit-scrollbar-thumb {
|
|
background: var(--alpha-15);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.settings-view:hover::-webkit-scrollbar-thumb {
|
|
background: var(--alpha-25);
|
|
}
|
|
|
|
.settings-view::-webkit-scrollbar-thumb:hover {
|
|
background: var(--alpha-40);
|
|
}
|
|
|
|
.loading-text {
|
|
color: var(--alpha-60);
|
|
font-size: 14px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.dropdown-with-help {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.help-icon-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.help-icon-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.7);
|
|
backdrop-filter: blur(4px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.loading-content {
|
|
text-align: center;
|
|
color: white;
|
|
}
|
|
|
|
.loading-content :global(.spinner) {
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 16px auto;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.loading-content p {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.loading-subtitle {
|
|
margin-top: 8px !important;
|
|
font-size: 14px !important;
|
|
opacity: 0.7;
|
|
font-weight: 400 !important;
|
|
}
|
|
|
|
.header {
|
|
padding-top: 24px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.back-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 14px;
|
|
color: var(--text-muted);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
margin-bottom: 16px;
|
|
transition: color 150ms ease;
|
|
}
|
|
|
|
.back-btn:hover {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.title {
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
/* Settings Navigation */
|
|
.settings-nav {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 16px;
|
|
padding: 12px 32px;
|
|
margin: 0 -24px 24px -32px;
|
|
width: calc(100% + 56px);
|
|
background: var(--bg-primary);
|
|
border-bottom: 1px solid var(--alpha-6);
|
|
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.nav-link {
|
|
padding: 6px 0;
|
|
border: none;
|
|
background: none;
|
|
color: var(--text-muted);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: color 150ms ease, border-color 150ms ease;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nav-link:hover {
|
|
color: var(--text-secondary);
|
|
border-bottom-color: var(--text-muted);
|
|
}
|
|
|
|
.nav-link.active {
|
|
color: var(--text-primary);
|
|
border-bottom-color: var(--accent-primary);
|
|
}
|
|
|
|
.section {
|
|
scroll-margin-top: 60px;
|
|
background-color: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 24px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.section-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.subsection-title {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin: 20px 0 12px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
.section-note {
|
|
margin: -6px 0 16px;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
line-height: 1.4;
|
|
}
|
|
|
|
/* Compact Account Section */
|
|
.account-section {
|
|
padding: 16px 24px;
|
|
}
|
|
|
|
.account-card-compact {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
gap: 12px;
|
|
}
|
|
|
|
.avatar-small {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
background-color: var(--accent-primary);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: white;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
flex-shrink: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
.account-info-compact {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 6px 8px;
|
|
min-width: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
.username-compact {
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.account-info-compact .separator {
|
|
color: var(--text-muted);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.subscription-text {
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.logout-btn-compact {
|
|
padding: 6px 16px;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--danger);
|
|
background: none;
|
|
color: var(--danger);
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease;
|
|
flex-shrink: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
.logout-btn-compact:hover {
|
|
background-color: var(--danger-bg);
|
|
}
|
|
|
|
/* Collapsible sections */
|
|
.collapsible-section .section-title-btn {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 12px;
|
|
width: 100%;
|
|
padding: 0;
|
|
margin-bottom: 8px;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.collapsible-section .section-title-btn .section-title {
|
|
margin-bottom: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.section-summary {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
text-align: left;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.collapsible-section .section-title-btn :global(svg) {
|
|
flex-shrink: 0;
|
|
color: var(--text-muted);
|
|
transition: color 150ms ease;
|
|
}
|
|
|
|
.collapsible-section .section-title-btn:hover :global(svg) {
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.setting-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
height: 48px;
|
|
border-bottom: 1px solid var(--bg-tertiary);
|
|
gap: 16px;
|
|
}
|
|
|
|
.setting-row.last {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.setting-label {
|
|
font-size: 14px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.setting-with-description {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 0 1 60%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.setting-description {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.setting-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 1;
|
|
max-width: 60%;
|
|
min-width: 0;
|
|
}
|
|
|
|
.setting-desc {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.setting-note {
|
|
font-size: 11px;
|
|
color: var(--text-muted);
|
|
opacity: 0.8;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.setting-row:has(.setting-note) {
|
|
height: auto;
|
|
min-height: 48px;
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.setting-row:has(.setting-description) {
|
|
height: auto;
|
|
min-height: 48px;
|
|
padding: 12px 0;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.setting-row:has(.setting-desc) {
|
|
height: auto;
|
|
min-height: 48px;
|
|
padding: 12px 0;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.setting-row:has(.radio-group) {
|
|
height: auto;
|
|
min-height: 48px;
|
|
padding: 12px 0;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.setting-value {
|
|
font-size: 14px;
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.setting-value.muted {
|
|
opacity: 0.5;
|
|
font-style: italic;
|
|
}
|
|
|
|
.format-detail {
|
|
font-size: 12px;
|
|
opacity: 0.7;
|
|
margin-left: 4px;
|
|
}
|
|
|
|
.slider-container {
|
|
width: 240px;
|
|
}
|
|
|
|
.radio-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.radio-option {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
cursor: pointer;
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.radio-option:hover {
|
|
background: var(--alpha-5);
|
|
}
|
|
|
|
.radio-option input[type="radio"] {
|
|
width: 18px;
|
|
height: 18px;
|
|
cursor: pointer;
|
|
accent-color: var(--accent-color, #1db954);
|
|
}
|
|
|
|
.radio-option span {
|
|
font-size: 14px;
|
|
color: var(--text-primary);
|
|
user-select: none;
|
|
}
|
|
|
|
.connect-btn {
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
background-color: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.connect-btn:hover {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.updates-check-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
min-width: 112px;
|
|
}
|
|
|
|
.updates-check-btn :global(.spin) {
|
|
animation: updates-spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes updates-spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.connect-btn.connected {
|
|
background: none;
|
|
border: 1px solid var(--text-muted);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.connect-btn.connected:hover {
|
|
border-color: var(--text-secondary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.clear-btn {
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid #ff6b6b;
|
|
background: none;
|
|
color: #ff6b6b;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease;
|
|
}
|
|
|
|
.clear-btn:hover:not(:disabled) {
|
|
background-color: rgba(255, 107, 107, 0.1);
|
|
}
|
|
|
|
.clear-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.folder-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--text-muted);
|
|
background: none;
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
}
|
|
|
|
.folder-btn:hover {
|
|
border-color: var(--text-primary);
|
|
color: var(--text-primary);
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
/* Harmonize button widths across settings rows */
|
|
.connect-btn,
|
|
.clear-btn,
|
|
.folder-btn,
|
|
.logout-btn {
|
|
min-width: 140px;
|
|
padding-top: 7px;
|
|
padding-bottom: 7px;
|
|
}
|
|
|
|
.folder-btn {
|
|
justify-content: center;
|
|
}
|
|
|
|
/* Last.fm styles */
|
|
.lastfm-connected {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.lastfm-username {
|
|
font-size: 12px;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.lastfm-config {
|
|
padding: 16px;
|
|
background-color: var(--bg-tertiary);
|
|
border-radius: 8px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.config-info {
|
|
font-size: 13px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.config-info a {
|
|
color: var(--accent-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.config-info a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.config-field {
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.config-field label {
|
|
display: block;
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.config-field input {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 6px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.config-field input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.config-field input::placeholder {
|
|
color: var(--text-disabled);
|
|
}
|
|
|
|
.auth-info {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin: 16px 0;
|
|
padding: 12px;
|
|
background-color: var(--bg-secondary);
|
|
border-radius: 6px;
|
|
}
|
|
|
|
.auth-start-btn,
|
|
.auth-complete-btn {
|
|
width: 100%;
|
|
padding: 10px 16px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
}
|
|
|
|
.auth-start-btn {
|
|
background-color: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.auth-start-btn:hover:not(:disabled) {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.auth-start-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.auth-complete-btn {
|
|
background-color: #1db954;
|
|
color: white;
|
|
border: none;
|
|
}
|
|
|
|
.auth-complete-btn:hover:not(:disabled) {
|
|
background-color: #1ed760;
|
|
}
|
|
|
|
.auth-complete-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.auth-cancel-btn {
|
|
width: 100%;
|
|
padding: 10px 16px;
|
|
margin-top: 8px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 150ms ease;
|
|
background: none;
|
|
border: 1px solid var(--text-muted);
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.auth-cancel-btn:hover {
|
|
border-color: var(--text-secondary);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.connect-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Offline status indicator */
|
|
.status-indicator {
|
|
font-weight: 500;
|
|
color: #4ade80;
|
|
}
|
|
|
|
.status-indicator.offline {
|
|
color: #fbbf24;
|
|
}
|
|
|
|
/* Network check overlay */
|
|
.network-check-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 9999;
|
|
}
|
|
|
|
.network-check-spinner {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 4px solid var(--alpha-20);
|
|
border-top-color: var(--accent-primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* Flatpak section styles */
|
|
.flatpak-section {
|
|
background-color: var(--bg-tertiary);
|
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.flatpak-info {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.flatpak-intro {
|
|
font-size: 14px;
|
|
line-height: 1.6;
|
|
margin-bottom: 20px;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.flatpak-guide h4 {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
margin-bottom: 8px;
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.flatpak-guide h4:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.flatpak-guide p {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.code-block {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 6px;
|
|
padding: 12px;
|
|
font-family: 'Fira Code', 'Courier New', monospace;
|
|
font-size: 12px;
|
|
color: var(--accent-primary);
|
|
overflow-x: auto;
|
|
margin: 8px 0 16px 0;
|
|
white-space: pre;
|
|
}
|
|
|
|
.flatpak-note {
|
|
font-size: 12px;
|
|
color: var(--text-muted);
|
|
font-style: italic;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.flatpak-note strong {
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Flatpak warning banner */
|
|
.flatpak-warning {
|
|
display: flex;
|
|
gap: 12px;
|
|
background-color: rgba(251, 191, 36, 0.1);
|
|
border: 1px solid rgba(251, 191, 36, 0.3);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin: 16px 0;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.warning-icon {
|
|
font-size: 20px;
|
|
flex-shrink: 0;
|
|
line-height: 1;
|
|
}
|
|
|
|
.warning-content {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.warning-content strong {
|
|
color: rgb(251, 191, 36);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Buffer slider styling */
|
|
.buffer-slider {
|
|
width: 120px;
|
|
height: 4px;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: var(--alpha-20);
|
|
border-radius: 2px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.buffer-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: var(--accent-color, #3b82f6);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
transition: transform 0.1s ease;
|
|
}
|
|
|
|
.buffer-slider::-webkit-slider-thumb:hover {
|
|
transform: scale(1.1);
|
|
}
|
|
|
|
.buffer-slider::-moz-range-thumb {
|
|
width: 14px;
|
|
height: 14px;
|
|
background: var(--accent-color, #3b82f6);
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
/* Flatpak copyable command styling */
|
|
.copyable-command {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.copyable-command .code-block {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
background: var(--bg-tertiary);
|
|
border-radius: 6px;
|
|
padding: 8px 12px;
|
|
user-select: all;
|
|
min-width: 0;
|
|
flex: 1;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.copy-btn {
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
padding: 6px 14px;
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.copy-btn:hover {
|
|
background: var(--accent-secondary);
|
|
}
|
|
|
|
/* Theme selector with filter button */
|
|
.theme-selector {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.theme-filter-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
background: var(--bg-tertiary);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: var(--text-muted);
|
|
cursor: pointer;
|
|
transition: background-color 150ms ease, color 150ms ease;
|
|
}
|
|
|
|
.theme-filter-btn:hover {
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
</style>
|
|
|
|
<MigrationModal
|
|
isOpen={showMigrationModal}
|
|
onClose={closeMigrationModal}
|
|
totalTracks={legacyTracksCount}
|
|
/>
|
|
|
|
<AlsaUtilsHelpModal
|
|
isOpen={showAlsaUtilsHelpModal}
|
|
onClose={() => showAlsaUtilsHelpModal = false}
|
|
/>
|