feat(local-library): ephemeral folder playback (no DB persistence)
Adds an "Open Folder" button in the LocalLibrary > Folders tree toolbar that lets the user point QBZ at any folder on disk and play it without adding anything to local_tracks. Lets the app act as an ad-hoc media player for downloaded albums, external drives, etc. Backend: - New ephemeral_library module: in-memory HashMap<i64, LocalTrack> keyed by synthetic negative ids. Reuses LibraryScanner and MetadataExtractor to walk the folder and build LocalTrack records. - Three V2 commands: v2_ephemeral_open_folder (scan + populate, replaces any prior session), v2_ephemeral_clear, v2_ephemeral_get_track. - v2_library_play_track gets a small branch at the top: track_id < 0 resolves through the ephemeral state and goes straight to the regular local-file playback path (read file, hand to player). DB-backed paths (Qobuz cache, CUE start positions) are skipped — ephemeral tracks can't be any of those. Frontend: - "Open Folder" button (FolderOpen icon) next to the collapse-all button in the tree toolbar. Click opens the OS file picker, the result lands in a new ephemeralFolder $state. - Right pane gets a new branch above the existing album/folder views: when ephemeralFolder is set, render a dedicated pane with header (folder name, path, track count, Play All, Close) and a flat track list with play-on-click rows. - Lifecycle per spec: a session survives until (1) a different album or tree folder is selected — handled via $effect that watches selectedAlbum and selectedFolderPath, (2) the user clicks the Close button, or (3) the backend reports the file/directory is gone (caught in the play handler and folded into the same close path). - Translations for en, es, de, fr, pt. Notes: - The audio_cache key is `track_id as u64`, which for negative ids wraps to a large u64 value — unique and safe (DB ids are positive). - Edit-metadata gating for ephemeral tracks is deferred: there is no edit trigger from the new pane yet, so the sidecar/direct toggle behaviour is unchanged. Will gate when an edit path lands. cc
This commit is contained in:
11 files changed
+752
-9
No files matched your search
@@ -0,0 +1,46 @@
|
||||
//! Tauri commands for the ephemeral library.
|
||||
//!
|
||||
//! Three commands surface the in-memory ephemeral state to the frontend:
|
||||
//! * `v2_ephemeral_open_folder` — scan + extract metadata for every
|
||||
//! audio file under a path, replace any prior session, return the
|
||||
//! fresh track list.
|
||||
//! * `v2_ephemeral_clear` — drop the current session.
|
||||
//! * `v2_ephemeral_get_track` — fetch a single ephemeral track by its
|
||||
//! synthetic id (used by the player when it sees a negative id and
|
||||
//! needs to recover the metadata for queue display, etc.). The hot
|
||||
//! playback path resolves ids inline via `EphemeralLibraryState::get_track`,
|
||||
//! not through this command.
|
||||
|
||||
use std::path::Path;
|
||||
use tauri::State;
|
||||
|
||||
use crate::ephemeral_library::{EphemeralFolderResult, EphemeralLibraryState};
|
||||
use qbz_library::LocalTrack;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_ephemeral_open_folder(
|
||||
path: String,
|
||||
state: State<'_, EphemeralLibraryState>,
|
||||
) -> Result<EphemeralFolderResult, String> {
|
||||
log::info!("Command: v2_ephemeral_open_folder {}", path);
|
||||
state
|
||||
.open_folder(Path::new(&path))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_ephemeral_clear(
|
||||
state: State<'_, EphemeralLibraryState>,
|
||||
) -> Result<(), String> {
|
||||
log::info!("Command: v2_ephemeral_clear");
|
||||
state.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_ephemeral_get_track(
|
||||
track_id: i64,
|
||||
state: State<'_, EphemeralLibraryState>,
|
||||
) -> Result<Option<LocalTrack>, String> {
|
||||
Ok(state.get_track(track_id))
|
||||
}
|
||||
@@ -826,6 +826,7 @@ pub async fn v2_library_get_tracks_by_ids(
|
||||
pub async fn v2_library_play_track(
|
||||
track_id: i64,
|
||||
library_state: State<'_, LibraryState>,
|
||||
ephemeral_state: State<'_, crate::ephemeral_library::EphemeralLibraryState>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
offline_cache: State<'_, crate::offline_cache::OfflineCacheState>,
|
||||
app_state: State<'_, AppState>,
|
||||
@@ -838,6 +839,29 @@ pub async fn v2_library_play_track(
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Negative ids belong to the ephemeral library (an ad-hoc folder the
|
||||
// user opened without persisting it to local_tracks). Resolve to the
|
||||
// in-memory cache and skip every DB-bound branch below — ephemeral
|
||||
// tracks are never qobuz_download, never offline-cached, never have a
|
||||
// CUE pointer. Just read the file and hand it to the player.
|
||||
if track_id < 0 {
|
||||
let track = ephemeral_state
|
||||
.get_track(track_id)
|
||||
.ok_or_else(|| format!("Ephemeral track {} not found (session may have been cleared)", track_id))?;
|
||||
let file_path = std::path::Path::new(&track.file_path);
|
||||
if !file_path.exists() {
|
||||
return Err(format!("Ephemeral file not found: {}", track.file_path));
|
||||
}
|
||||
let audio_data =
|
||||
std::fs::read(file_path).map_err(|e| format!("Failed to read ephemeral file: {}", e))?;
|
||||
let bridge = bridge.get().await;
|
||||
bridge
|
||||
.player()
|
||||
.play_data(audio_data, track_id as u64)
|
||||
.map_err(|e| format!("Failed to play ephemeral track: {}", e))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let track = {
|
||||
let guard = library_state.db.lock().await;
|
||||
let db = guard.as_ref().ok_or("No active session - please log in")?;
|
||||
|
||||
@@ -83,3 +83,6 @@ pub use mixtapes::*;
|
||||
|
||||
pub mod offline_cache;
|
||||
pub use offline_cache::*;
|
||||
|
||||
mod ephemeral;
|
||||
pub use ephemeral::*;
|
||||
@@ -0,0 +1,170 @@
|
||||
//! In-memory ephemeral library for ad-hoc folder playback.
|
||||
//!
|
||||
//! The user can point QBZ at a folder that lives outside their library
|
||||
//! (a downloaded album they haven't decided to keep, an external drive,
|
||||
//! etc.), browse it, and play tracks from it without anything landing
|
||||
//! in `local_tracks`. The ephemeral session lives only in memory: a
|
||||
//! `HashMap<i64, LocalTrack>` keyed by *synthetic negative ids*. Negative
|
||||
//! ids are how the rest of the playback pipeline distinguishes ephemeral
|
||||
//! tracks from DB-resolvable ones — DB ids are always positive
|
||||
//! (autoincrement), so a negative `track_id` arriving at
|
||||
//! `v2_library_play_track` is unambiguously an ephemeral track and gets
|
||||
//! routed here instead of the DB.
|
||||
//!
|
||||
//! Only one folder is held at a time; opening a new folder replaces the
|
||||
//! previous session. The state vanishes on app exit by virtue of being
|
||||
//! in-memory — nothing persists, no migration, no cleanup logic needed.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use qbz_library::{LibraryError, LibraryScanner, LocalTrack, MetadataExtractor};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct EphemeralFolderResult {
|
||||
pub folder_path: String,
|
||||
pub tracks: Vec<LocalTrack>,
|
||||
pub skipped_files: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EphemeralError {
|
||||
Lock,
|
||||
Library(String),
|
||||
Io(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for EphemeralError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Lock => write!(f, "ephemeral library state lock poisoned"),
|
||||
Self::Library(msg) => write!(f, "{}", msg),
|
||||
Self::Io(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LibraryError> for EphemeralError {
|
||||
fn from(e: LibraryError) -> Self {
|
||||
EphemeralError::Library(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct EphemeralLibraryInner {
|
||||
tracks: HashMap<i64, LocalTrack>,
|
||||
next_id: i64,
|
||||
current_folder_path: Option<String>,
|
||||
}
|
||||
|
||||
impl EphemeralLibraryInner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tracks: HashMap::new(),
|
||||
next_id: -1,
|
||||
current_folder_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.tracks.clear();
|
||||
self.next_id = -1;
|
||||
self.current_folder_path = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EphemeralLibraryState {
|
||||
inner: Mutex<EphemeralLibraryInner>,
|
||||
}
|
||||
|
||||
impl EphemeralLibraryState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(EphemeralLibraryInner::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a folder, extract metadata for every supported audio file
|
||||
/// found, assign synthetic negative ids and stash the result. The
|
||||
/// previous ephemeral session, if any, is dropped.
|
||||
pub fn open_folder(&self, path: &Path) -> Result<EphemeralFolderResult, EphemeralError> {
|
||||
if !path.exists() {
|
||||
return Err(EphemeralError::Io(format!(
|
||||
"Folder does not exist: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(EphemeralError::Io(format!(
|
||||
"Not a directory: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let scanner = LibraryScanner::new();
|
||||
let scan = scanner.scan_directory(path)?;
|
||||
|
||||
let mut tracks_out: Vec<LocalTrack> = Vec::with_capacity(scan.audio_files.len());
|
||||
let mut skipped_files: usize = 0;
|
||||
|
||||
let mut inner = self.inner.lock().map_err(|_| EphemeralError::Lock)?;
|
||||
inner.reset();
|
||||
|
||||
for audio_file in &scan.audio_files {
|
||||
match MetadataExtractor::extract(audio_file) {
|
||||
Ok(mut track) => {
|
||||
track.id = inner.next_id;
|
||||
inner.next_id -= 1;
|
||||
track.source = Some("ephemeral".to_string());
|
||||
inner.tracks.insert(track.id, track.clone());
|
||||
tracks_out.push(track);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[ephemeral] failed to extract metadata from {}: {}",
|
||||
audio_file.display(),
|
||||
e
|
||||
);
|
||||
skipped_files += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let folder_path = path.display().to_string();
|
||||
inner.current_folder_path = Some(folder_path.clone());
|
||||
|
||||
log::info!(
|
||||
"[ephemeral] opened {} ({} tracks, {} skipped)",
|
||||
folder_path,
|
||||
tracks_out.len(),
|
||||
skipped_files
|
||||
);
|
||||
|
||||
Ok(EphemeralFolderResult {
|
||||
folder_path,
|
||||
tracks: tracks_out,
|
||||
skipped_files,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear(&self) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a synthetic negative id to the cached `LocalTrack`. Returns
|
||||
/// `None` if the id is unknown (stale queue entry from a previous
|
||||
/// session, race against `clear`, etc.).
|
||||
pub fn get_track(&self, id: i64) -> Option<LocalTrack> {
|
||||
let inner = self.inner.lock().ok()?;
|
||||
inner.tracks.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EphemeralLibraryState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub mod config;
|
||||
pub mod credentials;
|
||||
pub mod discogs;
|
||||
pub mod discord_rpc;
|
||||
pub mod ephemeral_library;
|
||||
pub mod flatpak;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod idle_inhibit;
|
||||
@@ -1468,6 +1469,7 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
}
|
||||
})
|
||||
.manage(library_state)
|
||||
.manage(ephemeral_library::EphemeralLibraryState::new())
|
||||
.manage(cast_state)
|
||||
.manage(dlna_state)
|
||||
.manage(offline_cache_state)
|
||||
@@ -1674,6 +1676,9 @@ pub fn run(qconnect_cli_override: Option<bool>) {
|
||||
commands_v2::v2_library_get_scan_progress,
|
||||
commands_v2::v2_library_get_tracks_by_ids,
|
||||
commands_v2::v2_library_play_track,
|
||||
commands_v2::v2_ephemeral_open_folder,
|
||||
commands_v2::v2_ephemeral_clear,
|
||||
commands_v2::v2_ephemeral_get_track,
|
||||
commands_v2::v2_playlist_set_sort,
|
||||
commands_v2::v2_playlist_set_artwork,
|
||||
commands_v2::v2_playlist_get_all_settings,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { onMount, onDestroy, tick, untrack } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
HardDrive, Music, Disc3, MicVocal, FolderPlus, Trash2, RefreshCw,
|
||||
HardDrive, Music, Disc3, MicVocal, FolderPlus, FolderOpen, Trash2, RefreshCw,
|
||||
Settings, ArrowLeft, X, Play, CircleAlert, ImageDown, Upload, Search, LayoutGrid, List, ListOrdered, PenLine,
|
||||
Network, Power, PowerOff, ChevronLeft, ChevronRight, Shuffle, SlidersHorizontal, ArrowUpDown, ChevronDown, Check, SquareCheckBig, CassetteTape, ChevronsDownUp
|
||||
} from 'lucide-svelte';
|
||||
@@ -513,6 +513,12 @@
|
||||
// line ~4563, hiding the tree rail. Tree mode keeps the navigation
|
||||
// visible by loading album tracks into a separate state.
|
||||
function handleFolderTreeSelect(path: string) {
|
||||
// Navigating to a tree folder ends the ephemeral session — the user
|
||||
// is moving back into the library proper, so the in-memory cache and
|
||||
// the right-pane override both get dropped.
|
||||
if (ephemeralFolder) {
|
||||
void closeEphemeralFolder();
|
||||
}
|
||||
selectedFolderPath = path;
|
||||
}
|
||||
|
||||
@@ -563,6 +569,125 @@
|
||||
await handleTrackPlay(track);
|
||||
}
|
||||
|
||||
// Open an ad-hoc folder via the OS file picker. The backend scans the
|
||||
// folder, extracts metadata for every supported audio file and stashes
|
||||
// the result in EphemeralLibraryState (in-memory, negative ids). The
|
||||
// tree itself is untouched — the right pane shows the result instead
|
||||
// of the regular folder/album views. Replaces any prior ephemeral
|
||||
// session.
|
||||
async function openEphemeralFolder(): Promise<void> {
|
||||
if (openingEphemeralFolder) return;
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: $t('library.ephemeralFolder.dialogTitle')
|
||||
});
|
||||
if (typeof selected !== 'string' || selected.length === 0) return;
|
||||
|
||||
openingEphemeralFolder = true;
|
||||
// Clear the existing folder/album selection BEFORE assigning the
|
||||
// ephemeral result. The $effect that closes ephemeral on
|
||||
// selection-change reacts to either field becoming non-null while
|
||||
// ephemeral is set; doing the reset first means we never enter the
|
||||
// racy intermediate state where both ephemeral and a real
|
||||
// selection are non-null at the same time.
|
||||
selectedFolderPath = null;
|
||||
selectedAlbum = null;
|
||||
const result = await invoke<EphemeralFolderState>('v2_ephemeral_open_folder', {
|
||||
path: selected
|
||||
});
|
||||
ephemeralFolder = result;
|
||||
if (result.tracks.length === 0) {
|
||||
showToast($t('library.ephemeralFolder.empty'), 'info');
|
||||
} else if (result.skippedFiles > 0) {
|
||||
showToast(
|
||||
$t('library.ephemeralFolder.openedWithSkips', {
|
||||
values: { count: result.tracks.length, skipped: result.skippedFiles }
|
||||
}),
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
$t('library.ephemeralFolder.opened', { values: { count: result.tracks.length } }),
|
||||
'success'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LocalLibrary] Failed to open ephemeral folder:', err);
|
||||
showToast(
|
||||
$t('library.ephemeralFolder.openFailed', { values: { error: String(err) } }),
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
openingEphemeralFolder = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeEphemeralFolder(): Promise<void> {
|
||||
ephemeralFolder = null;
|
||||
try {
|
||||
await invoke('v2_ephemeral_clear');
|
||||
} catch (err) {
|
||||
console.warn('[LocalLibrary] Failed to clear ephemeral state:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function playEphemeralTrack(track: LocalTrack): Promise<void> {
|
||||
if (!ephemeralFolder) return;
|
||||
try {
|
||||
const startIndex = ephemeralFolder.tracks.findIndex((item) => item.id === track.id);
|
||||
await setPlaybackContext(
|
||||
'local_library',
|
||||
'ephemeral',
|
||||
$t('library.ephemeralFolder.contextLabel'),
|
||||
'local',
|
||||
ephemeralFolder.tracks.map((item) => item.id),
|
||||
Math.max(0, startIndex)
|
||||
);
|
||||
await setQueueForLocalTracks(ephemeralFolder.tracks, Math.max(0, startIndex));
|
||||
await invoke('v2_library_play_track', { trackId: track.id });
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
console.error('[LocalLibrary] Failed to play ephemeral track:', err);
|
||||
// Backend returns "Ephemeral file not found" / "Ephemeral track not found"
|
||||
// when the folder or files have been moved/deleted/unmounted while the
|
||||
// session was open. Treat that as the "directory or files no longer
|
||||
// exist" cleanup trigger from the spec — drop the whole session so
|
||||
// the user gets a clean slate instead of a half-broken pane.
|
||||
if (message.includes('Ephemeral file not found')
|
||||
|| message.includes('Ephemeral track not found')) {
|
||||
await closeEphemeralFolder();
|
||||
showToast($t('library.ephemeralFolder.gone'), 'info');
|
||||
return;
|
||||
}
|
||||
showToast(
|
||||
$t('library.ephemeralFolder.playFailed', { values: { error: message } }),
|
||||
'error'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Spec rule: an ephemeral session "survives until a different album is
|
||||
// loaded, the Clear/Close button is pressed, or the directory/files are
|
||||
// gone". This effect handles the first trigger — anything that points
|
||||
// the right pane at a real album or tree folder ends the session. The
|
||||
// Close button handles the second trigger directly via closeEphemeralFolder;
|
||||
// the playEphemeralTrack error path handles the third.
|
||||
$effect(() => {
|
||||
if (!ephemeralFolder) return;
|
||||
if (selectedAlbum != null || selectedFolderPath != null) {
|
||||
untrack(() => {
|
||||
void closeEphemeralFolder();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function playAllEphemeral(): Promise<void> {
|
||||
if (!ephemeralFolder || ephemeralFolder.tracks.length === 0) return;
|
||||
await playEphemeralTrack(ephemeralFolder.tracks[0]);
|
||||
}
|
||||
|
||||
function handleLibraryPreferencesSaved(prefs: LibraryPreferences) {
|
||||
libraryPreferences = sanitizeLibraryPreferences(prefs);
|
||||
// Keep the shared store in sync so the title-bar / sidebar dropdowns
|
||||
@@ -878,6 +1003,25 @@
|
||||
// Path of the folder currently selected in the tree; drives the
|
||||
// right-pane routing (album-detail vs FolderDetail vs empty-state).
|
||||
let selectedFolderPath = $state<string | null>(null);
|
||||
|
||||
// Ephemeral folder session: when set, the right pane shows the contents
|
||||
// of an ad-hoc folder the user opened via the "Open Folder" button. The
|
||||
// tracks here have synthetic negative ids that route to the in-memory
|
||||
// EphemeralLibraryState on the backend (see v2_library_play_track). The
|
||||
// session vanishes on app exit (no DB persistence).
|
||||
type EphemeralFolderState = {
|
||||
folderPath: string;
|
||||
tracks: LocalTrack[];
|
||||
skippedFiles: number;
|
||||
};
|
||||
let ephemeralFolder = $state<EphemeralFolderState | null>(null);
|
||||
let openingEphemeralFolder = $state(false);
|
||||
|
||||
const ephemeralFolderName = $derived.by(() => {
|
||||
if (!ephemeralFolder) return '';
|
||||
const segments = ephemeralFolder.folderPath.split('/').filter(Boolean);
|
||||
return segments.length > 0 ? segments[segments.length - 1] : ephemeralFolder.folderPath;
|
||||
});
|
||||
// Set of paths the user has expanded in the tree. Each top-level
|
||||
// <LocalLibraryFolderTree> shares this set so siblings stay in sync.
|
||||
let treeExpandedPaths = $state(new SvelteSet<string>());
|
||||
@@ -901,8 +1045,8 @@
|
||||
// forcing the rail wider). Bounds: 200px floor; 40% of the live content
|
||||
// area as ceiling. Persisted on drag-end via
|
||||
// v2_set_library_folders_tree_sidebar_width.
|
||||
const FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH = 302;
|
||||
const FOLDER_TREE_SIDEBAR_MIN_WIDTH = 200;
|
||||
const FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH = 290;
|
||||
const FOLDER_TREE_SIDEBAR_MIN_WIDTH = 290;
|
||||
let folderTreeSidebarWidth = $state<number>(FOLDER_TREE_SIDEBAR_DEFAULT_WIDTH);
|
||||
let isResizingTreeSidebar = $state(false);
|
||||
let folderTreeContentAreaWidth = $state<number>(0);
|
||||
@@ -5476,6 +5620,16 @@
|
||||
>
|
||||
<ChevronsDownUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tree-toolbar-btn"
|
||||
onclick={openEphemeralFolder}
|
||||
disabled={openingEphemeralFolder}
|
||||
title={$t('library.ephemeralFolder.tooltip')}
|
||||
aria-label={$t('library.ephemeralFolder.buttonLabel')}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</button>
|
||||
<input
|
||||
type="search"
|
||||
class="tree-search-input"
|
||||
@@ -5538,7 +5692,81 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="folder-content-column">
|
||||
{#if selectedAlbumForTree}
|
||||
{#if ephemeralFolder}
|
||||
<!-- Ephemeral session: an ad-hoc folder the user opened
|
||||
without persisting it to local_tracks. The track
|
||||
ids here are negative; play through them lands in
|
||||
the ephemeral branch of v2_library_play_track. -->
|
||||
<div class="ephemeral-pane">
|
||||
<div class="ephemeral-header">
|
||||
<div class="ephemeral-header-info">
|
||||
<FolderOpen size={20} class="ephemeral-icon" />
|
||||
<div class="ephemeral-titles">
|
||||
<div class="ephemeral-title">{ephemeralFolderName}</div>
|
||||
<div class="ephemeral-subtitle">
|
||||
{$t('library.ephemeralFolder.subtitle', {
|
||||
values: {
|
||||
count: ephemeralFolder.tracks.length,
|
||||
path: ephemeralFolder.folderPath
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ephemeral-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="ephemeral-play-btn"
|
||||
onclick={playAllEphemeral}
|
||||
disabled={ephemeralFolder.tracks.length === 0}
|
||||
title={$t('library.ephemeralFolder.playAll')}
|
||||
>
|
||||
<Play size={14} />
|
||||
<span>{$t('library.ephemeralFolder.playAll')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ephemeral-close-btn"
|
||||
onclick={closeEphemeralFolder}
|
||||
title={$t('library.ephemeralFolder.close')}
|
||||
aria-label={$t('library.ephemeralFolder.close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if ephemeralFolder.tracks.length === 0}
|
||||
<div class="ephemeral-empty">
|
||||
{$t('library.ephemeralFolder.empty')}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ephemeral-track-list">
|
||||
{#each ephemeralFolder.tracks as track (track.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ephemeral-track-row"
|
||||
class:active={activeTrackId === track.id}
|
||||
onclick={() => playEphemeralTrack(track)}
|
||||
>
|
||||
<span class="ephemeral-track-num">
|
||||
{track.disc_number && track.disc_number > 1
|
||||
? `${track.disc_number}.${track.track_number ?? '—'}`
|
||||
: track.track_number ?? '—'}
|
||||
</span>
|
||||
<span class="ephemeral-track-meta">
|
||||
<span class="ephemeral-track-title">{formatTrackTitle(track)}</span>
|
||||
<span class="ephemeral-track-secondary">
|
||||
{track.artist}{#if track.album} · {track.album}{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="ephemeral-track-quality">{getQualityBadge(track)}</span>
|
||||
<span class="ephemeral-track-duration">{formatDuration(track.duration_secs)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if selectedAlbumForTree}
|
||||
<!-- Compact album view: artwork + metadata + play +
|
||||
track list, sized for the right pane so the tree
|
||||
rail stays visible. The full-page album-detail
|
||||
@@ -8370,12 +8598,13 @@
|
||||
|
||||
/* Dedicated tree-mode search input. Decoupled from the global
|
||||
`albumSearch` input — driving its own filter via `treeSearchInput`
|
||||
in the script. Pinned to a moderate fixed width (~140px) so the
|
||||
toolbar items stay left-aligned and the search input doesn't
|
||||
stretch across the whole column. */
|
||||
in the script. Pinned to a moderate fixed width (~140px) and
|
||||
pushed to the right edge of the toolbar via `margin-left: auto`
|
||||
so the action icons stay anchored on the left. */
|
||||
.tree-search-input {
|
||||
flex: 0 0 auto;
|
||||
width: 140px;
|
||||
margin-left: auto;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
@@ -8390,9 +8619,9 @@
|
||||
|
||||
.folder-tree-column {
|
||||
/* Width is set inline via style:width — see folderTreeSidebarWidth in
|
||||
LocalLibraryView.svelte. The 302px fallback only kicks in if the
|
||||
LocalLibraryView.svelte. The 290px fallback only kicks in if the
|
||||
inline style fails to attach (defensive). */
|
||||
width: 302px;
|
||||
width: 290px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -8498,6 +8727,197 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Ephemeral folder pane: full content area showing the contents of an
|
||||
ad-hoc folder the user opened without persisting to the library. The
|
||||
header carries a Play All affordance and a close button; below it a
|
||||
simple track list (no virtualization needed — ephemeral sessions are
|
||||
bounded to one folder at a time and rarely exceed a few hundred
|
||||
rows). Visually marked as transient via the "Session" eyebrow color
|
||||
so it doesn't read as part of the persistent library. */
|
||||
.ephemeral-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.ephemeral-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 4px 12px 4px;
|
||||
border-bottom: 1px solid var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ephemeral-header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ephemeral-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ephemeral-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ephemeral-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ephemeral-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ephemeral-play-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 14px;
|
||||
background: var(--accent-primary);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.ephemeral-play-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.ephemeral-play-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ephemeral-close-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.ephemeral-close-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ephemeral-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.ephemeral-track-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ephemeral-track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 36px 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 8px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.ephemeral-track-row:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.ephemeral-track-row.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.ephemeral-track-num {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.ephemeral-track-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ephemeral-track-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ephemeral-track-secondary {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ephemeral-track-quality {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ephemeral-track-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Inline Flat / Tree toggle in the jumpnav row.
|
||||
Frontend-design intent: the toggle must NOT compete with the tab
|
||||
list for visual weight. So it sits right of the tabs (separated by
|
||||
|
||||
@@ -915,6 +915,21 @@
|
||||
"listView": "Liste",
|
||||
"viewArtwork": "Albumcover ansehen"
|
||||
},
|
||||
"ephemeralFolder": {
|
||||
"buttonLabel": "Ordner öffnen",
|
||||
"tooltip": "Öffnet einen Ordner ad-hoc — die Titel erscheinen im Inhaltsbereich und bleiben verfügbar, bis die App geschlossen wird. Es wird nichts zur Bibliothek hinzugefügt.",
|
||||
"dialogTitle": "Ordner zum Abspielen auswählen",
|
||||
"contextLabel": "Geöffneter Ordner",
|
||||
"subtitle": "{count, plural, one {# Titel} other {# Titel}} · {path}",
|
||||
"playAll": "Alle abspielen",
|
||||
"close": "Ordner schließen",
|
||||
"empty": "Keine unterstützten Audiodateien in diesem Ordner gefunden.",
|
||||
"opened": "{count, plural, one {# Titel} other {# Titel}} geöffnet",
|
||||
"openedWithSkips": "{count, plural, one {# Titel} other {# Titel}} geöffnet, {skipped, plural, one {# Datei} other {# Dateien}} übersprungen",
|
||||
"openFailed": "Ordner konnte nicht geöffnet werden: {error}",
|
||||
"playFailed": "Wiedergabe fehlgeschlagen: {error}",
|
||||
"gone": "Ordner nicht mehr verfügbar — Sitzung beendet."
|
||||
},
|
||||
"folderListTitle": "Bibliotheksordner",
|
||||
"editTabs": {
|
||||
"title": "Library-Tabs bearbeiten",
|
||||
|
||||
@@ -915,6 +915,21 @@
|
||||
"listView": "List",
|
||||
"viewArtwork": "View album art"
|
||||
},
|
||||
"ephemeralFolder": {
|
||||
"buttonLabel": "Open Folder",
|
||||
"tooltip": "Open a folder ad-hoc — its tracks load into the content pane and remain available until the app closes. Nothing is added to your library.",
|
||||
"dialogTitle": "Choose a folder to play",
|
||||
"contextLabel": "Open Folder",
|
||||
"subtitle": "{count, plural, one {# track} other {# tracks}} · {path}",
|
||||
"playAll": "Play all",
|
||||
"close": "Close folder",
|
||||
"empty": "No supported audio files found in this folder.",
|
||||
"opened": "Opened {count, plural, one {# track} other {# tracks}}",
|
||||
"openedWithSkips": "Opened {count, plural, one {# track} other {# tracks}}, skipped {skipped, plural, one {# file} other {# files}}",
|
||||
"openFailed": "Could not open folder: {error}",
|
||||
"playFailed": "Playback failed: {error}",
|
||||
"gone": "Folder is no longer available — session cleared."
|
||||
},
|
||||
"explicit": "Explicit",
|
||||
"folderListTitle": "Library Folders",
|
||||
"editTabs": {
|
||||
|
||||
@@ -915,6 +915,21 @@
|
||||
"listView": "Lista",
|
||||
"viewArtwork": "Ver portada del álbum"
|
||||
},
|
||||
"ephemeralFolder": {
|
||||
"buttonLabel": "Abrir carpeta",
|
||||
"tooltip": "Abre una carpeta ad-hoc — sus pistas se cargan en el panel y permanecen disponibles hasta cerrar la app. No se agrega nada a tu biblioteca.",
|
||||
"dialogTitle": "Selecciona una carpeta para reproducir",
|
||||
"contextLabel": "Carpeta abierta",
|
||||
"subtitle": "{count, plural, one {# pista} other {# pistas}} · {path}",
|
||||
"playAll": "Reproducir todo",
|
||||
"close": "Cerrar carpeta",
|
||||
"empty": "No se encontraron archivos de audio compatibles en esta carpeta.",
|
||||
"opened": "Cargada {count, plural, one {# pista} other {# pistas}}",
|
||||
"openedWithSkips": "Cargadas {count, plural, one {# pista} other {# pistas}}, omitidos {skipped, plural, one {# archivo} other {# archivos}}",
|
||||
"openFailed": "No se pudo abrir la carpeta: {error}",
|
||||
"playFailed": "Falló la reproducción: {error}",
|
||||
"gone": "La carpeta ya no está disponible — sesión cerrada."
|
||||
},
|
||||
"folderListTitle": "Carpetas de la Biblioteca",
|
||||
"editTabs": {
|
||||
"title": "Editar pestañas de Library",
|
||||
|
||||
@@ -915,6 +915,21 @@
|
||||
"listView": "Liste",
|
||||
"viewArtwork": "Voir la pochette de l'album"
|
||||
},
|
||||
"ephemeralFolder": {
|
||||
"buttonLabel": "Ouvrir un dossier",
|
||||
"tooltip": "Ouvre un dossier de manière ponctuelle — ses pistes sont chargées dans le panneau et restent disponibles jusqu'à la fermeture de l'application. Rien n'est ajouté à votre bibliothèque.",
|
||||
"dialogTitle": "Sélectionne un dossier à lire",
|
||||
"contextLabel": "Dossier ouvert",
|
||||
"subtitle": "{count, plural, one {# piste} other {# pistes}} · {path}",
|
||||
"playAll": "Tout lire",
|
||||
"close": "Fermer le dossier",
|
||||
"empty": "Aucun fichier audio pris en charge n'a été trouvé dans ce dossier.",
|
||||
"opened": "{count, plural, one {# piste} other {# pistes}} chargée(s)",
|
||||
"openedWithSkips": "{count, plural, one {# piste} other {# pistes}} chargée(s), {skipped, plural, one {# fichier} other {# fichiers}} ignoré(s)",
|
||||
"openFailed": "Impossible d'ouvrir le dossier : {error}",
|
||||
"playFailed": "Échec de la lecture : {error}",
|
||||
"gone": "Le dossier n'est plus disponible — session fermée."
|
||||
},
|
||||
"folderListTitle": "Dossiers de la bibliothèque",
|
||||
"editTabs": {
|
||||
"title": "Modifier les onglets Library",
|
||||
|
||||
@@ -915,6 +915,21 @@
|
||||
"listView": "Lista",
|
||||
"viewArtwork": "Ver capa do álbum"
|
||||
},
|
||||
"ephemeralFolder": {
|
||||
"buttonLabel": "Abrir pasta",
|
||||
"tooltip": "Abre uma pasta de forma pontual — suas faixas são carregadas no painel e permanecem disponíveis até o app fechar. Nada é adicionado à sua biblioteca.",
|
||||
"dialogTitle": "Selecione uma pasta para reproduzir",
|
||||
"contextLabel": "Pasta aberta",
|
||||
"subtitle": "{count, plural, one {# faixa} other {# faixas}} · {path}",
|
||||
"playAll": "Reproduzir tudo",
|
||||
"close": "Fechar pasta",
|
||||
"empty": "Nenhum arquivo de áudio compatível encontrado nesta pasta.",
|
||||
"opened": "{count, plural, one {# faixa} other {# faixas}} carregada(s)",
|
||||
"openedWithSkips": "{count, plural, one {# faixa} other {# faixas}} carregada(s), {skipped, plural, one {# arquivo} other {# arquivos}} ignorado(s)",
|
||||
"openFailed": "Não foi possível abrir a pasta: {error}",
|
||||
"playFailed": "Falha na reprodução: {error}",
|
||||
"gone": "A pasta não está mais disponível — sessão encerrada."
|
||||
},
|
||||
"folderListTitle": "Pastas da Biblioteca",
|
||||
"editTabs": {
|
||||
"title": "Editar abas da Library",
|
||||
|
||||
Reference in new issue
Block a user