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:
vicrodh committed 2026-05-09 02:13:44 -06:00
1 parent 4454619371
commit aa015829aa
11 files changed
+752 -9

No files matched your search

+46
View File
@@ -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))
}
+24
View File
@@ -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")?;
+3
View File
@@ -83,3 +83,6 @@ pub use mixtapes::*;
pub mod offline_cache;
pub use offline_cache::*;
mod ephemeral;
pub use ephemeral::*;
+170
View File
@@ -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()
}
}
+5
View File
@@ -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
+15
View File
@@ -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",
+15
View File
@@ -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": {
+15
View File
@@ -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",
+15
View File
@@ -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",
+15
View File
@@ -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",