Merge branch 'updates/uiux-1.1.7' into pre-release
This commit is contained in:
commit
6df4f8ea2e
23 files changed
+2343
-58
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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(¤t_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
@@ -494,7 +494,7 @@ select {
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user