Merge branch 'updates/uiux-1.1.7' into pre-release

This commit is contained in:
Victor RH committed 2026-01-27 03:21:36 -06:00
commit 6df4f8ea2e
23 files changed
+2343 -58

No files matched your search

+18
View File
@@ -30,6 +30,7 @@ pub mod radio_engine;
pub mod session_store;
pub mod share;
pub mod tray;
pub mod updates;
use std::sync::Arc;
use tauri::{Emitter, Manager};
@@ -191,6 +192,9 @@ pub fn run() {
// Initialize tray settings state
let tray_settings_state = config::tray_settings::TraySettingsState::new()
.expect("Failed to initialize tray settings");
// Initialize updates state
let updates_state = updates::UpdatesState::new()
.expect("Failed to initialize updates state");
// Initialize subscription validity tracking (for offline download compliance)
let subscription_state = config::create_subscription_state()
.expect("Failed to initialize subscription state");
@@ -430,6 +434,7 @@ pub fn run() {
.manage(playback_prefs_state)
.manage(favorites_prefs_state)
.manage(tray_settings_state)
.manage(updates_state)
.manage(musicbrainz_state)
.manage(listenbrainz_state)
.manage(remote_metadata_state)
@@ -756,6 +761,19 @@ pub fn run() {
config::playback_preferences::set_show_context_icon,
config::favorites_preferences::get_favorites_preferences,
config::favorites_preferences::save_favorites_preferences,
// Updates commands
updates::get_update_preferences,
updates::set_update_check_on_launch,
updates::set_show_whats_new_on_launch,
updates::acknowledge_release,
updates::ignore_release,
updates::is_release_acknowledged,
updates::is_release_ignored,
updates::has_whats_new_been_shown,
updates::mark_whats_new_shown,
updates::get_current_version,
updates::check_for_updates,
updates::fetch_release_for_version,
offline::commands::set_allow_immediate_scrobbling,
offline::commands::set_allow_accumulated_scrobbling,
offline::commands::set_show_network_folders_in_manual_offline,
+519
View File
@@ -0,0 +1,519 @@
//! Update checks and "What's New" persistence
//!
//! Requirements:
//! - Durable persistence via SQLite (no JSON files)
//! - Network failures must fail silently
//! - Update checks must never block startup
use chrono::{DateTime, Duration as ChronoDuration, Utc};
use log::{info, warn};
use reqwest::Client;
use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const GITHUB_RELEASES_URL: &str = "https://api.github.com/repos/vicrodh/qbz/releases";
const UPDATE_MIN_AGE_HOURS: i64 = 12;
const NETWORK_TIMEOUT_SECONDS: u64 = 4;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePreferences {
pub check_on_launch: bool,
pub show_whats_new_on_launch: bool,
}
impl Default for UpdatePreferences {
fn default() -> Self {
Self {
check_on_launch: true,
show_whats_new_on_launch: true,
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct GithubRelease {
tag_name: String,
name: String,
published_at: String,
body: Option<String>,
html_url: String,
draft: bool,
prerelease: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReleaseInfo {
pub version: String,
pub tag_name: String,
pub name: String,
pub published_at: String,
pub published_at_epoch: i64,
pub body: Option<String>,
pub html_url: String,
pub is_old_enough: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UpdateCheckStatus {
NoUpdates,
UpdateAvailable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheckResult {
pub status: UpdateCheckStatus,
pub current_version: String,
pub release: Option<ReleaseInfo>,
}
impl UpdateCheckResult {
fn no_updates(current_version: String) -> Self {
Self {
status: UpdateCheckStatus::NoUpdates,
current_version,
release: None,
}
}
}
/// SQLite-backed persistence for updates
pub struct UpdatesStore {
conn: Connection,
}
impl UpdatesStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or("Could not determine data directory")?
.join("qbz");
std::fs::create_dir_all(&data_dir)
.map_err(|e| format!("Failed to create data directory: {}", e))?;
let db_path = data_dir.join("updates.db");
let conn =
Connection::open(&db_path).map_err(|e| format!("Failed to open updates database: {}", e))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS update_preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
check_on_launch INTEGER NOT NULL DEFAULT 1,
show_whats_new_on_launch INTEGER NOT NULL DEFAULT 1
);
INSERT OR IGNORE INTO update_preferences (id, check_on_launch, show_whats_new_on_launch)
VALUES (1, 1, 1);
CREATE TABLE IF NOT EXISTS acknowledged_releases (
version TEXT PRIMARY KEY,
acknowledged_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS ignored_releases (
version TEXT PRIMARY KEY,
ignored_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS whats_new_shown (
version TEXT PRIMARY KEY,
shown_at INTEGER NOT NULL
);",
)
.map_err(|e| format!("Failed to create updates tables: {}", e))?;
// Migration guard: ensure both preference columns exist even on partial schemas
let pref_migrations = [
"ALTER TABLE update_preferences ADD COLUMN check_on_launch INTEGER NOT NULL DEFAULT 1",
"ALTER TABLE update_preferences ADD COLUMN show_whats_new_on_launch INTEGER NOT NULL DEFAULT 1",
];
for migration in pref_migrations {
let _ = conn.execute(migration, []);
}
Ok(Self { conn })
}
pub fn get_preferences(&self) -> Result<UpdatePreferences, String> {
self.conn
.query_row(
"SELECT check_on_launch, show_whats_new_on_launch FROM update_preferences WHERE id = 1",
[],
|row| {
Ok(UpdatePreferences {
check_on_launch: row.get::<_, i64>(0)? != 0,
show_whats_new_on_launch: row.get::<_, i64>(1)? != 0,
})
},
)
.map_err(|e| format!("Failed to get update preferences: {}", e))
}
pub fn set_check_on_launch(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE update_preferences SET check_on_launch = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set check_on_launch: {}", e))?;
Ok(())
}
pub fn set_show_whats_new_on_launch(&self, enabled: bool) -> Result<(), String> {
self.conn
.execute(
"UPDATE update_preferences SET show_whats_new_on_launch = ?1 WHERE id = 1",
params![enabled as i64],
)
.map_err(|e| format!("Failed to set show_whats_new_on_launch: {}", e))?;
Ok(())
}
pub fn acknowledge_release(&self, version: &str) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO acknowledged_releases (version, acknowledged_at) VALUES (?1, ?2)",
params![version, now_epoch_seconds()],
)
.map_err(|e| format!("Failed to acknowledge release: {}", e))?;
Ok(())
}
pub fn ignore_release(&self, version: &str) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO ignored_releases (version, ignored_at) VALUES (?1, ?2)",
params![version, now_epoch_seconds()],
)
.map_err(|e| format!("Failed to ignore release: {}", e))?;
Ok(())
}
pub fn is_release_acknowledged(&self, version: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM acknowledged_releases WHERE version = ?1 LIMIT 1",
params![version],
|_row| Ok(()),
)
.is_ok()
}
pub fn is_release_ignored(&self, version: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM ignored_releases WHERE version = ?1 LIMIT 1",
params![version],
|_row| Ok(()),
)
.is_ok()
}
pub fn has_whats_new_been_shown(&self, version: &str) -> bool {
self.conn
.query_row(
"SELECT 1 FROM whats_new_shown WHERE version = ?1 LIMIT 1",
params![version],
|_row| Ok(()),
)
.is_ok()
}
pub fn mark_whats_new_shown(&self, version: &str) -> Result<(), String> {
self.conn
.execute(
"INSERT OR REPLACE INTO whats_new_shown (version, shown_at) VALUES (?1, ?2)",
params![version, now_epoch_seconds()],
)
.map_err(|e| format!("Failed to mark whats_new_shown: {}", e))?;
Ok(())
}
}
/// Thread-safe state wrapper
pub struct UpdatesState {
store: Arc<Mutex<UpdatesStore>>,
client: Client,
}
impl UpdatesState {
pub fn new() -> Result<Self, String> {
let store = UpdatesStore::new()?;
let client = Client::builder()
.timeout(Duration::from_secs(NETWORK_TIMEOUT_SECONDS))
.user_agent("qbz-nix-updates")
.build()
.map_err(|e| format!("Failed to create updates HTTP client: {}", e))?;
Ok(Self {
store: Arc::new(Mutex::new(store)),
client,
})
}
fn with_store<T>(&self, f: impl FnOnce(&UpdatesStore) -> T) -> T {
let guard = self.store.lock().expect("UpdatesStore mutex poisoned");
f(&guard)
}
fn current_version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
}
fn now_epoch_seconds() -> i64 {
Utc::now().timestamp()
}
fn normalize_version_tag(tag: &str) -> String {
tag.trim().trim_start_matches('v').to_string()
}
fn parse_version_parts(version: &str) -> Vec<u64> {
normalize_version_tag(version)
.split('.')
.map(|segment| {
let numeric: String = segment.chars().take_while(|c| c.is_ascii_digit()).collect();
numeric.parse::<u64>().unwrap_or(0)
})
.collect()
}
fn is_newer_version(current: &str, candidate: &str) -> bool {
let current_parts = parse_version_parts(current);
let candidate_parts = parse_version_parts(candidate);
let max_len = current_parts.len().max(candidate_parts.len());
for idx in 0..max_len {
let cur = *current_parts.get(idx).unwrap_or(&0);
let cand = *candidate_parts.get(idx).unwrap_or(&0);
if cand > cur {
return true;
}
if cand < cur {
return false;
}
}
false
}
fn parse_published_at_epoch(published_at: &str) -> Option<i64> {
let parsed: DateTime<Utc> = DateTime::parse_from_rfc3339(published_at)
.ok()?
.with_timezone(&Utc);
Some(parsed.timestamp())
}
fn is_old_enough(epoch_seconds: i64) -> bool {
let published = DateTime::<Utc>::from_timestamp(epoch_seconds, 0);
let Some(published) = published else {
return false;
};
let age = Utc::now() - published;
age >= ChronoDuration::hours(UPDATE_MIN_AGE_HOURS)
}
fn to_release_info(release: GithubRelease) -> Option<ReleaseInfo> {
let epoch = parse_published_at_epoch(&release.published_at)?;
let version = normalize_version_tag(&release.tag_name);
let old_enough = is_old_enough(epoch);
Some(ReleaseInfo {
version,
tag_name: release.tag_name,
name: release.name,
published_at: release.published_at,
published_at_epoch: epoch,
body: release.body,
html_url: release.html_url,
is_old_enough: old_enough,
})
}
async fn fetch_releases(client: &Client) -> Result<Vec<GithubRelease>, String> {
let response = client
.get(GITHUB_RELEASES_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?;
if !response.status().is_success() {
return Err(format!("Release fetch returned HTTP {}", response.status()));
}
response
.json::<Vec<GithubRelease>>()
.await
.map_err(|e| format!("Failed to parse releases JSON: {}", e))
}
async fn fetch_release_by_tag(client: &Client, tag: &str) -> Result<GithubRelease, String> {
let url = format!("{}/tags/{}", GITHUB_RELEASES_URL, tag);
let response = client
.get(url)
.send()
.await
.map_err(|e| format!("Failed to fetch release by tag: {}", e))?;
if !response.status().is_success() {
return Err(format!(
"Release-by-tag fetch returned HTTP {}",
response.status()
));
}
response
.json::<GithubRelease>()
.await
.map_err(|e| format!("Failed to parse release-by-tag JSON: {}", e))
}
fn select_latest_valid_update(
current_version: &str,
releases: Vec<GithubRelease>,
state: &UpdatesState,
) -> Option<ReleaseInfo> {
for release in releases {
if release.draft || release.prerelease {
continue;
}
let Some(info) = to_release_info(release) else {
continue;
};
if !info.is_old_enough {
continue;
}
if !is_newer_version(current_version, &info.version) {
continue;
}
let is_blocked = state.with_store(|store| {
store.is_release_acknowledged(&info.version) || store.is_release_ignored(&info.version)
});
if is_blocked {
continue;
}
return Some(info);
}
None
}
// ==================== Tauri commands ====================
#[tauri::command]
pub fn get_update_preferences(state: tauri::State<UpdatesState>) -> Result<UpdatePreferences, String> {
state.with_store(|store| store.get_preferences())
}
#[tauri::command]
pub fn set_update_check_on_launch(
enabled: bool,
state: tauri::State<UpdatesState>,
) -> Result<(), String> {
state.with_store(|store| store.set_check_on_launch(enabled))
}
#[tauri::command]
pub fn set_show_whats_new_on_launch(
enabled: bool,
state: tauri::State<UpdatesState>,
) -> Result<(), String> {
state.with_store(|store| store.set_show_whats_new_on_launch(enabled))
}
#[tauri::command]
pub fn acknowledge_release(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
state.with_store(|store| store.acknowledge_release(&version))
}
#[tauri::command]
pub fn ignore_release(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
state.with_store(|store| store.ignore_release(&version))
}
#[tauri::command]
pub fn is_release_acknowledged(version: String, state: tauri::State<UpdatesState>) -> bool {
state.with_store(|store| store.is_release_acknowledged(&version))
}
#[tauri::command]
pub fn is_release_ignored(version: String, state: tauri::State<UpdatesState>) -> bool {
state.with_store(|store| store.is_release_ignored(&version))
}
#[tauri::command]
pub fn has_whats_new_been_shown(version: String, state: tauri::State<UpdatesState>) -> bool {
state.with_store(|store| store.has_whats_new_been_shown(&version))
}
#[tauri::command]
pub fn mark_whats_new_shown(version: String, state: tauri::State<UpdatesState>) -> Result<(), String> {
state.with_store(|store| store.mark_whats_new_shown(&version))
}
#[tauri::command]
pub fn get_current_version(state: tauri::State<UpdatesState>) -> String {
state.current_version()
}
#[tauri::command]
pub async fn check_for_updates(
mode: String,
state: tauri::State<'_, UpdatesState>,
) -> Result<UpdateCheckResult, String> {
let current_version = state.current_version();
if mode == "launch" {
let prefs = match state.with_store(|store| store.get_preferences()) {
Ok(p) => p,
Err(e) => {
warn!("[Updates] Failed to read preferences: {}", e);
return Ok(UpdateCheckResult::no_updates(current_version));
}
};
if !prefs.check_on_launch {
info!("[Updates] Skipping launch check (disabled in preferences)");
return Ok(UpdateCheckResult::no_updates(current_version));
}
}
let releases = match fetch_releases(&state.client).await {
Ok(r) => r,
Err(e) => {
// Silent failure: log and return "no updates"
warn!("[Updates] Release fetch failed: {}", e);
return Ok(UpdateCheckResult::no_updates(current_version));
}
};
let maybe_update = select_latest_valid_update(&current_version, releases, &state);
match maybe_update {
Some(release) => Ok(UpdateCheckResult {
status: UpdateCheckStatus::UpdateAvailable,
current_version,
release: Some(release),
}),
None => Ok(UpdateCheckResult::no_updates(current_version)),
}
}
#[tauri::command]
pub async fn fetch_release_for_version(
version: String,
state: tauri::State<'_, UpdatesState>,
) -> Result<Option<ReleaseInfo>, String> {
// Prefer exact tag lookup to avoid scanning all releases for the current version.
let tag = if version.starts_with('v') {
version.clone()
} else {
format!("v{}", version)
};
let release = match fetch_release_by_tag(&state.client, &tag).await {
Ok(r) => r,
Err(e) => {
warn!("[Updates] Release-by-tag fetch failed for {}: {}", tag, e);
return Ok(None);
}
};
if release.draft || release.prerelease {
return Ok(None);
}
Ok(to_release_info(release))
}
+1 -1
View File
@@ -494,7 +494,7 @@ select {
.btn-primary {
background: var(--accent-primary);
color: var(--bg-primary);
color: var(--text-primary);
}
.btn-primary:hover:not(:disabled) {
+9
View File
@@ -123,9 +123,18 @@
<div class="attribution">
<strong>Lucide</strong> — Icon library (ISC)
</div>
<div class="attribution">
<strong>MusicBrainz</strong> — Music metadata database
</div>
<div class="attribution">
<strong>Song.link/Odesli</strong> — Music link aggregation API
</div>
<div class="attribution">
<strong>LRCLIB</strong> — Synced lyrics provider
</div>
<div class="attribution">
<strong>lyrics.ovh</strong> — Lyrics API
</div>
</div>
</div>
+74 -19
View File
@@ -12,6 +12,13 @@
Link,
RefreshCw
} from 'lucide-svelte';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeGlobal,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Props {
onPlayNext?: () => void;
@@ -53,7 +60,11 @@
let submenuStyle = $state('');
let downloadSubmenuStyle = $state('');
let portalTarget: HTMLElement | null = null;
const menuId = Symbol('album-menu');
let isHoveringMenu = $state(false);
// Unique menu ID for global store
let menuIdCounter = 0;
const menuId = `album-menu-${++menuIdCounter}-${Date.now()}`;
const hasQueue = $derived(!!(onPlayNext || onPlayLater));
const hasLibrary = $derived(!!onAddToPlaylist);
@@ -66,6 +77,16 @@
shareOpen = false;
downloadOpen = false;
onOpenChange?.(false);
closeGlobalMenu(menuId);
}
function doOpenMenu() {
openGlobalMenu(menuId);
isOpen = true;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(true);
setMenuPosition();
}
function handleClickOutside(event: MouseEvent) {
@@ -79,16 +100,17 @@
onMount(() => {
portalTarget = document.body;
const handleOtherOpen = (event: Event) => {
const detail = (event as CustomEvent<symbol>).detail;
if (detail !== menuId && isOpen) {
closeMenu();
// Subscribe to global floating menu store
const unsubscribe = subscribeGlobal(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== menuId && isOpen) {
isOpen = false;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(false);
}
};
window.addEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
return () => {
window.removeEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
};
});
return unsubscribe;
});
async function setMenuPosition(retries = 2) {
@@ -210,10 +232,40 @@
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll, true);
// Auto-close after inactivity
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHoveringMenu) closeMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
const cancelIdleClose = () => {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
};
if (!isHoveringMenu) scheduleIdleClose();
const onAnyActivity = () => {
if (!isHoveringMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onAnyActivity, true);
window.addEventListener('wheel', onAnyActivity, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('pointermove', onAnyActivity, true);
window.removeEventListener('wheel', onAnyActivity, true);
cancelIdleClose();
};
}
});
@@ -231,14 +283,10 @@
bind:this={triggerRef}
onclick={(e) => {
e.stopPropagation();
const nextOpen = !isOpen;
isOpen = nextOpen;
shareOpen = false;
downloadOpen = false;
onOpenChange?.(nextOpen);
if (nextOpen) {
window.dispatchEvent(new CustomEvent('qbz-album-menu-open', { detail: menuId }));
setMenuPosition();
if (isOpen) {
closeMenu();
} else {
doOpenMenu();
}
}}
aria-label="Album actions"
@@ -248,7 +296,14 @@
{#if isOpen && portalTarget}
<Portal target={portalTarget}>
<div class="menu" bind:this={menuEl} style={menuStyle} onmousedown={(e) => e.stopPropagation()}>
<div
class="menu"
bind:this={menuEl}
style={menuStyle}
onmousedown={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringMenu = true}
onmouseleave={() => isHoveringMenu = false}
>
{#if hasQueue}
{#if onPlayNext}
<button class="menu-item" onclick={() => handleAction(onPlayNext)}>
+67 -6
View File
@@ -1,5 +1,12 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Props {
value: string;
@@ -13,37 +20,91 @@
let { value, options, onchange, wide = false, expandLeft = false, compact = false }: Props = $props();
let isOpen = $state(false);
let isHovering = $state(false);
let dropdownRef: HTMLDivElement;
// Unique ID for this dropdown instance
const menuId = `dropdown-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
function handleClickOutside(event: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
closeDropdown();
}
}
function openDropdown() {
openGlobalMenu(menuId);
isOpen = true;
}
function closeDropdown() {
isOpen = false;
closeGlobalMenu(menuId);
}
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== menuId && isOpen) {
isOpen = false;
}
});
return unsubscribe;
});
$effect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
// Inactivity timeout
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHovering) closeDropdown();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHovering) scheduleIdleClose();
const onActivity = () => {
if (!isHovering) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
</script>
<div class="dropdown" class:wide bind:this={dropdownRef}>
<button class="trigger" onclick={() => (isOpen = !isOpen)}>
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()}>
<span class="value-text">{value}</span>
<ChevronDown size={16} class="chevron" />
</button>
{#if isOpen}
<div class="menu" class:expand-left={expandLeft} class:compact>
<div
class="menu"
class:expand-left={expandLeft}
class:compact
onmouseenter={() => isHovering = true}
onmouseleave={() => isHovering = false}
>
{#each options as option}
<button
class="option"
class:selected={option === value}
onclick={() => {
onchange(option);
isOpen = false;
closeDropdown();
}}
title={option}
>
@@ -170,7 +231,7 @@
background-color: rgba(66, 133, 244, 0.15);
color: var(--text-primary);
}
[data-theme="light"] .option.selected,
[data-theme="warm"] .option.selected {
background-color: rgba(var(--accent-primary), 0.15);
@@ -365,10 +365,10 @@
{scanning ? $t('library.scanning') : $t('library.scanThisFolder')}
</button>
<div class="footer-spacer"></div>
<button class="btn-secondary" onclick={onClose} disabled={loading}>
<button class="btn btn-secondary" onclick={onClose} disabled={loading}>
{$t('actions.cancel')}
</button>
<button class="btn-primary" onclick={handleSave} disabled={loading}>
<button class="btn btn-primary" onclick={handleSave} disabled={loading}>
{loading ? $t('actions.saving') : $t('actions.save')}
</button>
</div>
@@ -3,8 +3,6 @@
import { X, CloudOff } from 'lucide-svelte';
import { showToast } from '$lib/stores/toastStore';
import { t } from '$lib/i18n';
import QobuzLegalNotice from '$lib/components/QobuzLegalNotice.svelte';
import { qobuzTosAccepted } from '$lib/stores/qobuzLegalStore';
import {
subscribe as subscribeOffline,
isOffline as checkIsOffline
@@ -70,7 +68,7 @@
const detectedProvider = $derived(detectProvider(url));
const activeProvider = $derived(lockedProvider ?? detectedProvider);
const isValid = $derived(!!detectedProvider && !isOffline && $qobuzTosAccepted);
const isValid = $derived(!!detectedProvider && !isOffline);
$effect(() => {
if (isOpen) {
@@ -213,8 +211,6 @@
</div>
<div class="modal-body">
<QobuzLegalNotice checkboxDisabled={loading} />
{#if isOffline}
<div class="offline-warning" role="alert" aria-live="polite">
<CloudOff size={16} aria-hidden="true" />
@@ -280,8 +276,8 @@
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick={onClose} disabled={loading}>Close</button>
<button class="btn-primary" onclick={handleImport} disabled={!isValid || loading || importCompleted}>
<button class="btn btn-secondary" onclick={onClose} disabled={loading}>Close</button>
<button class="btn btn-primary" onclick={handleImport} disabled={!isValid || loading || importCompleted}>
{#if loading}
Importing...
{:else}
+2 -2
View File
@@ -577,10 +577,10 @@
</div>
{/if}
<div class="footer-right">
<button class="btn-secondary" onclick={onClose} disabled={loading}>
<button class="btn btn-secondary" onclick={onClose} disabled={loading}>
Cancel
</button>
<button class="btn-primary" onclick={handleSubmit} disabled={loading}>
<button class="btn btn-primary" onclick={handleSubmit} disabled={loading}>
{#if loading}
Saving...
{:else if mode === 'create'}
+143 -3
View File
@@ -24,6 +24,13 @@
movePlaylistToFolder,
type PlaylistFolder
} from '$lib/stores/playlistFoldersStore';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Playlist {
id: number;
@@ -137,6 +144,7 @@
});
function showFolderPopover(event: MouseEvent, folder: PlaylistFolder) {
openGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
folderPopover = {
visible: true,
@@ -149,6 +157,7 @@
function closeFolderPopover() {
folderPopover = { ...folderPopover, visible: false, folderId: null };
closeGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
}
// Filtered folders for context menu
@@ -175,6 +184,14 @@
let submenuEl: HTMLDivElement | null = null;
let menuStyle = $state('');
let submenuStyle = $state('');
let isHoveringDropdown = $state(false);
let isHoveringContextMenu = $state(false);
let isHoveringFolderPopover = $state(false);
// Unique IDs for global floating menu store
const SIDEBAR_DROPDOWN_ID = 'sidebar-dropdown';
const SIDEBAR_CONTEXT_MENU_ID = 'sidebar-context-menu';
const SIDEBAR_FOLDER_POPOVER_ID = 'sidebar-folder-popover';
function openSubmenu() {
if (submenuCloseTimeout) {
@@ -420,6 +437,7 @@
clearTimeout(submenuCloseTimeout);
submenuCloseTimeout = null;
}
closeGlobalMenu(SIDEBAR_DROPDOWN_ID);
}
function handleClickOutside(event: MouseEvent) {
@@ -481,6 +499,7 @@
if (menuOpen) {
closeMenu();
} else {
openGlobalMenu(SIDEBAR_DROPDOWN_ID);
menuOpen = true;
await setMenuPosition();
document.addEventListener('click', handleClickOutside);
@@ -540,6 +559,114 @@
// Check if any playlists exist in root (no folder)
let rootPlaylists = $derived(getPlaylistsInFolder(null));
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
// Close dropdown if another menu is active
if (activeId !== null && activeId !== SIDEBAR_DROPDOWN_ID && menuOpen) {
menuOpen = false;
sortSubmenuOpen = false;
if (submenuCloseTimeout) {
clearTimeout(submenuCloseTimeout);
submenuCloseTimeout = null;
}
document.removeEventListener('click', handleClickOutside);
}
// Close context menu if another menu is active
if (activeId !== null && activeId !== SIDEBAR_CONTEXT_MENU_ID && contextMenu.visible) {
contextMenu = { ...contextMenu, visible: false };
contextMenuSearch = '';
}
// Close folder popover if another menu is active
if (activeId !== null && activeId !== SIDEBAR_FOLDER_POPOVER_ID && folderPopover.visible) {
folderPopover = { ...folderPopover, visible: false, folderId: null };
}
});
return unsubscribe;
});
// Inactivity timeout for dropdown menu
$effect(() => {
if (menuOpen) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (menuOpen && !isHoveringDropdown) closeMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringDropdown) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringDropdown) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for context menu
$effect(() => {
if (contextMenu.visible) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (contextMenu.visible && !isHoveringContextMenu) closeContextMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringContextMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringContextMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for folder popover
$effect(() => {
if (folderPopover.visible) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (folderPopover.visible && !isHoveringFolderPopover) closeFolderPopover();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringFolderPopover) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringFolderPopover) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
$effect(() => {
if (!menuOpen) {
document.removeEventListener('click', handleClickOutside);
@@ -680,6 +807,8 @@
e.preventDefault();
e.stopPropagation();
openGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
// Get the current folder_id from settings
const settings = playlistSettings.get(playlist.id);
const currentFolderId = folderId ?? settings?.folder_id ?? null;
@@ -696,6 +825,7 @@
function closeContextMenu() {
contextMenu = { ...contextMenu, visible: false };
contextMenuSearch = '';
closeGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
}
async function handleMoveToFolder(folderId: string | null) {
@@ -812,7 +942,13 @@
<!-- Dropdown Menu -->
{#if menuOpen}
<div class="dropdown-menu" bind:this={menuEl} style={menuStyle}>
<div
class="dropdown-menu"
bind:this={menuEl}
style={menuStyle}
onmouseenter={() => isHoveringDropdown = true}
onmouseleave={() => isHoveringDropdown = false}
>
<!-- Sort by submenu trigger -->
<div
class="menu-item has-submenu"
@@ -1038,6 +1174,8 @@
class:has-search={showSearch}
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
onclick={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringContextMenu = true}
onmouseleave={() => isHoveringContextMenu = false}
role="menu"
>
{#if availableFolders.length > 0}
@@ -1095,6 +1233,8 @@
class="folder-popover"
style="left: {folderPopover.x}px; top: {folderPopover.y}px;"
onclick={(e) => e.stopPropagation()}
onmouseenter={() => isHoveringFolderPopover = true}
onmouseleave={() => isHoveringFolderPopover = false}
role="menu"
>
<div class="folder-popover-header">
@@ -1140,11 +1280,11 @@
/>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick={cancelCreateFolder}>
<button class="btn btn-secondary" onclick={cancelCreateFolder}>
{$t('actions.cancel')}
</button>
<button
class="btn-primary"
class="btn btn-primary"
onclick={handleCreateFolder}
disabled={!newFolderName.trim()}
>
+2 -1
View File
@@ -25,6 +25,7 @@
setActiveTrackMenuId,
subscribeActiveTrackMenuId
} from '$lib/stores/activeTrackMenu';
import { MENU_INACTIVITY_TIMEOUT } from '$lib/stores/floatingMenuStore';
interface Props {
onPlayNow?: () => void;
@@ -265,7 +266,7 @@
// Auto-close after inactivity when the cursor is not hovering any menu/submenu.
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const idleMs = 5_000;
const idleMs = MENU_INACTIVITY_TIMEOUT;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
@@ -0,0 +1,110 @@
<script lang="ts">
import Modal from '../Modal.svelte';
interface Props {
isOpen: boolean;
currentVersion: string;
newVersion: string;
onClose: () => void;
onVisitReleasePage: () => void;
}
let { isOpen, currentVersion, newVersion, onClose, onVisitReleasePage }: Props = $props();
function handleVisit(): void {
onVisitReleasePage();
}
</script>
<Modal {isOpen} onClose={onClose} title="New release available" maxWidth="560px">
<div class="update-modal">
<p class="lead">A new version of QBZ has been released</p>
<div class="version-row" aria-label="Version change">
<span class="version-chip">v{currentVersion}</span>
<span class="arrow">→</span>
<span class="version-chip new">v{newVersion}</span>
</div>
<button class="download-btn" onclick={handleVisit} type="button">
Download on GitHub
</button>
</div>
{#snippet footer()}
<div class="footer-actions">
<button class="btn btn-ghost" type="button" onclick={onClose}>Close</button>
<button class="btn btn-primary" type="button" onclick={handleVisit}>Visit release page</button>
</div>
{/snippet}
</Modal>
<style>
.update-modal {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
padding: 8px 0;
}
.lead {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 500;
}
.version-row {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.version-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 12px;
border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color, var(--bg-tertiary));
color: var(--text-primary);
font-weight: 600;
}
.version-chip.new {
background: color-mix(in srgb, var(--accent-primary) 16%, var(--bg-tertiary));
border-color: color-mix(in srgb, var(--accent-primary) 40%, var(--bg-tertiary));
}
.arrow {
color: var(--text-muted);
font-size: 18px;
}
.download-btn {
margin-top: 8px;
border: none;
background: transparent;
color: var(--accent-primary);
font-weight: 600;
cursor: pointer;
padding: 8px 10px;
border-radius: 8px;
}
.download-btn:hover {
background: var(--bg-hover);
}
.footer-actions {
display: flex;
gap: 8px;
width: 100%;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,57 @@
<script lang="ts">
import Modal from '../Modal.svelte';
import type { UpdateCheckStatus } from '$lib/stores/updatesStore';
interface Props {
isOpen: boolean;
status: UpdateCheckStatus;
newVersion: string;
onClose: () => void;
onVisitReleasePage: () => void;
}
let { isOpen, status, newVersion, onClose, onVisitReleasePage }: Props = $props();
</script>
<Modal {isOpen} onClose={onClose} title="Check for updates" maxWidth="460px">
<div class="result-body">
{#if status === 'update_available'}
<p class="message">New version available: v{newVersion}</p>
{:else}
<p class="message">No updates found.</p>
{/if}
</div>
{#snippet footer()}
<div class="footer-actions">
<button class="btn btn-ghost" type="button" onclick={onClose}>Close</button>
{#if status === 'update_available'}
<button class="btn btn-primary" type="button" onclick={onVisitReleasePage}>Visit release page</button>
{/if}
</div>
{/snippet}
</Modal>
<style>
.result-body {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 12px 0;
}
.message {
margin: 0;
color: var(--text-primary);
font-weight: 500;
}
.footer-actions {
display: flex;
width: 100%;
justify-content: flex-end;
gap: 8px;
}
</style>
@@ -0,0 +1,90 @@
<script lang="ts">
import Modal from '../Modal.svelte';
type ReminderChoice = 'later' | 'ignore_release' | 'disable_all';
interface Props {
isOpen: boolean;
onClose: () => void;
onRemindLater: () => void;
onIgnoreRelease: () => void;
onDisableAllUpdates: () => void;
}
let { isOpen, onClose, onRemindLater, onIgnoreRelease, onDisableAllUpdates }: Props = $props();
let choice = $state<ReminderChoice>('later');
function handleSubmit(): void {
if (choice === 'ignore_release') {
onIgnoreRelease();
} else if (choice === 'disable_all') {
onDisableAllUpdates();
} else {
onRemindLater();
}
onClose();
}
</script>
<Modal {isOpen} onClose={onClose} title="New version reminder" maxWidth="600px">
<div class="reminder-modal">
<label class="option">
<input type="radio" name="reminder" bind:group={choice} value="later" />
<span>Remind me later</span>
</label>
<label class="option">
<input type="radio" name="reminder" bind:group={choice} value="ignore_release" />
<span>Do not notify me again about this release</span>
</label>
<label class="option">
<input type="radio" name="reminder" bind:group={choice} value="disable_all" />
<span>Do not notify me about new releases</span>
</label>
<p class="hint">Can be re-enabled from Settings</p>
</div>
{#snippet footer()}
<div class="footer-actions">
<button class="btn btn-primary" type="button" onclick={handleSubmit}>OK, close</button>
</div>
{/snippet}
</Modal>
<style>
.reminder-modal {
display: flex;
flex-direction: column;
gap: 12px;
}
.option {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
font-size: 14px;
}
.option input[type='radio'] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
}
.hint {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 13px;
}
.footer-actions {
display: flex;
width: 100%;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,169 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Modal from '../Modal.svelte';
import type { ReleaseInfo } from '$lib/stores/updatesStore';
import {
formatReleaseDate,
renderMarkdownWithToc,
} from '$lib/utils/markdown';
interface Props {
isOpen: boolean;
release: ReleaseInfo | null;
showTitleBar: boolean;
onClose: () => void;
}
let { isOpen, release, showTitleBar, onClose }: Props = $props();
const PLAYER_HEIGHT = 104;
const TITLEBAR_HEIGHT = $derived(showTitleBar ? 32 : 0);
const INNER_MARGIN = 20;
const EXTRA_PAD_TOP = 40;
const EXTRA_PAD_BOTTOM = 130;
const MAX_MODAL_OFFSET_PX = 145;
let maxHeightPx = $state(720);
function computeMaxHeight(): void {
if (typeof window === 'undefined') return;
// Per requirement: modal max height = window height - 145px.
const available = window.innerHeight - MAX_MODAL_OFFSET_PX;
maxHeightPx = Math.max(360, available);
}
onMount(() => {
computeMaxHeight();
const handler = () => computeMaxHeight();
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
});
onDestroy(() => {
// No-op, but keeps lifecycle explicit in this component.
});
const releaseDate = $derived(release ? formatReleaseDate(release.publishedAt) : '');
const markdownResult = $derived.by(() => renderMarkdownWithToc(release?.body));
const releaseHtml = $derived(markdownResult.html);
const modalTitle = $derived(
release
? `What's new in v${release.version}, ${releaseDate}`
: "What's new"
);
</script>
<div
class="whats-new-modal"
style={`
--updates-modal-max-height: ${maxHeightPx - 150}px;
--updates-overlay-pad-top: ${TITLEBAR_HEIGHT + INNER_MARGIN + EXTRA_PAD_TOP}px;
--updates-overlay-pad-bottom: ${PLAYER_HEIGHT + INNER_MARGIN + EXTRA_PAD_BOTTOM}px;
`}
>
<Modal
{isOpen}
onClose={onClose}
title={modalTitle}
maxWidth="910px"
>
{#if release}
{#if releaseHtml}
<div class="release-body whats-new-content">
{@html releaseHtml}
</div>
{:else}
<p class="empty">Release notes are not available for this version.</p>
{/if}
{:else}
<p class="empty">Release notes are not available.</p>
{/if}
{#snippet footer()}
<div class="footer-actions">
<button class="btn btn-primary" type="button" onclick={onClose}>Close</button>
</div>
{/snippet}
</Modal>
</div>
<style>
:global(.whats-new-modal .modal) {
max-height: var(--updates-modal-max-height);
}
:global(.whats-new-modal .modal-overlay) {
padding-top: var(--updates-overlay-pad-top);
padding-bottom: var(--updates-overlay-pad-bottom);
}
.release-body {
color: var(--text-primary);
}
/* Whats New content styling - using !important to override any conflicting styles */
.whats-new-content :global(p) {
margin: 0 0 12px !important;
line-height: 1.6 !important;
}
.whats-new-content :global(ul) {
margin: 6px 0 14px 10px !important;
padding: 0 0 0 24px !important;
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
list-style-type: disc !important;
list-style-position: outside !important;
}
/* First level list items - brighter color */
.whats-new-content :global(li) {
line-height: 1.6 !important;
color: var(--text-secondary) !important;
font-size: 12px !important;
font-weight: 400 !important;
}
/* Nested lists - extra indentation */
.whats-new-content :global(ul ul) {
margin: 6px 0 8px 0 !important;
padding-left: 24px !important;
}
/* Nested list items - darker/muted color */
.whats-new-content :global(ul ul li) {
color: var(--text-muted) !important;
font-size: 11px !important;
}
/* Section headings */
.whats-new-content :global(h3),
.whats-new-content :global(.wn-section) {
margin: 16px 0 8px !important;
font-weight: 700 !important;
font-size: 14px !important;
letter-spacing: 0.2px !important;
color: var(--text-primary) !important;
}
.whats-new-content :global(code) {
background: var(--bg-tertiary) !important;
border: 1px solid var(--bg-hover) !important;
padding: 1px 6px !important;
border-radius: 6px !important;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace !important;
font-size: 12px !important;
}
.empty {
color: var(--text-muted);
margin: 0;
}
.footer-actions {
display: flex;
width: 100%;
justify-content: flex-end;
}
</style>
@@ -24,6 +24,13 @@
movePlaylistToFolder,
type PlaylistFolder
} from '$lib/stores/playlistFoldersStore';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface Playlist {
id: number;
@@ -91,6 +98,12 @@
// Dropdown state
let showFilterMenu = $state(false);
let showSortMenu = $state(false);
let isHoveringFilterMenu = $state(false);
let isHoveringSortMenu = $state(false);
// Unique IDs for global floating menu store
const PM_FILTER_MENU_ID = 'playlist-manager-filter';
const PM_SORT_MENU_ID = 'playlist-manager-sort';
// Edit modal state
let editModalOpen = $state(false);
@@ -117,6 +130,85 @@
$effect(() => { localStorage.setItem('qbz-pm-sort', sort); });
$effect(() => { localStorage.setItem('qbz-pm-view', viewMode); });
// Helper functions for closing menus with global store
function closeFilterMenu() {
showFilterMenu = false;
closeGlobalMenu(PM_FILTER_MENU_ID);
}
function closeSortMenu() {
showSortMenu = false;
closeGlobalMenu(PM_SORT_MENU_ID);
}
// Subscribe to global floating menu store
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== PM_FILTER_MENU_ID && showFilterMenu) {
showFilterMenu = false;
}
if (activeId !== null && activeId !== PM_SORT_MENU_ID && showSortMenu) {
showSortMenu = false;
}
});
return unsubscribe;
});
// Inactivity timeout for filter menu
$effect(() => {
if (showFilterMenu) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (showFilterMenu && !isHoveringFilterMenu) closeFilterMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringFilterMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringFilterMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Inactivity timeout for sort menu
$effect(() => {
if (showSortMenu) {
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (showSortMenu && !isHoveringSortMenu) closeSortMenu();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHoveringSortMenu) scheduleIdleClose();
const onActivity = () => {
if (!isHoveringSortMenu) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
// Helper to get local content status for a playlist (calculated from actual data)
function getLocalContentStatus(playlistId: number): LocalContentStatus {
const playlist = playlists.find(p => p.id === playlistId);
@@ -699,7 +791,16 @@
<!-- Filter dropdown -->
<div class="dropdown-container">
<button class="control-btn" onclick={() => { showFilterMenu = !showFilterMenu; showSortMenu = false; }}>
<button class="control-btn" onclick={() => {
if (showFilterMenu) {
showFilterMenu = false;
closeGlobalMenu(PM_FILTER_MENU_ID);
} else {
showSortMenu = false;
openGlobalMenu(PM_FILTER_MENU_ID);
showFilterMenu = true;
}
}}>
{#if filter === 'hidden'}
<EyeOff size={16} />
{:else if filter === 'offline_unavailable'}
@@ -716,26 +817,30 @@
</span>
</button>
{#if showFilterMenu}
<div class="dropdown-menu">
<div
class="dropdown-menu"
onmouseenter={() => isHoveringFilterMenu = true}
onmouseleave={() => isHoveringFilterMenu = false}
>
{#if offlineStatus.isOffline}
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; closeFilterMenu(); }}>
{$t('offline.available')}
</button>
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; closeFilterMenu(); }}>
{$t('offline.partiallyAvailable')}
</button>
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; closeFilterMenu(); }}>
{$t('offline.notAvailableOffline')}
</button>
<div class="dropdown-divider"></div>
{/if}
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; closeFilterMenu(); }}>
{offlineStatus.isOffline ? $t('filter.all') : 'All'}
</button>
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; closeFilterMenu(); }}>
Visible
</button>
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; showFilterMenu = false; }}>
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; closeFilterMenu(); }}>
Hidden
</button>
</div>
@@ -744,27 +849,40 @@
<!-- Sort dropdown -->
<div class="dropdown-container">
<button class="control-btn" onclick={() => { showSortMenu = !showSortMenu; showFilterMenu = false; }}>
<button class="control-btn" onclick={() => {
if (showSortMenu) {
showSortMenu = false;
closeGlobalMenu(PM_SORT_MENU_ID);
} else {
showFilterMenu = false;
openGlobalMenu(PM_SORT_MENU_ID);
showSortMenu = true;
}
}}>
<ArrowUpDown size={16} />
<span>
{sort === 'name' ? 'Name' : sort === 'recent' ? 'Recent' : sort === 'playcount' ? 'Play Count' : sort === 'tracks' ? 'Track Count' : 'Custom'}
</span>
</button>
{#if showSortMenu}
<div class="dropdown-menu">
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; showSortMenu = false; }}>
<div
class="dropdown-menu"
onmouseenter={() => isHoveringSortMenu = true}
onmouseleave={() => isHoveringSortMenu = false}
>
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; closeSortMenu(); }}>
Name (A-Z)
</button>
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; closeSortMenu(); }}>
Recent
</button>
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; closeSortMenu(); }}>
Play Count
</button>
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; closeSortMenu(); }}>
Track Count
</button>
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; showSortMenu = false; }}>
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; closeSortMenu(); }}>
Custom Order
</button>
</div>
+181 -1
View File
@@ -7,6 +7,8 @@
import Toggle from '../Toggle.svelte';
import Dropdown from '../Dropdown.svelte';
import VolumeSlider from '../VolumeSlider.svelte';
import UpdateCheckResultModal from '../updates/UpdateCheckResultModal.svelte';
import WhatsNewModal from '../updates/WhatsNewModal.svelte';
import {
getOfflineCacheStats,
clearOfflineCache,
@@ -56,6 +58,20 @@
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;
@@ -64,6 +80,7 @@
userEmail?: string;
subscription?: string;
subscriptionValidUntil?: string | null;
showTitleBar?: boolean;
}
interface CacheStats {
@@ -92,7 +109,8 @@
userName = 'User',
userEmail = '',
subscription = 'Qobuz™',
subscriptionValidUntil = null
subscriptionValidUntil = null,
showTitleBar = true
}: Props = $props();
// Cache state (memory cache)
@@ -123,6 +141,16 @@
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;
@@ -132,6 +160,7 @@
let downloadsSection: HTMLElement;
let librarySection: HTMLElement;
let integrationsSection: HTMLElement;
let updatesSection: HTMLElement;
let storageSection: HTMLElement;
let activeSection = $state('audio');
@@ -144,6 +173,7 @@
{ id: 'downloads', label: 'Offline Library' },
{ id: 'library', label: 'Library' },
{ id: 'integrations', label: 'Integrations' },
{ id: 'updates', label: 'Updates' },
{ id: 'storage', label: 'Storage' },
];
@@ -157,6 +187,7 @@
case 'downloads': return downloadsSection;
case 'library': return librarySection;
case 'integrations': return integrationsSection;
case 'updates': return updatesSection;
case 'storage': return storageSection;
default: return undefined;
}
@@ -169,6 +200,51 @@
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);
@@ -499,6 +575,13 @@
// Load tray settings
loadTraySettings();
// Initialize updates preferences/version state
initUpdatesStore();
const unsubscribeUpdates = subscribeUpdates(() => {
updatePreferences = getUpdatePreferences();
updatesCurrentVersion = getUpdatesCurrentVersion();
});
// Detect if running in Flatpak
loadFlatpakStatus();
@@ -544,6 +627,7 @@
unsubscribeOffline();
unsubscribeZoom();
unsubscribeTitleBar();
unsubscribeUpdates();
settingsViewEl?.removeEventListener('scroll', handleScroll);
};
});
@@ -2263,6 +2347,85 @@ flatpak override --user --filesystem=/home/USUARIO/Música com.blitzfc.qbz</pre>
</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" bind:this={storageSection}>
<h3 class="section-title">{$t('settings.storage.title')}</h3>
@@ -2762,6 +2925,23 @@ flatpak override --user --filesystem=$HOME/music-nas com.blitzfc.qbz</pre>
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);
+237
View File
@@ -0,0 +1,237 @@
import { openUrl } from '@tauri-apps/plugin-opener';
import {
acknowledgeRelease,
checkForUpdates,
fetchReleaseForVersion,
getCurrentVersion,
getPreferences,
hasWhatsNewBeenShown,
ignoreRelease,
initUpdatesStore,
markWhatsNewShown,
setCheckOnLaunch,
} from '$lib/stores/updatesStore';
import type { ReleaseInfo } from '$lib/stores/updatesStore';
const SESSION_ID_KEY = 'qbz-updates-session-id';
const SESSION_CHECK_DONE_KEY = 'qbz-updates-session-check-done';
const SESSION_UPDATE_SHOWN_KEY = 'qbz-updates-session-update-shown';
const SESSION_WHATS_NEW_SHOWN_KEY = 'qbz-updates-session-whats-new-shown';
const DEV_FAKE_ENABLED_KEY = 'qbz-updates-dev-fake-enabled';
const DEV_FAKE_VERSION_KEY = 'qbz-updates-dev-fake-version';
const DEV_FAKE_URL_KEY = 'qbz-updates-dev-fake-url';
const DEV_FAKE_BODY_KEY = 'qbz-updates-dev-fake-body';
let sessionId = '';
let launchFlowStarted = false;
function ensureSessionId(): string {
if (sessionId) return sessionId;
if (typeof window === 'undefined') {
sessionId = 'server';
return sessionId;
}
sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
localStorage.setItem(SESSION_ID_KEY, sessionId);
} catch {
// Ignore storage errors; we will still gate in memory.
}
return sessionId;
}
function sessionScopedKey(baseKey: string): string {
return `${baseKey}:${ensureSessionId()}`;
}
function getSessionFlag(baseKey: string): boolean {
if (typeof window === 'undefined') return false;
try {
return localStorage.getItem(sessionScopedKey(baseKey)) === '1';
} catch {
return false;
}
}
function setSessionFlag(baseKey: string): void {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(sessionScopedKey(baseKey), '1');
} catch {
// Ignore storage errors.
}
}
function parseVersionParts(version: string): number[] {
return version
.trim()
.replace(/^v/i, '')
.split('.')
.map((segment) => {
const numeric = segment.match(/\d+/)?.[0] ?? '0';
return Number.parseInt(numeric, 10) || 0;
});
}
function isNewerVersion(current: string, candidate: string): boolean {
const a = parseVersionParts(current);
const b = parseVersionParts(candidate);
const len = Math.max(a.length, b.length);
for (let i = 0; i < len; i += 1) {
const av = a[i] ?? 0;
const bv = b[i] ?? 0;
if (bv > av) return true;
if (bv < av) return false;
}
return false;
}
function getDevFakeRelease(currentVersion: string): ReleaseInfo | null {
if (typeof window === 'undefined') return null;
try {
const enabled = localStorage.getItem(DEV_FAKE_ENABLED_KEY) === '1';
if (!enabled) return null;
const version = (localStorage.getItem(DEV_FAKE_VERSION_KEY) || '').trim();
if (!version) return null;
if (!isNewerVersion(currentVersion, version)) return null;
const htmlUrl =
localStorage.getItem(DEV_FAKE_URL_KEY) ||
`https://github.com/vicrodh/qbz/releases/tag/v${version}`;
const body = localStorage.getItem(DEV_FAKE_BODY_KEY);
const publishedAtDate = new Date(Date.now() - 13 * 60 * 60 * 1000);
const publishedAt = publishedAtDate.toISOString();
return {
version,
tagName: version.startsWith('v') ? version : `v${version}`,
name: `v${version}`,
publishedAt,
publishedAtEpoch: Math.floor(publishedAtDate.getTime() / 1000),
body,
htmlUrl,
isOldEnough: true,
};
} catch {
return null;
}
}
export interface LaunchUpdateDecision {
currentVersion: string;
updateRelease: ReleaseInfo | null;
whatsNewRelease: ReleaseInfo | null;
}
/**
* Decide which modal (if any) should be shown on launch.
*
* Priority:
* 1) Update available modal
* 2) What's New modal
*/
export async function decideLaunchModals(): Promise<LaunchUpdateDecision> {
if (launchFlowStarted) {
return {
currentVersion: getCurrentVersion(),
updateRelease: null,
whatsNewRelease: null,
};
}
launchFlowStarted = true;
ensureSessionId();
await initUpdatesStore();
const prefs = getPreferences();
const currentVersion = getCurrentVersion();
if (getSessionFlag(SESSION_CHECK_DONE_KEY)) {
return { currentVersion, updateRelease: null, whatsNewRelease: null };
}
setSessionFlag(SESSION_CHECK_DONE_KEY);
// Phase 1: update available
if (prefs.checkOnLaunch) {
const devFake = getDevFakeRelease(currentVersion);
if (devFake) {
const devScopedKey = `${SESSION_UPDATE_SHOWN_KEY}:${devFake.version}`;
if (!getSessionFlag(devScopedKey)) {
setSessionFlag(devScopedKey);
return {
currentVersion,
updateRelease: devFake,
whatsNewRelease: null,
};
}
}
const result = await checkForUpdates('launch');
if (result.status === 'update_available' && result.release) {
const scopedUpdateKey = `${SESSION_UPDATE_SHOWN_KEY}:${result.release.version}`;
if (!getSessionFlag(scopedUpdateKey)) {
setSessionFlag(scopedUpdateKey);
return {
currentVersion,
updateRelease: result.release,
whatsNewRelease: null,
};
}
}
}
// Phase 2: What's New for current version
if (prefs.showWhatsNewOnLaunch && currentVersion) {
const alreadyShown = await hasWhatsNewBeenShown(currentVersion);
if (!alreadyShown) {
const scopedWhatsNewKey = `${SESSION_WHATS_NEW_SHOWN_KEY}:${currentVersion}`;
if (!getSessionFlag(scopedWhatsNewKey)) {
const release = await fetchReleaseForVersion(currentVersion);
if (release) {
setSessionFlag(scopedWhatsNewKey);
// Persist "shown once per version" at the time we decide to show it.
await markWhatsNewShown(currentVersion);
return {
currentVersion,
updateRelease: null,
whatsNewRelease: release,
};
}
}
}
}
return { currentVersion, updateRelease: null, whatsNewRelease: null };
}
export async function openReleasePageAndAcknowledge(release: ReleaseInfo): Promise<void> {
try {
await openUrl(release.htmlUrl);
} catch (error) {
console.debug('[Updates] Failed to open release URL:', error);
}
try {
await acknowledgeRelease(release.version);
} catch (error) {
console.debug('[Updates] Failed to acknowledge release:', release.version, error);
}
}
export async function ignoreReleaseVersion(version: string): Promise<void> {
try {
await ignoreRelease(version);
} catch (error) {
console.debug('[Updates] Failed to ignore release:', version, error);
}
}
export async function disableUpdateChecks(): Promise<void> {
try {
await setCheckOnLaunch(false);
} catch (error) {
console.debug('[Updates] Failed to disable update checks:', error);
}
}
+31
View File
@@ -1,9 +1,16 @@
import { openMenu as openGlobalMenu, closeMenu as closeGlobalMenu, subscribe as subscribeGlobal, getActiveMenuId } from './floatingMenuStore';
type Listener = (menuId: number | null) => void;
let activeMenuId: number | null = null;
const listeners = new Set<Listener>();
let nextMenuId = 1;
// Generate global menu ID for track menus
function getGlobalMenuId(localId: number): string {
return `track-menu-${localId}`;
}
export function allocateTrackMenuId(): number {
// Monotonic id to ensure only one TrackMenu instance matches the active id at a time.
return nextMenuId++;
@@ -15,7 +22,19 @@ export function getActiveTrackMenuId(): number | null {
export function setActiveTrackMenuId(menuId: number | null): void {
if (activeMenuId === menuId) return;
// Close previous in global store
if (activeMenuId !== null) {
closeGlobalMenu(getGlobalMenuId(activeMenuId));
}
activeMenuId = menuId;
// Open new in global store
if (menuId !== null) {
openGlobalMenu(getGlobalMenuId(menuId));
}
for (const listener of listeners) listener(activeMenuId);
}
@@ -26,3 +45,15 @@ export function subscribeActiveTrackMenuId(listener: Listener): () => void {
listeners.delete(listener);
};
}
// Subscribe to global store to close track menus when other menus open
subscribeGlobal(() => {
const globalActive = getActiveMenuId();
// If global active menu is not a track menu, close any open track menu
if (globalActive !== null && !globalActive.startsWith('track-menu-')) {
if (activeMenuId !== null) {
activeMenuId = null;
for (const listener of listeners) listener(null);
}
}
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Global Floating Menu Store
*
* Ensures only one floating menu can be open at a time across the entire app.
* All floating menus (context menus, dropdowns, popovers) should use this store.
*/
type Subscriber = () => void;
let activeMenuId: string | null = null;
const subscribers = new Set<Subscriber>();
function notify() {
subscribers.forEach(fn => fn());
}
/**
* Subscribe to store changes
*/
export function subscribe(fn: Subscriber): () => void {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
/**
* Get the currently active menu ID
*/
export function getActiveMenuId(): string | null {
return activeMenuId;
}
/**
* Check if a specific menu is currently active
*/
export function isMenuActive(menuId: string): boolean {
return activeMenuId === menuId;
}
/**
* Open a menu (closes any other open menu first)
*/
export function openMenu(menuId: string): void {
if (activeMenuId !== menuId) {
activeMenuId = menuId;
notify();
}
}
/**
* Close a specific menu (only if it's the active one)
*/
export function closeMenu(menuId: string): void {
if (activeMenuId === menuId) {
activeMenuId = null;
notify();
}
}
/**
* Close any open menu
*/
export function closeAll(): void {
if (activeMenuId !== null) {
activeMenuId = null;
notify();
}
}
/**
* Standard inactivity timeout for floating menus (in milliseconds)
*/
export const MENU_INACTIVITY_TIMEOUT = 2000;
+146
View File
@@ -0,0 +1,146 @@
import { invoke } from '@tauri-apps/api/core';
export interface UpdatePreferences {
checkOnLaunch: boolean;
showWhatsNewOnLaunch: boolean;
}
export interface ReleaseInfo {
version: string;
tagName: string;
name: string;
publishedAt: string;
publishedAtEpoch: number;
body: string | null;
htmlUrl: string;
isOldEnough: boolean;
}
export type UpdateCheckStatus = 'no_updates' | 'update_available';
export interface UpdateCheckResult {
status: UpdateCheckStatus;
currentVersion: string;
release: ReleaseInfo | null;
}
let preferences: UpdatePreferences = {
checkOnLaunch: true,
showWhatsNewOnLaunch: true,
};
let currentVersion = '';
let initialized = false;
const listeners = new Set<() => void>();
function notify(): void {
for (const listener of listeners) {
listener();
}
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
listener();
return () => listeners.delete(listener);
}
export function getPreferences(): UpdatePreferences {
return preferences;
}
export function getCurrentVersion(): string {
return currentVersion;
}
export async function initUpdatesStore(): Promise<void> {
if (initialized) return;
initialized = true;
try {
const [prefs, version] = await Promise.all([
invoke<UpdatePreferences>('get_update_preferences'),
invoke<string>('get_current_version'),
]);
preferences = prefs;
currentVersion = version;
notify();
} catch (error) {
console.debug('[Updates] Failed to initialize updates store:', error);
}
}
export async function refreshUpdatePreferences(): Promise<void> {
try {
preferences = await invoke<UpdatePreferences>('get_update_preferences');
notify();
} catch (error) {
console.debug('[Updates] Failed to refresh update preferences:', error);
}
}
export async function setCheckOnLaunch(enabled: boolean): Promise<void> {
try {
await invoke('set_update_check_on_launch', { enabled });
preferences.checkOnLaunch = enabled;
notify();
} catch (error) {
console.error('[Updates] Failed to set checkOnLaunch:', error);
throw error;
}
}
export async function setShowWhatsNewOnLaunch(enabled: boolean): Promise<void> {
try {
await invoke('set_show_whats_new_on_launch', { enabled });
preferences.showWhatsNewOnLaunch = enabled;
notify();
} catch (error) {
console.error('[Updates] Failed to set showWhatsNewOnLaunch:', error);
throw error;
}
}
export async function checkForUpdates(mode: 'launch' | 'manual'): Promise<UpdateCheckResult> {
const result = await invoke<UpdateCheckResult>('check_for_updates', { mode });
return {
...result,
release: result.release ?? null,
};
}
export async function fetchReleaseForVersion(version: string): Promise<ReleaseInfo | null> {
try {
const release = await invoke<ReleaseInfo | null>('fetch_release_for_version', { version });
return release ?? null;
} catch (error) {
console.debug('[Updates] Failed to fetch release for version:', version, error);
return null;
}
}
export async function acknowledgeRelease(version: string): Promise<void> {
await invoke('acknowledge_release', { version });
}
export async function ignoreRelease(version: string): Promise<void> {
await invoke('ignore_release', { version });
}
export async function hasWhatsNewBeenShown(version: string): Promise<boolean> {
try {
return await invoke<boolean>('has_whats_new_been_shown', { version });
} catch (error) {
console.debug('[Updates] Failed to check whats_new_shown:', version, error);
return false;
}
}
export async function markWhatsNewShown(version: string): Promise<void> {
try {
await invoke('mark_whats_new_shown', { version });
} catch (error) {
console.debug('[Updates] Failed to mark whats_new_shown:', version, error);
}
}
+156
View File
@@ -0,0 +1,156 @@
function escapeHtml(input: string): string {
return input
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderInline(text: string): string {
const escaped = escapeHtml(text);
// Minimal inline formatting: **bold** and `code`
const withBold = escaped.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
return withBold.replace(/`([^`]+)`/g, '<code>$1</code>');
}
function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[`*_~]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
function countLeadingSpaces(line: string): number {
let count = 0;
for (const ch of line) {
if (ch === ' ') count += 1;
else if (ch === '\t') count += 2;
else break;
}
return count;
}
export interface TocEntry {
id: string;
label: string;
level: number;
}
export interface MarkdownRenderResult {
html: string;
toc: TocEntry[];
}
/**
* Safe markdown renderer for release notes.
*
* This intentionally supports a small subset and escapes all HTML first,
* which makes it safe to inject with {@html ...}.
*/
export function renderMarkdownWithToc(markdown: string | null | undefined): MarkdownRenderResult {
if (!markdown) {
return { html: '', toc: [] };
}
const lines = markdown.split('\n');
const html: string[] = [];
const toc: TocEntry[] = [];
// Stack of list levels currently open (each entry is the level number)
const openLists: number[] = [];
const closeListsToLevel = (targetLevel: number) => {
while (openLists.length > targetLevel) {
html.push('</ul>');
openLists.pop();
}
};
const openListToLevel = (targetLevel: number) => {
while (openLists.length < targetLevel) {
html.push('<ul>');
openLists.push(openLists.length + 1);
}
};
const pushHeading = (label: string, level: number) => {
const cleanLabel = label.trim();
if (!cleanLabel) return;
const id = slugify(cleanLabel);
toc.push({ id, label: cleanLabel, level });
// Use h3 for section headings to keep hierarchy subtle but visible.
html.push(`<h3 id="${id}" class="wn-section">${renderInline(cleanLabel)}</h3>`);
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
closeListsToLevel(0);
continue;
}
// Headings (#, ##, ###)
if (trimmed.startsWith('# ')) {
closeListsToLevel(0);
const label = trimmed.slice(2);
pushHeading(label, 0);
continue;
}
if (trimmed.startsWith('## ')) {
closeListsToLevel(0);
const label = trimmed.slice(3);
pushHeading(label, 0);
continue;
}
if (trimmed.startsWith('### ')) {
closeListsToLevel(0);
const label = trimmed.slice(4);
pushHeading(label, 1);
continue;
}
// List items with indentation-based nesting
const isList = trimmed.startsWith('- ') || trimmed.startsWith('* ');
if (isList) {
const indentSpaces = countLeadingSpaces(line);
const level = Math.floor(indentSpaces / 2);
const content = trimmed.slice(2).trim();
if (level === 0) {
// Top-level bullets become section headings without bullets.
closeListsToLevel(0);
pushHeading(content, 0);
continue;
}
openListToLevel(level);
html.push(`<li>${renderInline(content)}</li>`);
continue;
}
closeListsToLevel(0);
html.push(`<p>${renderInline(trimmed)}</p>`);
}
closeListsToLevel(0);
return { html: html.join('\n'), toc };
}
export function renderMarkdownSafe(markdown: string | null | undefined): string {
return renderMarkdownWithToc(markdown).html;
}
export function formatReleaseDate(isoDate: string): string {
const date = new Date(isoDate);
if (Number.isNaN(date.getTime())) return isoDate;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(date);
}
+121 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, tick } from 'svelte';
import { invoke, convertFileSrc } from '@tauri-apps/api/core';
import { listen, emitTo, type UnlistenFn } from '@tauri-apps/api/event';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
@@ -298,6 +298,16 @@
import CastPicker from '$lib/components/CastPicker.svelte';
import LyricsSidebar from '$lib/components/lyrics/LyricsSidebar.svelte';
import OfflinePlaceholder from '$lib/components/OfflinePlaceholder.svelte';
import UpdateAvailableModal from '$lib/components/updates/UpdateAvailableModal.svelte';
import UpdateReminderModal from '$lib/components/updates/UpdateReminderModal.svelte';
import WhatsNewModal from '$lib/components/updates/WhatsNewModal.svelte';
import type { ReleaseInfo } from '$lib/stores/updatesStore';
import {
decideLaunchModals,
disableUpdateChecks,
ignoreReleaseVersion,
openReleasePageAndAcknowledge,
} from '$lib/services/updatesService';
// Offline state
import {
@@ -326,6 +336,14 @@
// View State (from navigationStore subscription)
let activeView = $state<ViewType>('home');
let selectedPlaylistId = $state<number | null>(null);
let updatesCurrentVersion = $state('');
let updateRelease = $state<ReleaseInfo | null>(null);
let whatsNewRelease = $state<ReleaseInfo | null>(null);
let isUpdateModalOpen = $state(false);
let isReminderModalOpen = $state(false);
let isWhatsNewModalOpen = $state(false);
let updatesLaunchTriggered = $state(false);
// Album, Artist and Label data are fetched, so kept local
let selectedAlbum = $state<AlbumDetail | null>(null);
let selectedArtist = $state<ArtistDetail | null>(null);
@@ -334,6 +352,80 @@
let musicianModalData = $state<ResolvedMusician | null>(null);
let isArtistAlbumsLoading = $state(false);
function waitForHomePaint(): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve();
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => resolve());
});
});
}
async function runLaunchUpdateFlow(): Promise<void> {
// Ensure the UI has rendered and Home is visible before showing any modal.
await tick();
await waitForHomePaint();
if (activeView !== 'home') return;
const decision = await decideLaunchModals();
updatesCurrentVersion = decision.currentVersion;
if (decision.updateRelease) {
updateRelease = decision.updateRelease;
isUpdateModalOpen = true;
return;
}
if (decision.whatsNewRelease) {
whatsNewRelease = decision.whatsNewRelease;
isWhatsNewModalOpen = true;
}
}
function handleUpdateVisit(): void {
if (!updateRelease) return;
void openReleasePageAndAcknowledge(updateRelease);
isUpdateModalOpen = false;
updateRelease = null;
}
function handleUpdateClose(): void {
isUpdateModalOpen = false;
if (updateRelease) {
isReminderModalOpen = true;
}
}
function handleReminderClose(): void {
isReminderModalOpen = false;
updateRelease = null;
}
function handleReminderLater(): void {
// No persistence by design.
}
function handleReminderIgnoreRelease(): void {
if (!updateRelease) return;
void ignoreReleaseVersion(updateRelease.version);
}
function handleReminderDisableUpdates(): void {
void disableUpdateChecks();
}
function handleWhatsNewClose(): void {
isWhatsNewModalOpen = false;
whatsNewRelease = null;
}
$effect(() => {
if (updatesLaunchTriggered) return;
if (activeView !== 'home') return;
updatesLaunchTriggered = true;
void runLaunchUpdateFlow();
});
// Artist albums for "By the same artist" section in album view
let albumArtistAlbums = $state<{ id: string; title: string; artwork: string; quality: string; genre: string; releaseDate?: string }[]>([]);
@@ -2583,6 +2675,7 @@
userName={userInfo?.userName}
subscription={userInfo?.subscription}
subscriptionValidUntil={userInfo?.subscriptionValidUntil}
showTitleBar={showTitleBar}
/>
{:else if activeView === 'album' && selectedAlbum}
<AlbumDetailView
@@ -2999,6 +3092,33 @@
onClose={() => isAboutModalOpen = false}
/>
{#if updateRelease}
<UpdateAvailableModal
isOpen={isUpdateModalOpen}
currentVersion={updatesCurrentVersion}
newVersion={updateRelease.version}
onClose={handleUpdateClose}
onVisitReleasePage={handleUpdateVisit}
/>
<UpdateReminderModal
isOpen={isReminderModalOpen}
onClose={handleReminderClose}
onRemindLater={handleReminderLater}
onIgnoreRelease={handleReminderIgnoreRelease}
onDisableAllUpdates={handleReminderDisableUpdates}
/>
{/if}
{#if whatsNewRelease}
<WhatsNewModal
isOpen={isWhatsNewModalOpen}
release={whatsNewRelease}
{showTitleBar}
onClose={handleWhatsNewClose}
/>
{/if}
<!-- Track Info Modal -->
<TrackInfoModal
isOpen={isTrackInfoOpen}