diff --git a/Cargo.lock b/Cargo.lock index 8fa47bf..a1d6c14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,7 @@ dependencies = [ "confy", "cpal", "eframe", + "egui-phosphor", "egui_extras", "font-kit", "image 0.24.9", @@ -1149,6 +1150,15 @@ dependencies = [ "profiling", ] +[[package]] +name = "egui-phosphor" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171d57331491e92f0a7c73fad1088716e3200984309f4f8684731b750cee1378" +dependencies = [ + "egui", +] + [[package]] name = "egui-wgpu" version = "0.31.1" diff --git a/Cargo.toml b/Cargo.toml index 152cb98..f56a265 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ urlencoding = "2.1" ureq = "2.9" uuid = { version = "1.23.0", default-features = false, features = ["v4"] } lofty = "0.21.0" +egui-phosphor = "0.9" [features] default = [] diff --git a/build_mac_app.sh b/build_mac_app.sh index 423489b..bd9454c 100755 --- a/build_mac_app.sh +++ b/build_mac_app.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -APP_NAME="BirdPlayer" +APP_NAME="Bird Player" APP_DIR="target/release/$APP_NAME.app" CONTENTS_DIR="$APP_DIR/Contents" MACOS_DIR="$CONTENTS_DIR/MacOS" @@ -39,7 +39,7 @@ cat << PLIST > "$CONTENTS_DIR/Info.plist" CFBundleInfoDictionaryVersion 6.0 CFBundleName - BirdPlayer + Bird Player CFBundlePackageType APPL CFBundleShortVersionString diff --git a/docs/superpowers/plans/2026-05-06-playlist-metadata-playback-info.md b/docs/superpowers/plans/2026-05-06-playlist-metadata-playback-info.md new file mode 100644 index 0000000..d03eb29 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-playlist-metadata-playback-info.md @@ -0,0 +1,866 @@ +# Playlist Metadata and Playback Info Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add metadata fields (description, timestamps) to playlists and display contextual playback information in the player UI's top-right corner. + +**Architecture:** Extend Playlist data model with metadata fields, migrate database schema, extract audio technical info at playback time, and create a new UI component to display contextual information based on playback state. + +**Tech Stack:** Rust, egui, rusqlite, symphonia, lofty + +--- + +## File Structure + +**New files:** +- `src/app/components/playback_info_panel.rs` - UI component for displaying playback context + +**Modified files:** +- `src/lib/playlist.rs` - Add metadata fields and accessor methods +- `src/app/db.rs` - Database schema migration +- `src/lib/player.rs` - Add audio technical info fields +- `src/lib/audio/loader.rs` - Extract technical info from codec +- `src/lib/messaging.rs` - Add AudioEvent variant for technical info +- `src/app/core.rs` - Handle technical info event +- `src/app/components/mod.rs` - Export new component +- `src/app/components/player_component.rs` - Integrate playback info panel + +--- + +### Task 1: Add Playlist Metadata Fields + +**Files:** +- Modify: `src/lib/playlist.rs:9-18` + +- [ ] **Step 1: Add metadata fields to Playlist struct** + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Playlist { + pub id: Option, + name: Option, + pub tracks: Vec, + pub selected: Option, + #[serde(skip_serializing, skip_deserializing)] + pub selected_indices: HashSet, + #[serde(skip)] + pub is_dirty: bool, + description: Option, + created_at: i64, + updated_at: i64, +} +``` + +- [ ] **Step 2: Update Playlist::new() to initialize timestamps** + +In `src/lib/playlist.rs`, find `Playlist::new()` around line 27 and modify: + +```rust +pub fn new() -> Self { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + Self { + id: None, + name: None, + tracks: vec![], + selected: None, + selected_indices: HashSet::new(), + is_dirty: true, + description: None, + created_at: now, + updated_at: now, + } +} +``` + +- [ ] **Step 3: Add accessor methods for metadata** + +Add after `get_name()` method around line 46: + +```rust +pub fn description(&self) -> Option<&str> { + self.description.as_deref() +} + +pub fn set_description(&mut self, description: Option) { + self.description = description; + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + self.is_dirty = true; +} + +pub fn created_at(&self) -> i64 { + self.created_at +} + +pub fn updated_at(&self) -> i64 { + self.updated_at +} +``` + +- [ ] **Step 4: Update set_name to update timestamp** + +Modify `set_name()` around line 38: + +```rust +pub fn set_name(&mut self, name: String) { + self.name = Some(name); + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + self.is_dirty = true; +} +``` + +- [ ] **Step 5: Update add() to update timestamp** + +Modify `add()` around line 48: + +```rust +pub fn add(&mut self, track: LibraryItem) { + self.tracks.push(track); + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + self.is_dirty = true; +} +``` + +- [ ] **Step 6: Update remove() to update timestamp** + +Modify `remove()` around line 54: + +```rust +pub fn remove(&mut self, idx: usize) { + self.tracks.remove(idx); + self.selected_indices.remove(&idx); + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + self.is_dirty = true; + + // Update indices greater than the removed index + let mut to_remove = Vec::new(); + let mut to_add = Vec::new(); + + for &i in &self.selected_indices { + if i > idx { + to_remove.push(i); + to_add.push(i - 1); + } + } + + for i in to_remove { + self.selected_indices.remove(&i); + } + + for i in to_add { + self.selected_indices.insert(i); + } +} +``` + +- [ ] **Step 7: Update reorder() to update timestamp** + +Modify `reorder()` around line 80: + +```rust +pub fn reorder(&mut self, current_pos: usize, destination_pos: usize) { + let track = self.tracks.remove(current_pos); + self.tracks.insert(destination_pos, track); + self.updated_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + self.is_dirty = true; + + // Update selected indices after reordering + let mut new_selected = HashSet::new(); + + for &idx in &self.selected_indices { + if idx == current_pos { + new_selected.insert(destination_pos); + } else if (idx < current_pos && idx < destination_pos) + || (idx > current_pos && idx > destination_pos) + { + new_selected.insert(idx); + } else if idx < current_pos && idx >= destination_pos { + new_selected.insert(idx + 1); + } else if idx > current_pos && idx <= destination_pos { + new_selected.insert(idx - 1); + } + } + + self.selected_indices = new_selected; +} +``` + +- [ ] **Step 8: Commit** + +```bash +git add src/lib/playlist.rs +git commit -m "feat(playlist): add metadata fields (description, timestamps)" +``` + +--- + +### Task 2: Database Schema Migration + +**Files:** +- Modify: `src/app/db.rs:125-132` +- Modify: `src/app/db.rs:50-70` (schema version check) + +- [ ] **Step 1: Add migration logic to initialize_database()** + +Find the `initialize_database()` function around line 50 in `src/app/db.rs`. After creating the `playlists` table (around line 132), add migration logic: + +```rust +// Check schema version and run migrations +let current_version: i32 = connection + .query_row("SELECT version FROM schema_version", [], |row| row.get(0)) + .unwrap_or(1); + +if current_version < 2 { + tracing::info!("Running database migration to version 2"); + + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + // Add new columns to playlists table + connection.execute( + "ALTER TABLE playlists ADD COLUMN description TEXT", + [], + )?; + + connection.execute( + "ALTER TABLE playlists ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0", + [], + )?; + + connection.execute( + "ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", + [], + )?; + + // Set timestamps for existing playlists + connection.execute( + "UPDATE playlists SET created_at = ?1, updated_at = ?1 WHERE created_at = 0", + [current_time], + )?; + + // Update schema version + connection.execute("UPDATE schema_version SET version = 2", [])?; + + tracing::info!("Database migration to version 2 completed"); +} +``` + +- [ ] **Step 2: Update Playlist::save_to_db() to include new fields** + +Find `save_to_db()` method around line 150 in `src/lib/playlist.rs`. Update the INSERT and UPDATE statements: + +```rust +pub fn save_to_db(&mut self, conn: &Arc>) -> SqlResult<()> { + if !self.is_dirty { + return Ok(()); + } + + let mut conn = conn.lock().unwrap(); + + // Start a transaction + let tx = conn.transaction()?; + + // Insert or update the playlist record + let playlist_id = match self.id { + Some(id) => { + // Update existing playlist + tx.execute( + "UPDATE playlists SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![self.name, self.description, self.updated_at, id], + )?; + id + } + None => { + // Insert new playlist + tx.execute( + "INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![self.name, self.description, self.created_at, self.updated_at], + )?; + tx.last_insert_rowid() + } + }; + + // Clear existing playlist items + tx.execute( + "DELETE FROM playlist_items WHERE playlist_id = ?1", + rusqlite::params![playlist_id], + )?; + + // Insert the tracks with their positions + for (position, track) in self.tracks.iter().enumerate() { + tx.execute( + "INSERT INTO playlist_items (playlist_id, library_item_id, position) + VALUES (?1, ?2, ?3)", + rusqlite::params![playlist_id, track.key().to_string(), position as i32], + )?; + } + + // Commit the transaction + tx.commit()?; + self.is_dirty = false; + + Ok(()) +} +``` + +- [ ] **Step 3: Update save_to_db_and_update_id() similarly** + +Find `save_to_db_and_update_id()` around line 202 and apply the same changes: + +```rust +pub fn save_to_db_and_update_id(&mut self, conn: &Arc>) -> SqlResult<()> { + if !self.is_dirty { + return Ok(()); + } + + let mut conn = conn.lock().unwrap(); + let tx = conn.transaction()?; + + let playlist_id = match self.id { + Some(id) => { + tx.execute( + "UPDATE playlists SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![self.name, self.description, self.updated_at, id], + )?; + id + } + None => { + tx.execute( + "INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![self.name, self.description, self.created_at, self.updated_at], + )?; + tx.last_insert_rowid() + } + }; + + if self.id.is_none() { + self.id = Some(playlist_id); + } + + tx.execute( + "DELETE FROM playlist_items WHERE playlist_id = ?1", + rusqlite::params![playlist_id], + )?; + + for (position, track) in self.tracks.iter().enumerate() { + tx.execute( + "INSERT INTO playlist_items (playlist_id, library_item_id, position) + VALUES (?1, ?2, ?3)", + rusqlite::params![playlist_id, track.key().to_string(), position as i32], + )?; + } + + tx.commit()?; + self.is_dirty = false; + + Ok(()) +} +``` + +- [ ] **Step 4: Update load_from_db() to load new fields** + +Find `load_from_db()` around line 259 and update the SELECT statement: + +```rust +pub fn load_from_db(conn: &Arc>, playlist_id: i64) -> SqlResult { + let conn_guard = conn.lock().unwrap(); + + // Get the playlist info + let mut stmt = conn_guard.prepare( + "SELECT id, name, description, created_at, updated_at FROM playlists WHERE id = ?1" + )?; + + let mut playlist_rows = stmt.query(rusqlite::params![playlist_id])?; + + if let Some(row) = playlist_rows.next()? { + let id: i64 = row.get(0)?; + let name: Option = row.get(1)?; + let description: Option = row.get(2)?; + let created_at: i64 = row.get(3)?; + let updated_at: i64 = row.get(4)?; + + // Create the playlist + let mut playlist = Playlist { + id: Some(id), + name, + tracks: vec![], + selected: None, + selected_indices: HashSet::new(), + is_dirty: false, + description, + created_at, + updated_at, + }; + + // Get the tracks (rest of the method remains the same) + // ... existing track loading code ... +``` + +- [ ] **Step 5: Test database migration** + +Run: `cargo build` +Expected: Compiles successfully + +- [ ] **Step 6: Commit** + +```bash +git add src/app/db.rs src/lib/playlist.rs +git commit -m "feat(db): add playlist metadata schema migration to version 2" +``` + +--- + +### Task 3: Add Audio Technical Info to Player + +**Files:** +- Modify: `src/lib/player.rs:18-26` +- Modify: `src/lib/messaging.rs` (add AudioEvent variant) +- Modify: `src/lib/audio/loader.rs:88-99` +- Modify: `src/app/core.rs` (handle new event) + +- [ ] **Step 1: Add technical info fields to Player struct** + +In `src/lib/player.rs`, add fields after `duration` around line 25: + +```rust +pub struct Player { + pub selected_track: Option, + pub track_state: TrackState, + pub volume: f32, + pub playback_mode: PlaybackMode, + pub seek_to_timestamp: u64, + pub duration: u64, + pub sample_rate: Option, + pub channels: Option, + pub codec: Option, +} +``` + +- [ ] **Step 2: Initialize new fields in Player::new()** + +Around line 43: + +```rust +impl Player { + pub fn new() -> Self { + Self { + selected_track: None, + track_state: TrackState::Stopped, + volume: 1.0, + playback_mode: PlaybackMode::Normal, + seek_to_timestamp: 0, + duration: 0, + sample_rate: None, + channels: None, + codec: None, + } + } +``` + +- [ ] **Step 3: Add AudioEvent::TechnicalInfo variant** + +In `src/lib/messaging.rs`, find the `AudioEvent` enum and add: + +```rust +pub enum AudioEvent { + TotalTrackDuration(u64), + CurrentTimestamp(u64), + TrackEnded, + TechnicalInfo { + sample_rate: Option, + channels: Option, + codec: Option, + }, +} +``` + +- [ ] **Step 4: Extract technical info in audio loader** + +In `src/lib/audio/loader.rs`, after line 99 where `codec_params` is accessed, add: + +```rust +// Extract technical info for UI display +let sample_rate = track.codec_params.sample_rate; +let channels = track.codec_params.channels.map(|ch| ch.count() as u8); +let codec = track.codec_params.codec.map(|c| format!("{:?}", c)); + +// Send technical info to UI +ui_tx + .send(AudioEvent::TechnicalInfo { + sample_rate, + channels, + codec, + }) + .ok(); +``` + +- [ ] **Step 5: Handle TechnicalInfo event in App** + +In `src/app/core.rs`, find `pump_audio_events()` method and add handler: + +```rust +AudioEvent::TechnicalInfo { + sample_rate, + channels, + codec, +} => { + let player = self.player_mut_ref(); + player.sample_rate = sample_rate; + player.channels = channels; + player.codec = codec; +} +``` + +- [ ] **Step 6: Test compilation** + +Run: `cargo build` +Expected: Compiles successfully + +- [ ] **Step 7: Commit** + +```bash +git add src/lib/player.rs src/lib/messaging.rs src/lib/audio/loader.rs src/app/core.rs +git commit -m "feat(player): add audio technical info extraction (sample rate, channels, codec)" +``` + +--- + +### Task 4: Create PlaybackInfoPanel Component + +**Files:** +- Create: `src/app/components/playback_info_panel.rs` +- Modify: `src/app/components/mod.rs` + +- [ ] **Step 1: Create playback_info_panel.rs** + +Create new file `src/app/components/playback_info_panel.rs`: + +```rust +use super::AppComponent; +use crate::app::style::tokens; +use crate::app::App; +use eframe::egui::{self, Align, Layout, RichText}; + +pub struct PlaybackInfoPanel; + +impl AppComponent for PlaybackInfoPanel { + type Context = App; + + fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { + // Check if we have a playing playlist + let playing_playlist_idx = ctx.app_settings.playing_playlist_idx; + + if let Some(playlist_idx) = playing_playlist_idx { + // Case 1: Playlist is playing + Self::render_playlist_info(ctx, ui, playlist_idx); + } else { + // Case 2: Single track (no playlist) + Self::render_track_info(ctx, ui); + } + } +} + +impl PlaybackInfoPanel { + fn render_playlist_info(ctx: &App, ui: &mut egui::Ui, playlist_idx: usize) { + if let Some(playlist) = ctx.playlists.get(playlist_idx) { + ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { + let weak_color = ui.visuals().weak_text_color(); + + // Playlist name + if let Some(name) = playlist.get_name() { + ui.label( + RichText::new(&name) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Track position + if let Some(selected_track) = &ctx.player_ref().selected_track { + if let Some(pos) = playlist.get_pos(selected_track) { + let total = playlist.tracks.len(); + ui.label( + RichText::new(format!("Track {:02}/{:02}", pos + 1, total)) + .size(tokens::text::SM) + .color(weak_color), + ); + } + } + + // Description preview (first 30 chars) + if let Some(desc) = playlist.description() { + let preview = if desc.len() > 30 { + format!("{}...", &desc[..30]) + } else { + desc.to_string() + }; + ui.label( + RichText::new(preview) + .size(tokens::text::SM) + .color(weak_color), + ); + } + }); + } + } + + fn render_track_info(ctx: &App, ui: &mut egui::Ui) { + let player = ctx.player_ref(); + + if let Some(track) = &player.selected_track { + ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { + let weak_color = ui.visuals().weak_text_color(); + + // Album · Year + let mut album_line = String::new(); + if let Some(album) = track.album() { + album_line.push_str(&album); + } + if let Some(year) = track.year() { + if !album_line.is_empty() { + album_line.push_str(" · "); + } + album_line.push_str(&year.to_string()); + } + if !album_line.is_empty() { + ui.label( + RichText::new(album_line) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Genre + if let Some(genre) = track.genre() { + ui.label( + RichText::new(genre) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Format · Sample Rate · Channels + let mut tech_line = String::new(); + if let Some(codec) = &player.codec { + tech_line.push_str(codec); + } + if let Some(sample_rate) = player.sample_rate { + if !tech_line.is_empty() { + tech_line.push_str(" · "); + } + tech_line.push_str(&format!("{:.1}kHz", sample_rate as f32 / 1000.0)); + } + if let Some(channels) = player.channels { + if !tech_line.is_empty() { + tech_line.push_str(" · "); + } + let channel_str = match channels { + 1 => "Mono", + 2 => "Stereo", + n => return format!("{}ch", n), + }; + tech_line.push_str(channel_str); + } + if !tech_line.is_empty() { + ui.label( + RichText::new(tech_line) + .size(tokens::text::SM) + .color(weak_color), + ); + } + }); + } + } +} +``` + +- [ ] **Step 2: Export component in mod.rs** + +In `src/app/components/mod.rs`, add: + +```rust +mod playback_info_panel; +pub use playback_info_panel::PlaybackInfoPanel; +``` + +- [ ] **Step 3: Test compilation** + +Run: `cargo build` +Expected: Compiles successfully + +- [ ] **Step 4: Commit** + +```bash +git add src/app/components/playback_info_panel.rs src/app/components/mod.rs +git commit -m "feat(ui): add PlaybackInfoPanel component for contextual playback info" +``` + +--- + +### Task 5: Integrate PlaybackInfoPanel into Player UI + +**Files:** +- Modify: `src/app/components/player_component.rs:96-167` + +- [ ] **Step 1: Import PlaybackInfoPanel** + +At the top of `src/app/components/player_component.rs`, add to imports: + +```rust +use super::playback_info_panel::PlaybackInfoPanel; +``` + +- [ ] **Step 2: Add PlaybackInfoPanel to player layout** + +Find the horizontal layout around line 97 that contains `CassetteComponent` and track info. Modify to add the info panel on the right: + +```rust +// ── Top row: cover + track info + playback info ──────────────── +ui.horizontal(|ui| { + CassetteComponent::add(ctx, ui); + + ui.allocate_ui_with_layout( + vec2(ui.available_width() * 0.6, ui.available_height()), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + if let Some(track) = &selected_track { + let title = track.title.as_deref().unwrap_or("unknown title"); + let artist = track.artist.as_deref().unwrap_or("unknown artist"); + + let format_time = |timestamp: u64| -> String { + let total_seconds = timestamp / 1000; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + format!("{:02}:{:02}", minutes, seconds) + }; + + ui.add( + egui::Label::new( + RichText::new(title).size(tokens::text::LG).strong(), + ) + .truncate(), + ); + ui.add(egui::Label::new(RichText::new(artist).weak()).truncate()); + ui.add( + egui::Label::new( + RichText::new(format!( + "{} / {}", + format_time(seek_to_timestamp), + format_time(duration), + )) + .size(tokens::text::SM) + .weak(), + ) + .truncate(), + ); + if !current_playlist_name.is_empty() { + ui.add( + egui::Label::new( + RichText::new(format!( + "{}: {}", + t("playlist_label"), + current_playlist_name, + )) + .size(tokens::text::SM) + .weak(), + ) + .truncate(), + ); + } + } else { + ui.add(egui::Label::new( + RichText::new(t("no_track")).size(tokens::text::LG).strong(), + )); + let hint = if has_tracks_in_playlist { + t("select_track") + } else if current_playlist_idx.is_some() { + t("add_tracks") + } else { + t("create_playlist") + }; + ui.add(egui::Label::new(RichText::new(hint).weak())); + } + }, + ); + + // Add playback info panel on the right + ui.allocate_ui_with_layout( + vec2(ui.available_width(), ui.available_height()), + egui::Layout::top_down(egui::Align::RIGHT), + |ui| { + PlaybackInfoPanel::add(ctx, ui); + }, + ); +}); +``` + +- [ ] **Step 3: Test compilation** + +Run: `cargo build` +Expected: Compiles successfully + +- [ ] **Step 4: Test the UI** + +Run: `cargo run` +Expected: +- Right corner shows playlist info when playing from a playlist +- Right corner shows album/technical info when playing a single track +- Info updates when switching tracks + +- [ ] **Step 5: Commit** + +```bash +git add src/app/components/player_component.rs +git commit -m "feat(ui): integrate PlaybackInfoPanel into player component" +``` + +--- + +## Self-Review Checklist + +**Spec coverage:** +- ✅ Playlist metadata fields (description, timestamps) - Task 1 +- ✅ Database schema migration - Task 2 +- ✅ Audio technical info extraction - Task 3 +- ✅ PlaybackInfoPanel component - Task 4 +- ✅ UI integration - Task 5 + +**Placeholder scan:** +- ✅ No TBD, TODO, or "implement later" +- ✅ All code blocks are complete +- ✅ All commands have expected output + +**Type consistency:** +- ✅ `description: Option` used consistently +- ✅ `created_at: i64` and `updated_at: i64` used consistently +- ✅ `sample_rate: Option`, `channels: Option`, `codec: Option` used consistently + +**Missing from spec:** +- None - all requirements covered + +--- + +## Execution Complete + +All tasks defined. Plan is ready for execution. + diff --git a/docs/superpowers/specs/2026-05-06-playlist-metadata-playback-info-design.md b/docs/superpowers/specs/2026-05-06-playlist-metadata-playback-info-design.md new file mode 100644 index 0000000..33e4d3b --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-playlist-metadata-playback-info-design.md @@ -0,0 +1,200 @@ +--- +name: Playlist Metadata and Playback Info Panel +description: Add metadata fields to playlists and display contextual playback information in the player UI +type: feature +date: 2026-05-06 +--- + +# Playlist Metadata and Playback Info Panel + +## Overview + +Add metadata fields (description, timestamps) to playlists and display contextual information in the player UI's top-right corner. When a playlist is playing, show playlist context; when playing a single track, show album/audio technical info. + +## Goals + +1. Allow users to add descriptions to playlists for organization and context +2. Track playlist creation and modification times +3. Display relevant playback context in the player UI without cluttering the interface +4. Provide audio technical information (sample rate, channels, codec) for users who care about quality + +## Non-Goals + +- Playlist cover images (deferred to future work) +- Total playlist duration (requires storing duration in LibraryItem, deferred) +- Album-level metadata beyond what's already in LibraryItem +- Playlist sorting/filtering by metadata (can be added later if needed) + +## Data Model Changes + +### Playlist Structure + +Add three new fields to `Playlist` struct in `src/lib/playlist.rs`: + +```rust +pub struct Playlist { + pub id: Option, + name: Option, + pub tracks: Vec, + pub selected: Option, + #[serde(skip_serializing, skip_deserializing)] + pub selected_indices: HashSet, + #[serde(skip)] + pub is_dirty: bool, + + // New fields + description: Option, + created_at: i64, // Unix timestamp in milliseconds + updated_at: i64, // Unix timestamp in milliseconds +} +``` + +**Field semantics:** +- `description`: User-provided text describing the playlist's purpose, mood, or content +- `created_at`: Set once when `Playlist::new()` is called +- `updated_at`: Updated whenever: + - `name` or `description` changes + - Tracks are added, removed, or reordered + - NOT updated for: selection changes, playback state changes + +### Database Schema + +Modify `playlists` table in `src/app/db.rs`: + +```sql +ALTER TABLE playlists ADD COLUMN description TEXT; +ALTER TABLE playlists ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0; +ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0; +``` + +**Migration strategy:** +- Check schema version in `initialize_database()` +- Increment schema version from 1 to 2 +- For existing playlists, set `created_at` and `updated_at` to migration timestamp +- `description` defaults to NULL for existing playlists + +### Player State + +Add audio technical info fields to `Player` struct in `src/lib/player.rs`: + +```rust +pub struct Player { + // ... existing fields ... + + // New fields (populated when track loads) + pub sample_rate: Option, + pub channels: Option, + pub codec: Option, +} +``` + +These fields are populated in `src/lib/audio/loader.rs` when a track loads, extracted from `symphonia`'s `codec_params`. + +## UI Components + +### New Component: PlaybackInfoPanel + +**Location:** `src/app/components/playback_info_panel.rs` + +**Responsibility:** Render contextual playback information in the player UI's top-right corner. + +**Display logic:** + +**Case 1: Playlist is playing** (when `ctx.app_settings.playing_playlist_idx.is_some()`) +``` +Playlist Name +Track 03/12 +Description preview... (first 30 chars if present) +``` + +**Case 2: Single track (no playlist)** (when `ctx.app_settings.playing_playlist_idx.is_none()`) +``` +Album · 2024 +Rock +FLAC · 44.1kHz · Stereo +``` + +**Styling:** +- Font: `tokens::text::SM` +- Color: `ui.visuals().weak_text_color()` +- Alignment: Right-aligned +- Spacing: Compact vertical stack with `tokens::spacing::XS` + +**Integration:** +Modify `src/app/components/player_component.rs` line ~97 (the horizontal layout with cassette + track info) to add `PlaybackInfoPanel` on the right side. + +## Implementation Details + +### Timestamp Management + +Use `std::time::SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_millis() as i64` to generate timestamps. + +Update `updated_at` in: +- `Playlist::set_name()` +- `Playlist::set_description()` (new method) +- `Playlist::add()` +- `Playlist::remove()` +- `Playlist::reorder()` + +### Audio Technical Info Extraction + +In `src/lib/audio/loader.rs`, after successfully loading a track (around line 88-99 where `codec_params` is accessed): + +```rust +// Extract technical info for UI display +let sample_rate = track.codec_params.sample_rate; +let channels = track.codec_params.channels.map(|ch| ch.count() as u8); +let codec = track.codec_params.codec.map(|c| format!("{:?}", c)); + +// Send to player state via AudioEvent +ui_tx.send(AudioEvent::TechnicalInfo { sample_rate, channels, codec }).ok(); +``` + +Add new `AudioEvent::TechnicalInfo` variant and handle it in `src/app/core.rs` to update `Player` state. + +### Database Migration + +In `src/app/db.rs`, modify `initialize_database()`: + +1. Check current schema version +2. If version < 2, run migration: + ```rust + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + conn.execute("ALTER TABLE playlists ADD COLUMN description TEXT", [])?; + conn.execute("ALTER TABLE playlists ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0", [])?; + conn.execute("ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", [])?; + + // Set timestamps for existing playlists + conn.execute( + "UPDATE playlists SET created_at = ?1, updated_at = ?1 WHERE created_at = 0", + [current_time] + )?; + + // Update schema version + conn.execute("UPDATE schema_version SET version = 2", [])?; + ``` + +### Playlist Save/Load + +Update `Playlist::save_to_db()` and `Playlist::load_from_db()` in `src/lib/playlist.rs` to include new fields in INSERT/UPDATE/SELECT statements. + +## Testing Considerations + +- Verify migration works on existing databases +- Test that `updated_at` updates correctly for all modification operations +- Test that `updated_at` does NOT update for selection/playback changes +- Verify UI displays correct info for both playlist and single-track cases +- Test with tracks that have missing metadata (no album, no genre, etc.) +- Test with audio files that don't expose sample rate/channels + +## Future Enhancements + +- Playlist cover images +- Total playlist duration (requires storing duration in LibraryItem) +- Playlist sorting by created_at/updated_at +- Playlist search by description +- Batch edit playlist metadata diff --git a/src/app/bootstrap.rs b/src/app/bootstrap.rs index 82e65d8..84bdb54 100644 --- a/src/app/bootstrap.rs +++ b/src/app/bootstrap.rs @@ -81,6 +81,12 @@ pub fn start_app() -> Result<()> { let fonts = font::setup_fonts(); cc.egui_ctx.set_fonts(fonts); + // Apply brand-aware visual tweaks (selection colour etc.) + cc.egui_ctx.style_mut(|style| { + crate::app::style::apply_brand_visuals(&mut style.visuals); + crate::app::style::apply_compact_spacing(&mut style.spacing); + }); + Ok(Box::new(app)) }), )?; diff --git a/src/app/components/cassette_component.rs b/src/app/components/cassette_component.rs index 5eb7116..419c098 100644 --- a/src/app/components/cassette_component.rs +++ b/src/app/components/cassette_component.rs @@ -1,56 +1,21 @@ use super::AppComponent; +use crate::app::style::tokens; use crate::app::App; use ::image::io::Reader as ImageReader; use eframe::egui::epaint::*; +use eframe::egui::Sense; use eframe::egui::{vec2, ColorImage, Shape, TextureHandle}; -use eframe::egui::{Rect, Sense}; use log::{error, info, warn}; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; -use std::time::Instant; - -struct CassetteColors { - stroke: Color32, - tape: Color32, - reel_stroke: Color32, - reel_spokes: Color32, - default_album_art: Color32, -} - -impl CassetteColors { - fn from_theme(ui: &eframe::egui::Ui) -> Self { - if ui.visuals().dark_mode { - Self { - stroke: Color32::from_rgb(60, 60, 65), - tape: Color32::from_rgb(0, 0, 0), - reel_stroke: Color32::from_rgb(60, 60, 65), - reel_spokes: Color32::from_rgb(80, 80, 85), - default_album_art: Color32::from_rgb(0, 0, 0), - } - } else { - Self { - stroke: Color32::from_rgb(160, 160, 165), - tape: Color32::from_rgb(0, 0, 0), - reel_stroke: Color32::from_rgb(160, 160, 165), - reel_spokes: Color32::from_rgb(180, 180, 185), - default_album_art: Color32::from_rgb(255, 255, 255), - } - } - } -} pub struct CassetteComponent; -const ALBUM_ART_SIZE: f32 = 120.0; -const CASSETTE_WIDTH: f32 = 280.0; -const CASSETTE_HEIGHT: f32 = 160.0; -const REEL_RADIUS: f32 = 40.0; -const ROTATION_SPEED_PLAYING: f32 = 0.8; + +const ALBUM_ART_SIZE: f32 = tokens::size::ALBUM; thread_local! { - static LAST_UPDATE: std::cell::RefCell = std::cell::RefCell::new(Instant::now()); static IMAGE_CACHE: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); - static ROTATION_ANGLE: std::cell::RefCell = const {std::cell::RefCell::new(0.0)}; } impl AppComponent for CassetteComponent { @@ -58,154 +23,23 @@ impl AppComponent for CassetteComponent { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { ui.horizontal(|ui| { - let colors = CassetteColors::from_theme(ui); - let rect = ui.available_rect_before_wrap().shrink(10.0); - let rect = Rect::from_min_size(rect.min, vec2(CASSETTE_WIDTH, CASSETTE_HEIGHT)); - - ui.allocate_rect(rect, Sense::hover()); - - let left_reel_center = rect.left_center() + vec2(REEL_RADIUS + 20.0, 0.0); - let right_reel_center = rect.right_center() - vec2(REEL_RADIUS + 20.0, 0.0); - let center_rect = eframe::egui::Rect::from_center_size( - rect.center(), - vec2(ALBUM_ART_SIZE, ALBUM_ART_SIZE), - ); - - // Draw main cassette frame with rounded corners - let corner_radius = 8.0; - ui.painter().add(Shape::Rect(RectShape { - rect, - corner_radius: corner_radius.into(), - fill: Color32::TRANSPARENT, - stroke: Stroke::new(1.0, colors.stroke), - stroke_kind: StrokeKind::Middle, - round_to_pixels: None, - blur_width: 0.0, - brush: None, - })); - - // Draw bottom detail area - let detail_height = 20.0; - let detail_rect = Rect::from_min_max( - rect.left_bottom() - vec2(0.0, detail_height), - rect.right_bottom(), - ); - - // Draw horizontal lines for detail area - ui.painter().line_segment( - [ - detail_rect.left_top(), - eframe::egui::pos2(detail_rect.right(), detail_rect.top()), - ], - Stroke::new(1.0, colors.stroke), - ); - - // Draw round holes on the sides - let button_radius = 8.0; - let button_margin = 20.0; - - // Left hole - ui.painter().circle_stroke( - detail_rect.left_center() + vec2(button_margin, 0.0), - button_radius, - Stroke::new(1.0, colors.stroke), - ); - - // Right hole - ui.painter().circle_stroke( - detail_rect.right_center() - vec2(button_margin, 0.0), - button_radius, - Stroke::new(1.0, colors.stroke), - ); - - // Draw trapezoid frame in the center - let trapezoid_width = 120.0; - let trapezoid_inset = 10.0; - let center_x = detail_rect.center().x; - - let trapezoid_points = vec![ - eframe::egui::pos2( - center_x - (trapezoid_width - trapezoid_inset) / 2.0, - detail_rect.top() + 4.0, - ), - eframe::egui::pos2( - center_x + (trapezoid_width - trapezoid_inset) / 2.0, - detail_rect.top() + 4.0, - ), - eframe::egui::pos2(center_x + trapezoid_width / 2.0, detail_rect.bottom() - 2.0), - eframe::egui::pos2(center_x - trapezoid_width / 2.0, detail_rect.bottom() - 2.0), - ]; - - ui.painter().add(Shape::convex_polygon( - trapezoid_points.clone(), - Color32::TRANSPARENT, - Stroke::new(1.0, colors.stroke), - )); - - // Draw holes in the trapezoid frame with varying sizes as rounded rectangles - let hole_sizes = [2.0, 3.0, 4.0, 4.0, 3.0, 2.0]; // Height of the holes - let hole_width = 3.0; // Fixed width for all holes - let num_holes = hole_sizes.len(); - let hole_spacing = (trapezoid_width - trapezoid_inset / 2.0) / (num_holes as f32 + 1.0); - let hole_y = detail_rect.bottom() - 8.0; - - for i in 1..=num_holes { - let hole_x = center_x - trapezoid_width / 2.0 + (i as f32 * hole_spacing); - let hole_height = hole_sizes[i - 1]; - - let hole_rect = Rect::from_center_size( - eframe::egui::pos2(hole_x, hole_y), - vec2(hole_width, hole_height), - ); - - ui.painter().add(Shape::Rect(RectShape { - rect: hole_rect, - corner_radius: 1.0.into(), - fill: ui.visuals().window_fill(), // Use window background color for transparent/white fill - stroke: Stroke::new(1.0, colors.stroke), - stroke_kind: StrokeKind::Middle, - round_to_pixels: None, - blur_width: 0.0, - brush: None, - })); - } - - let (current_angle, tape_progress) = update_animation(ctx); - - draw_tape( - ui, - left_reel_center, - right_reel_center, - center_rect, - tape_progress, - &colors, - ); - - draw_reel( - ui, - left_reel_center, - current_angle, - colors.reel_stroke, - 1.0 - tape_progress, - &colors, - ); - - draw_reel( - ui, - right_reel_center, - -current_angle, - colors.reel_stroke, - tape_progress, - &colors, - ); - - let mut show_wave_canvas = true; + // Reserve a square slot via the layout itself: in a horizontal + // strip with the default Align::Center, allocate_exact_size will + // vertically centre the cover against the info column on its + // right. Doing the math by hand against `available_rect_before_wrap` + // is wrong because that rect's height extends to the bottom of + // the parent panel, not to the row's content height — which used + // to push the cover above the visible band entirely. + let side = ALBUM_ART_SIZE.max(64.0); + let (rect, _resp) = ui.allocate_exact_size(vec2(side, side), Sense::hover()); + + let mut show_default = true; if let Some(selected_track) = &ctx.player_ref().selected_track { if let Some(picture) = selected_track.pictures().first() { let path = picture.file_path.clone(); - show_wave_canvas = !IMAGE_CACHE.with(|cache| { + show_default = !IMAGE_CACHE.with(|cache| { if !cache.borrow().contains_key(&path) { if let Ok(image_bytes) = std::fs::read(&path) { let reader = ImageReader::new(Cursor::new(image_bytes)) @@ -237,11 +71,9 @@ impl AppComponent for CassetteComponent { } if let Some(texture) = cache.borrow().get(&path) { - let image_rect = center_rect; - // Calculate UV coordinates for center-cropped fit let image_aspect = texture.size_vec2()[0] / texture.size_vec2()[1]; - let rect_aspect = image_rect.width() / image_rect.height(); + let rect_aspect = rect.width() / rect.height(); let (uv_min, uv_max) = if image_aspect > rect_aspect { // Image is wider than display area - crop sides @@ -261,9 +93,12 @@ impl AppComponent for CassetteComponent { ) }; + // Need to draw image using a meshed shape or just image with rounded corners + // Egui doesn't natively support rounded corners on simple image() without a custom shape/mesh + // But for simplicity, we just use UI image element with rounding. ui.painter().image( texture.id(), - image_rect, + rect, eframe::egui::Rect::from_min_max(uv_min, uv_max), Color32::WHITE, ); @@ -276,163 +111,31 @@ impl AppComponent for CassetteComponent { } } - if show_wave_canvas { - show_default_album_art(ctx, ui, center_rect); + if show_default { + show_default_album_art(ctx, ui, rect); } }); } } -fn update_animation(ctx: &mut App) -> (f32, f32) { - let current_angle = ROTATION_ANGLE.with(|angle| { - let now = Instant::now(); - let elapsed = LAST_UPDATE.with(|last| { - let elapsed = now.duration_since(*last.borrow()); - *last.borrow_mut() = now; - elapsed - }); - - let is_playing = matches!( - ctx.player_ref().track_state, - crate::app::player::TrackState::Playing - ); // Slightly slower spin keeps motion smooth when repaint cadence drops. - let rotation_speed = if is_playing { - ROTATION_SPEED_PLAYING - } else { - 0.0 - }; - - *angle.borrow_mut() += rotation_speed * elapsed.as_secs_f32(); - *angle.borrow() - }); - - // Calculate the current progress directly - let current_timestamp = ctx.player_ref().seek_to_timestamp as f32; - let duration = ctx.player_ref().duration as f32; - let tape_progress = if duration > 0.0 { - current_timestamp / duration +fn show_default_album_art(ctx: &App, ui: &mut eframe::egui::Ui, rect: eframe::egui::Rect) { + let fill_color = if ui.visuals().dark_mode { + tokens::color::ALBUM_BG_DARK } else { - 0.0 + tokens::color::ALBUM_BG_LIGHT }; - (current_angle, tape_progress) -} - -fn draw_tape( - ui: &mut eframe::egui::Ui, - left_reel_center: eframe::egui::Pos2, - right_reel_center: eframe::egui::Pos2, - center_rect: eframe::egui::Rect, - progress: f32, - colors: &CassetteColors, -) { - let tape_thickness = 4.0; - - let top_left = center_rect.left_top() + vec2(-5.0, 5.0); - let _bottom_left = center_rect.left_bottom() + vec2(-5.0, -5.0); - let top_right = center_rect.right_top() + vec2(5.0, 5.0); - let _bottom_right = center_rect.right_bottom() + vec2(5.0, -5.0); - - ui.painter().line_segment( - [left_reel_center, top_left], - Stroke::new(tape_thickness, colors.tape), - ); - - ui.painter().line_segment( - [top_left, top_right], - Stroke::new(tape_thickness, colors.tape), - ); - - ui.painter().line_segment( - [top_right, right_reel_center], - Stroke::new(tape_thickness, colors.tape), - ); - - let left_amount = 1.0 - progress; - let right_amount = progress; - - let max_fill_radius = REEL_RADIUS * 0.8; - let center_hole_radius = REEL_RADIUS * 0.3; - - if left_amount > 0.05 { - let left_fill_radius = REEL_RADIUS * 0.3 + max_fill_radius * left_amount; - ui.painter() - .circle_filled(left_reel_center, left_fill_radius, colors.tape); - ui.painter().circle_filled( - left_reel_center, - center_hole_radius, - ui.visuals().window_fill(), - ); - } - - if right_amount > 0.05 { - let right_fill_radius = REEL_RADIUS * 0.3 + max_fill_radius * right_amount; - ui.painter() - .circle_filled(right_reel_center, right_fill_radius, colors.tape); - ui.painter().circle_filled( - right_reel_center, - center_hole_radius, - ui.visuals().window_fill(), - ); - } -} - -fn draw_reel( - ui: &mut eframe::egui::Ui, - center: eframe::egui::Pos2, - angle: f32, - _color: Color32, - _tape_amount: f32, - colors: &CassetteColors, -) { - // Draw outer circle (main reel) - ui.painter() - .circle_stroke(center, REEL_RADIUS, Stroke::new(1.0, colors.reel_stroke)); - - // Draw gear frame around the center - let gear_radius = REEL_RADIUS * 0.3; - let num_teeth = 12; - for i in 0..num_teeth { - let tooth_angle = angle + i as f32 * 2.0 * std::f32::consts::PI / num_teeth as f32; - let inner_point = center - + vec2( - tooth_angle.cos() * gear_radius * 0.8, - tooth_angle.sin() * gear_radius * 0.8, - ); - let outer_point = center - + vec2( - tooth_angle.cos() * gear_radius, - tooth_angle.sin() * gear_radius, - ); - - ui.painter().line_segment( - [inner_point, outer_point], - Stroke::new(1.5, colors.reel_spokes), - ); - } - - // Draw middle circle (gear frame) - ui.painter() - .circle_stroke(center, gear_radius, Stroke::new(1.0, colors.reel_stroke)); - - // Draw center hole - ui.painter() - .circle_filled(center, REEL_RADIUS * 0.15, Color32::TRANSPARENT); - ui.painter().circle_stroke( - center, - REEL_RADIUS * 0.15, - Stroke::new(1.0, colors.reel_stroke), - ); -} + let stroke_color = if ui.visuals().dark_mode { + tokens::color::ALBUM_STROKE_DARK + } else { + tokens::color::ALBUM_STROKE_LIGHT + }; -fn show_default_album_art(ctx: &App, ui: &mut eframe::egui::Ui, rect: eframe::egui::Rect) { - let colors = CassetteColors::from_theme(ui); - let corner_radius = 0.0; ui.painter().add(Shape::Rect(RectShape { rect, - corner_radius: corner_radius.into(), - fill: colors.default_album_art, - stroke: Stroke::new(1.0, colors.stroke), + corner_radius: tokens::radius::LG.into(), + fill: fill_color, + stroke: Stroke::new(tokens::size::STROKE_WIDTH, stroke_color), stroke_kind: StrokeKind::Middle, round_to_pixels: None, blur_width: 0.0, @@ -440,89 +143,50 @@ fn show_default_album_art(ctx: &App, ui: &mut eframe::egui::Ui, rect: eframe::eg })); // Create a vertical layout for the text - let text_spacing = 24.0; - let title_pos = rect.center(); - let artist_pos = rect.center() + vec2(0.0, text_spacing); + + let title_pos = rect.center() - vec2(0.0, 12.0); + let artist_pos = rect.center() + vec2(0.0, 12.0); // Get track information from the player if let Some(selected_track) = &ctx.player_ref().selected_track { // Calculate maximum text width (80% of rect width to leave some margin) - let max_width = rect.width() * 0.8; - let title_font = eframe::egui::FontId::proportional(12.0); - let artist_font = eframe::egui::FontId::proportional(12.0); + + let title_font = eframe::egui::FontId::proportional(tokens::text::MD); + let artist_font = eframe::egui::FontId::proportional(tokens::text::SM); // Draw title with truncation let title = selected_track .title() .unwrap_or("Unknown Title".to_string()); - let title_galley = - ui.painter() - .layout_no_wrap(title.clone(), title_font.clone(), Color32::DARK_GRAY); - - let truncated_title = if title_galley.rect.width() > max_width { - // Find appropriate truncation point - let mut truncated = title.clone(); - while truncated.len() > 3 { - // Keep at least 3 chars - truncated.pop(); - let test_galley = ui.painter().layout_no_wrap( - format!("{}...", truncated), - title_font.clone(), - Color32::DARK_GRAY, - ); - if test_galley.rect.width() <= max_width { - truncated.push_str("..."); - break; - } - } - truncated - } else { - title - }; + // Use basic text truncation ui.painter().text( title_pos, eframe::egui::Align2::CENTER_CENTER, - truncated_title, + title, title_font, - Color32::DARK_GRAY, + ui.visuals().text_color(), ); // Draw artist with truncation let artist = selected_track .artist() .unwrap_or("Unknown Artist".to_string()); - let artist_galley = - ui.painter() - .layout_no_wrap(artist.clone(), artist_font.clone(), Color32::DARK_GRAY); - - let truncated_artist = if artist_galley.rect.width() > max_width { - // Find appropriate truncation point - let mut truncated = artist.clone(); - while truncated.len() > 3 { - // Keep at least 3 chars - truncated.pop(); - let test_galley = ui.painter().layout_no_wrap( - format!("{}...", truncated), - artist_font.clone(), - Color32::DARK_GRAY, - ); - if test_galley.rect.width() <= max_width { - truncated.push_str("..."); - break; - } - } - truncated - } else { - artist - }; ui.painter().text( artist_pos, eframe::egui::Align2::CENTER_CENTER, - truncated_artist, + artist, artist_font, - Color32::DARK_GRAY, + ui.visuals().text_color(), + ); + } else { + ui.painter().text( + rect.center(), + eframe::egui::Align2::CENTER_CENTER, + "No Cover", + eframe::egui::FontId::proportional(tokens::text::MD), + ui.visuals().text_color(), ); } } diff --git a/src/app/components/footer.rs b/src/app/components/footer.rs index a20ef7d..7438793 100644 --- a/src/app/components/footer.rs +++ b/src/app/components/footer.rs @@ -1,4 +1,5 @@ use super::AppComponent; +use crate::app::style::icons; use crate::app::App; pub struct Footer; @@ -63,7 +64,7 @@ impl AppComponent for Footer { }); // Add a search button that only triggers when clicked - if ui.button("🔍").clicked() + if ui.button(icons::SEARCH).clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(eframe::egui::Key::Enter))) { @@ -140,7 +141,7 @@ impl AppComponent for Footer { } // Close button to exit search mode - if ui.button("x").clicked() { + if ui.button(icons::CLOSE).clicked() { search_active = false; search_text.clear(); ui.memory_mut(|mem| { @@ -248,11 +249,11 @@ impl AppComponent for Footer { { ui.label( eframe::egui::RichText::new("No matches found") - .color(eframe::egui::Color32::RED), + .color(crate::app::style::tokens::color::LYRICS_FAILED), ); } }); - } else if ui.button("🔍 Search").clicked() { + } else if ui.button(format!("{} Search", icons::SEARCH)).clicked() { search_active = true; // Reset the first frame flag when search is activated ui.memory_mut(|mem| { diff --git a/src/app/components/library_component.rs b/src/app/components/library_component.rs index 782a822..39e370e 100644 --- a/src/app/components/library_component.rs +++ b/src/app/components/library_component.rs @@ -13,103 +13,105 @@ impl AppComponent for LibraryComponent { // Keep track of paths to remove (if any) let mut path_to_remove: Option = None; - eframe::egui::ScrollArea::both().show(ui, |ui| { - ui.horizontal(|ui| { - // Create a clickable label for "Music Files" with context menu - let music_label = ui.add( - Label::new(RichText::new(t("music_files")).strong()).sense(Sense::click()), - ); - - // Add context menu with expand/collapse options - music_label.context_menu(|ui| { - if ui.button(t("expand_all")).clicked() { - // Set all folders to expanded - ctx.ui_state.library_folders_expanded = true; - - // Force clear the memory to make all folders expand - ui.ctx().memory_mut(|mem| { - mem.data.clear(); - }); - - ui.close_menu(); - } + // Header is rendered OUTSIDE the ScrollArea so it stays pinned and + // its bottom rule lines up with the playlist tabs / lyrics panel + // rules on the same horizontal seam. + crate::app::style::panel_header(ui, |ui| { + // Create a clickable label for "Music Files" with context menu + let music_label = ui.add( + Label::new( + RichText::new(t("music_files")).size(crate::app::style::tokens::text::SM), + ) + .sense(Sense::click()), + ); + + // Add context menu with expand/collapse options + music_label.context_menu(|ui| { + if ui.button(t("expand_all")).clicked() { + // Set all folders to expanded + ctx.ui_state.library_folders_expanded = true; + + // Force clear the memory to make all folders expand + ui.ctx().memory_mut(|mem| { + mem.data.clear(); + }); - if ui.button(t("collapse_all")).clicked() { - // Set all folders to collapsed - ctx.ui_state.library_folders_expanded = false; + ui.close_menu(); + } - // Force clear the memory to make all folders collapse - ui.ctx().memory_mut(|mem| { - mem.data.clear(); - }); + if ui.button(t("collapse_all")).clicked() { + // Set all folders to collapsed + ctx.ui_state.library_folders_expanded = false; - ui.close_menu(); - } + // Force clear the memory to make all folders collapse + ui.ctx().memory_mut(|mem| { + mem.data.clear(); + }); + + ui.close_menu(); + } - if ui.button(t("resync_all")).clicked() { - // Get all paths that need to be reimported - let paths_to_resync: Vec<_> = ctx + if ui.button(t("resync_all")).clicked() { + // Get all paths that need to be reimported + let paths_to_resync: Vec<_> = ctx + .library + .paths() + .iter() + .filter(|p| p.status() == crate::app::library::LibraryPathStatus::Imported) + .map(|p| (p.id(), p.path().clone())) + .collect(); + + // For each path, trigger a resync + for (path_id, _path) in paths_to_resync { + // Temporarily set path to NotImported to trigger reimport + ctx.library.set_path_to_not_imported(path_id); + + // Re-import the path, which will update existing items + let path_to_import = ctx .library .paths() .iter() - .filter(|p| { - p.status() == crate::app::library::LibraryPathStatus::Imported - }) - .map(|p| (p.id(), p.path().clone())) - .collect(); - - // For each path, trigger a resync - for (path_id, _path) in paths_to_resync { - // Temporarily set path to NotImported to trigger reimport - ctx.library.set_path_to_not_imported(path_id); - - // Re-import the path, which will update existing items - ctx.import_library_paths( - ctx.library - .paths() - .iter() - .find(|p| p.id() == path_id) - .unwrap(), - ); - } - - ui.close_menu(); - } - }); - - ui.add_space(5.0); // Add a small space between label and buttons - - // Add a button to select and import a folder - if ui - .button("+") - .on_hover_text(t("add_music_folder")) - .clicked() - { - if let Some(new_path) = rfd::FileDialog::new().pick_folder() { - // Add the path to the library - let path_exists = !ctx.library.add_path(new_path.clone()); - - // If it existed, find it and rescan. If new, import the bottom-most path. - let path_to_import = if path_exists { - ctx.library - .paths() - .iter() - .find(|p| *p.path() == new_path) - .cloned() - } else { - ctx.library.paths().last().cloned() - }; - - if let Some(p) = path_to_import { - ctx.import_library_paths(&p); - } + .find(|p| p.id() == path_id) + .cloned() + .unwrap(); + ctx.import_library_paths(&path_to_import); } + + ui.close_menu(); } }); - // Add some vertical spacing - ui.add_space(5.0); + ui.add_space(crate::app::style::tokens::spacing::XS); // Add a small space between label and buttons + + // Add a button to select and import a folder + if ui + .add(eframe::egui::Button::new(crate::app::style::icons::PLUS).frame(false)) + .on_hover_text(t("add_music_folder")) + .clicked() + { + if let Some(new_path) = rfd::FileDialog::new().pick_folder() { + // Add the path to the library + let path_exists = !ctx.library.add_path(new_path.clone()); + + // If it existed, find it and rescan. If new, import the bottom-most path. + let path_to_import = if path_exists { + ctx.library + .paths() + .iter() + .find(|p| *p.path() == new_path) + .cloned() + } else { + ctx.library.paths().last().cloned() + }; + + if let Some(p) = path_to_import { + ctx.import_library_paths(&p); + } + } + } + }); + eframe::egui::ScrollArea::both().show(ui, |ui| { // Group library items by their library_id (which corresponds to folder paths) let mut folder_items: HashMap> = HashMap::new(); @@ -129,8 +131,10 @@ impl AppComponent for LibraryComponent { // Create a header with default behavior that allows individual control // but is also affected by the global expand/collapse actions - let header = CollapsingHeader::new(RichText::new(folder_name).strong()) - .default_open(ctx.ui_state.library_folders_expanded); // Use the global setting after memory clear + let header = CollapsingHeader::new( + RichText::new(folder_name).size(crate::app::style::tokens::text::SM), + ) + .default_open(ctx.ui_state.library_folders_expanded); // Use the global setting after memory clear // Show the header and get its response let section = header.show(ui, |ui| { @@ -159,9 +163,12 @@ impl AppComponent for LibraryComponent { // Create a clickable label for each track let item_label = ui.add( - Label::new(RichText::new(display_text)) - .sense(Sense::click()) - .wrap_mode(TextWrapMode::Truncate), + Label::new( + RichText::new(display_text) + .size(crate::app::style::tokens::text::SM), + ) + .sense(Sense::click()) + .wrap_mode(TextWrapMode::Truncate), ); if item_label.hovered() { ui.ctx() diff --git a/src/app/components/lyrics_component.rs b/src/app/components/lyrics_component.rs index 6413e2a..0af3e20 100644 --- a/src/app/components/lyrics_component.rs +++ b/src/app/components/lyrics_component.rs @@ -3,6 +3,7 @@ use eframe::egui; use super::AppComponent; use crate::app::libstate::lyrics_state::LyricsFetchState; use crate::app::lyrics::{Lyrics, LyricsService}; +use crate::app::style::{icons, tokens}; use crate::app::{t, App}; enum ManualUploadResult { @@ -17,18 +18,20 @@ impl AppComponent for LyricsComponent { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - ui.horizontal(|ui| { + crate::app::style::panel_header(ui, |ui| { Self::show_lyrics_type(ui, ctx); Self::show_lyrics_header(ui, ctx); Self::show_status(ui, ctx); }); - ui.add_space(6.0); - ui.separator(); - - egui::ScrollArea::vertical().show(ui, |ui| { - Self::show_body(ctx, ui); - }); + // auto_shrink([false, false]) lets the scroll area span the full + // panel width so the vertical scrollbar sits flush against the + // panel's right edge instead of floating in the middle. + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| { + Self::show_body(ctx, ui); + }); } } @@ -36,10 +39,20 @@ impl LyricsComponent { fn show_lyrics_header(ui: &mut egui::Ui, ctx: &mut App) { // Show track info if let Some(lyrics) = ctx.lyrics_manager().current_lyrics() { - ui.label(format!("{} - {}", &lyrics.track_name, &lyrics.artist_name)); + ui.label( + egui::RichText::new(format!("{} — {}", &lyrics.track_name, &lyrics.artist_name)) + .size(tokens::text::SM), + ); } - if ui.button(t("upload_lyrics")).clicked() { + let upload_clicked = ui + .scope(|ui| { + crate::app::style::borderless_button_visuals(ui.visuals_mut()); + ui.button(t("upload_lyrics")) + }) + .inner + .clicked(); + if upload_clicked { match Self::handle_manual_upload(ctx) { ManualUploadResult::Updated => { ctx.ui_state.lyrics_fetch_state = LyricsFetchState::Loaded; @@ -56,21 +69,19 @@ impl LyricsComponent { if let Some(lyrics) = ctx.lyrics_manager().current_lyrics() { // Show lyrics type indicator let lyrics_type = if !lyrics.lines.is_empty() { - "🎤" + icons::LYRICS_SYNCED } else if lyrics.plain_lyrics.is_some() { - "📝" + icons::LYRICS_PLAIN } else if lyrics.instrumental { - "🎸" + icons::LYRICS_INSTRUMENTAL } else { - "-" + icons::LYRICS_NONE }; ui.label( egui::RichText::new(lyrics_type) - .color(egui::Color32::from_rgb(100, 150, 255)) + .color(tokens::color::LYRICS_TYPE_ICON) .italics(), ); - - ui.separator(); } } @@ -82,7 +93,7 @@ impl LyricsComponent { } LyricsFetchState::Failed(message) => { ui.colored_label( - egui::Color32::from_rgb(230, 80, 80), + tokens::color::LYRICS_FAILED, format!("Lyrics unavailable: {}", message), ); } @@ -95,7 +106,9 @@ impl LyricsComponent { // Show lyrics if lyrics.instrumental { ui.add(egui::Label::new( - egui::RichText::new("♪ Instrumental ♪").italics(), + egui::RichText::new("♪ Instrumental ♪") + .italics() + .size(tokens::text::SM), )); } else if !lyrics.lines.is_empty() { // Show synced lyrics with current line highlighting @@ -104,16 +117,22 @@ impl LyricsComponent { Self::show_plain_lyrics(ui, plain_lyrics); } else { ui.add(egui::Label::new( - egui::RichText::new("No lyrics available").italics(), + egui::RichText::new("No lyrics available") + .italics() + .size(tokens::text::SM), )); } } else { ui.vertical_centered(|ui| { ui.add(egui::Label::new( - egui::RichText::new("No lyrics loaded").italics(), + egui::RichText::new("No lyrics loaded") + .italics() + .size(tokens::text::SM), )); ui.add(egui::Label::new( - egui::RichText::new("Select a track to view lyrics").italics(), + egui::RichText::new("Select a track to view lyrics") + .italics() + .size(tokens::text::SM), )); }); } @@ -146,12 +165,14 @@ impl LyricsComponent { // Highlight current line and scroll to it ui.add(egui::Label::new( egui::RichText::new(label) - .color(egui::Color32::BLUE) - .size(14.0) + .color(tokens::color::LYRICS_CURRENT_LINE) + .size(tokens::text::SM) .strong(), )) } else { - ui.add(egui::Label::new(egui::RichText::new(label))) + ui.add(egui::Label::new( + egui::RichText::new(label).size(tokens::text::SM), + )) }; // Scroll to current line to keep it visible @@ -160,16 +181,18 @@ impl LyricsComponent { } // Add some spacing between lines - ui.add_space(4.0); + ui.add_space(tokens::spacing::SM); } } fn show_plain_lyrics(ui: &mut eframe::egui::Ui, plain_lyrics: &str) { for line in plain_lyrics.lines() { if line.trim().is_empty() { - ui.add_space(8.0); + ui.add_space(tokens::spacing::MD); } else { - ui.add(egui::Label::new(egui::RichText::new(line))); + ui.add(egui::Label::new( + egui::RichText::new(line).size(tokens::text::SM), + )); } } } diff --git a/src/app/components/main_shell.rs b/src/app/components/main_shell.rs index e7cf902..34fe7bf 100644 --- a/src/app/components/main_shell.rs +++ b/src/app/components/main_shell.rs @@ -5,6 +5,7 @@ use super::player_component::PlayerComponent; use super::playlist_content::PlaylistContent; use super::window_chrome::WindowChrome; use super::AppComponent; +use crate::app::style::tokens; use crate::app::App; use eframe::egui; @@ -14,36 +15,52 @@ impl MainShell { pub fn show(app: &mut App, ctx: &egui::Context) { egui::TopBottomPanel::top("Window Chrome") .show_separator_line(true) + .frame( + egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin { + left: tokens::spacing::SM as i8, + right: tokens::spacing::XS as i8, + top: 2, + bottom: 2, + }), + ) .show(ctx, |ui| { WindowChrome::add(app, ui); }); egui::TopBottomPanel::top("Player").show(ctx, |ui| { PlayerComponent::add(app, ui); - ui.add_space(5.0); + ui.add_space(tokens::spacing::XS); }); egui::TopBottomPanel::bottom("Footer").show(ctx, |ui| { Footer::add(app, ui); }); + egui::SidePanel::left("Library Window") + .default_width(200.0) + .show(ctx, |ui| { + LibraryComponent::add(app, ui); + }); + if app.ui_state.show_lyrics_panel { egui::SidePanel::right("Lyrics Panel") - .default_width(300.0) + .default_width(280.0) + .min_width(220.0) .resizable(true) .show(ctx, |ui| { LyricsComponent::add(app, ui); }); } - egui::SidePanel::left("Library Window") - .default_width(200.0) + egui::CentralPanel::default() + // Match SidePanel's default frame so the middle column's + // top inner_margin equals the library / lyrics panels — + // otherwise the central panel adds extra top padding and + // the playlist tab strip's bottom separator lands a few + // pixels below the other two panel separators. + .frame(egui::Frame::side_top_panel(&ctx.style())) .show(ctx, |ui| { - LibraryComponent::add(app, ui); + PlaylistContent::add(app, ui); }); - - egui::CentralPanel::default().show(ctx, |ui| { - PlaylistContent::add(app, ui); - }); } } diff --git a/src/app/components/mod.rs b/src/app/components/mod.rs index e1ef56b..07482b6 100644 --- a/src/app/components/mod.rs +++ b/src/app/components/mod.rs @@ -4,6 +4,7 @@ pub mod language_selector; pub mod library_component; pub mod lyrics_component; pub mod main_shell; +pub mod playback_info_panel; pub mod player_component; pub mod playlist_content; pub mod playlist_table; diff --git a/src/app/components/playback_info_panel.rs b/src/app/components/playback_info_panel.rs new file mode 100644 index 0000000..ab0480a --- /dev/null +++ b/src/app/components/playback_info_panel.rs @@ -0,0 +1,148 @@ +use super::AppComponent; +use crate::app::style::tokens; +use crate::app::App; +use eframe::egui::{self, Align, Layout, RichText}; + +const DESCRIPTION_PREVIEW_LENGTH: usize = 30; + +pub struct PlaybackInfoPanel; + +impl AppComponent for PlaybackInfoPanel { + type Context = App; + + fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { + // Check if we have a playing playlist + let playing_playlist_idx = ctx.app_settings.playing_playlist_idx; + + if let Some(playlist_idx) = playing_playlist_idx { + // Case 1: Playlist is playing + Self::render_playlist_info(ctx, ui, playlist_idx); + } else { + // Case 2: Single track (no playlist) + Self::render_track_info(ctx, ui); + } + } +} + +impl PlaybackInfoPanel { + fn render_playlist_info(ctx: &App, ui: &mut egui::Ui, playlist_idx: usize) { + if let Some(playlist) = ctx.playlists.get(playlist_idx) { + ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { + let weak_color = ui.visuals().weak_text_color(); + + // Playlist name + if let Some(name) = playlist.get_name() { + ui.label( + RichText::new(&name) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Track position + if let Some(selected_track) = &ctx.player_ref().selected_track { + if let Some(pos) = playlist.get_pos(selected_track) { + let total = playlist.tracks.len(); + ui.label( + RichText::new(format!("Track {:02}/{:02}", pos + 1, total)) + .size(tokens::text::SM) + .color(weak_color), + ); + } + } + + // Description preview (first 30 chars) + if let Some(desc) = playlist.description() { + let preview = if desc.chars().count() > DESCRIPTION_PREVIEW_LENGTH { + format!( + "{}...", + desc.chars() + .take(DESCRIPTION_PREVIEW_LENGTH) + .collect::() + ) + } else { + desc.to_string() + }; + ui.label( + RichText::new(preview) + .size(tokens::text::SM) + .color(weak_color), + ); + } + }); + } + } + + fn render_track_info(ctx: &App, ui: &mut egui::Ui) { + let player = ctx.player_ref(); + + if let Some(track) = &player.selected_track { + ui.with_layout(Layout::top_down(Align::RIGHT), |ui| { + let weak_color = ui.visuals().weak_text_color(); + + // Album · Year + let mut album_line = String::new(); + if let Some(album) = track.album() { + album_line.push_str(&album); + } + if let Some(year) = track.year() { + if !album_line.is_empty() { + album_line.push_str(" · "); + } + album_line.push_str(&year.to_string()); + } + if !album_line.is_empty() { + ui.label( + RichText::new(album_line) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Genre + if let Some(genre) = track.genre() { + ui.label( + RichText::new(genre) + .size(tokens::text::SM) + .color(weak_color), + ); + } + + // Format · Sample Rate · Channels + let mut tech_line = String::new(); + if let Some(codec) = &player.codec { + tech_line.push_str(codec); + } + if let Some(sample_rate) = player.sample_rate { + if !tech_line.is_empty() { + tech_line.push_str(" · "); + } + tech_line.push_str(&format!("{:.1}kHz", sample_rate as f32 / 1000.0)); + } + if let Some(channels) = player.channels { + if !tech_line.is_empty() { + tech_line.push_str(" · "); + } + let channel_str = match channels { + 1 => "Mono", + 2 => "Stereo", + _ => { + tech_line.push_str(&format!("{}ch", channels)); + "" + } + }; + if !channel_str.is_empty() { + tech_line.push_str(channel_str); + } + } + if !tech_line.is_empty() { + ui.label( + RichText::new(tech_line) + .size(tokens::text::SM) + .color(weak_color), + ); + } + }); + } + } +} diff --git a/src/app/components/player_component.rs b/src/app/components/player_component.rs index 87b5d90..eedb61e 100644 --- a/src/app/components/player_component.rs +++ b/src/app/components/player_component.rs @@ -1,25 +1,28 @@ use super::cassette_component::CassetteComponent; +use super::playback_info_panel::PlaybackInfoPanel; use super::AppComponent; use crate::app::services::PlayerService; -use crate::app::style::{ButtonExt, SliderExt}; +use crate::app::style::{icons, player_button, tokens, ButtonExt, SliderExt}; use crate::app::t; -use crate::{app::App, app::AudioEvent}; -use eframe::egui::style::HandleShape; -use eframe::egui::{self, vec2}; -use std::time::Instant; +use crate::app::App; +use eframe::egui::{self, vec2, RichText}; pub struct PlayerComponent; -const CASSETTE_WIDTH: f32 = 280.0; - struct SelectedTrackSummary { title: Option, artist: Option, } -// For periodic state saving -thread_local! { - static LAST_SAVE: std::cell::RefCell = std::cell::RefCell::new(Instant::now()); +/// User-driven actions that can be triggered from the player control row. +/// Using an enum (rather than string keys) keeps the UI dispatch type-checked +/// and makes adding new controls a compile-time concern. +enum PlayerAction { + TogglePlayPause, + Previous, + Next, + ToggleMode, + ToggleDesktopLyrics, } impl AppComponent for PlayerComponent { @@ -34,67 +37,8 @@ impl AppComponent for PlayerComponent { return; } - // Process UI commands first - let ui_cmd = { - let player = ctx.player_mut_ref(); - player.ui_rx.try_recv().ok() - }; - - if let Some(new_seek_cmd) = ui_cmd { - match new_seek_cmd { - AudioEvent::CurrentTimestamp(seek_timestamp) => { - // Check if we need to save - let should_save = LAST_SAVE.with(|last_save| { - let elapsed = last_save.borrow().elapsed().as_secs(); - if elapsed > 30 { - *last_save.borrow_mut() = Instant::now(); - true - } else { - false - } - }); - - if should_save { - ctx.update_player_persistence(); - ctx.save_state(); - } - - let player = ctx.player_mut_ref(); - PlayerService::set_seek_to_timestamp(player, seek_timestamp); - } - AudioEvent::TotalTrackDuration(dur) => { - tracing::info!("Received Duration: {}", dur); - let player = ctx.player_mut_ref(); - PlayerService::set_duration(player, dur); - } - AudioEvent::AudioFinished => { - tracing::info!("Track finished, getting next..."); - // Clone playlist before mutable borrow - let playlist_clone = ctx - .app_settings - .current_playlist_idx - .and_then(|idx| ctx.playlists.get(idx).cloned()); - - if let Some(playlist) = playlist_clone { - let player = ctx.player_mut_ref(); - PlayerService::next_track(player, &playlist); - } - ctx.fetch_lyrics_for_current_track(); - } - AudioEvent::PlaybackStateChanged(is_playing) => { - tracing::info!( - "Playback state changed to: {}", - if is_playing { "Playing" } else { "Paused" } - ); - let player = ctx.player_mut_ref(); - if is_playing { - PlayerService::play(player); - } else { - PlayerService::pause(player); - } - } - } - } + // NOTE: Audio events are pumped centrally in `App::update` via + // `pump_audio_events`, so this component is now purely a renderer. // Then collect all necessary data (不可变借用) let ( @@ -139,6 +83,8 @@ impl AppComponent for PlayerComponent { }; let has_selected_track = selected_track.is_some(); + let desktop_lyrics_enabled = ctx.ui_state.desktop_lyrics_enabled; + let is_muted = ctx.ui_state.volume_before_mute.is_some(); // Get playlist tracks info for the current playlist let current_playlist_idx = ctx.app_settings.current_playlist_idx; @@ -147,287 +93,255 @@ impl AppComponent for PlayerComponent { current_playlist_idx.is_some_and(|idx| !ctx.playlists[idx].tracks.is_empty()); // Now render UI without borrowing ctx in closures that also borrow ctx - ui.horizontal(|ui| { - // Call cassette component with separate ctx reference - CassetteComponent::add(ctx, ui); - - // Add minimum width constraint for the vertical layout - let min_width = 200.0; // Minimum width in pixels - let available_width = ui.available_width(); - let panel_width = if available_width > CASSETTE_WIDTH { - available_width - } else { - min_width - }; - - ui.allocate_ui_with_layout( - vec2(panel_width, ui.available_height()), - egui::Layout::top_down(egui::Align::LEFT), - |ui| { - ui.add_space(10.0); // Add margin at the top - - // Show track info if selected, otherwise show default message - if let Some(track) = &selected_track { - let title = track.title.as_deref().unwrap_or("unknown title"); - ui.add( - eframe::egui::Label::new(format!("{}{}", t("song"), title)) - .wrap_mode(eframe::egui::TextWrapMode::Truncate), - ) - .highlight(); - - let artist = track.artist.as_deref().unwrap_or("unknown artist"); - ui.label(format!("{}{}", t("artist"), artist)); - - ui.label(format!("{}{}", t("playlist"), current_playlist_name)); - } else { - // Default display when no track is selected - ui.add( - eframe::egui::Label::new(t("no_track")) - .wrap_mode(eframe::egui::TextWrapMode::Truncate), - ); - - if has_tracks_in_playlist { - ui.label(t("select_track")); - } else if current_playlist_idx.is_some() { - ui.label(t("add_tracks")); + ui.vertical(|ui| { + // ── Top row: cover + track info + playback info ──────────────── + ui.horizontal(|ui| { + CassetteComponent::add(ctx, ui); + + // Calculate available width for middle section (60% of remaining space) + let remaining_width = ui.available_width(); + let middle_width = remaining_width * 0.6; + + ui.allocate_ui_with_layout( + vec2(middle_width, ui.available_height()), + egui::Layout::top_down(egui::Align::LEFT), + |ui| { + if let Some(track) = &selected_track { + let title = track.title.as_deref().unwrap_or("unknown title"); + let artist = track.artist.as_deref().unwrap_or("unknown artist"); + + let format_time = |timestamp: u64| -> String { + let total_seconds = timestamp / 1000; + let minutes = total_seconds / 60; + let seconds = total_seconds % 60; + format!("{:02}:{:02}", minutes, seconds) + }; + + // Title — large + bold, truncated so a long song name + // can never push the rest of the layout down. + ui.add( + egui::Label::new( + RichText::new(title).size(tokens::text::LG).strong(), + ) + .truncate(), + ); + ui.add(egui::Label::new(RichText::new(artist).weak()).truncate()); + // Time on its own line so a long playlist name + // never collides with the elapsed/duration counter. + ui.add( + egui::Label::new( + RichText::new(format!( + "{} / {}", + format_time(seek_to_timestamp), + format_time(duration), + )) + .size(tokens::text::SM) + .weak(), + ) + .truncate(), + ); + if !current_playlist_name.is_empty() { + ui.add( + egui::Label::new( + RichText::new(format!( + "{}: {}", + t("playlist_label"), + current_playlist_name, + )) + .size(tokens::text::SM) + .weak(), + ) + .truncate(), + ); + } } else { - ui.label(t("create_playlist")); + ui.add(egui::Label::new( + RichText::new(t("no_track")).size(tokens::text::LG).strong(), + )); + let hint = if has_tracks_in_playlist { + t("select_track") + } else if current_playlist_idx.is_some() { + t("add_tracks") + } else { + t("create_playlist") + }; + ui.add(egui::Label::new(RichText::new(hint).weak())); } - } - - // Add space to push controls to bottom - ui.add_space(ui.available_height() - 70.0); - - // Time Slider - ui.horizontal(|ui| { - let format_time = |timestamp: u64| -> String { - let total_seconds = timestamp / 1000; - let minutes = total_seconds / 60; - let seconds = total_seconds % 60; - - format!("{:02}:{:02}", minutes, seconds) - }; - - let mut current_seek = seek_to_timestamp; + }, + ); + + // Add playback info panel on the right + ui.allocate_ui_with_layout( + vec2(ui.available_width(), ui.available_height()), + egui::Layout::top_down(egui::Align::RIGHT), + |ui| { + PlaybackInfoPanel::add(ctx, ui); + }, + ); + }); - ui.style_mut().spacing.slider_width = ui.available_width() - 100.0; - ui.style_mut().visuals.slider_trailing_fill = true; - let time_slider = ui.add( - eframe::egui::Slider::new(&mut current_seek, 0..=duration) - .logarithmic(false) - .show_value(false) - .clamping(eframe::egui::SliderClamping::Always) - .trailing_fill(true) - .handle_shape(HandleShape::Rect { aspect_ratio: 0.5 }), - ); + // ── Middle row: full-width timeline scrubber ──────────────────── + ui.scope(|ui| { + ui.style_mut().spacing.slider_rail_height = 2.0; + // Stretch the slider across the entire player panel. + ui.style_mut().spacing.slider_width = ui.available_width() - tokens::spacing::MD; + let mut current_ms = seek_to_timestamp as f64; + let total_ms = (duration.max(1)) as f64; + let resp = ui.add_enabled( + has_selected_track && duration > 0, + egui::Slider::new(&mut current_ms, 0.0..=total_ms) + .show_value(false) + .handle_shape(egui::style::HandleShape::Circle), + ); + if resp.dragged() { + ctx.player_mut_ref().seeking_since = Some(std::time::Instant::now()); + ctx.player_mut_ref().seek_to_timestamp = current_ms as u64; + } else if resp.drag_stopped() { + PlayerService::seek_to(ctx.player_mut_ref(), current_ms as u64); + } else if resp.changed() { + // Click on rail (no drag) + PlayerService::seek_to(ctx.player_mut_ref(), current_ms as u64); + } + }); - // Update in real-time while dragging (just the timestamp, not seeking the audio) - if time_slider.dragged() && has_selected_track { - PlayerService::set_seek_to_timestamp( - ctx.player_mut_ref(), - current_seek, + // ── Bottom row: transport (left) + volume (right) ─────────────── + ui.horizontal(|ui| { + let prev_btn = ui.add_enabled( + has_selected_track, + egui::Button::new(icons::PREV).player_style(), + ); + let play_pause_icon = if is_playing { + icons::PAUSE + } else { + icons::PLAY + }; + let play_pause_btn = ui.add_enabled( + has_selected_track, + player_button(play_pause_icon, is_playing && has_selected_track), + ); + let next_btn = ui.add_enabled( + has_selected_track, + egui::Button::new(icons::NEXT).player_style(), + ); + + let mode_icon = match playback_mode { + crate::app::player::PlaybackMode::Normal => icons::MODE_NORMAL, + crate::app::player::PlaybackMode::Repeat => icons::MODE_REPEAT, + crate::app::player::PlaybackMode::RepeatOne => icons::MODE_REPEAT_ONE, + crate::app::player::PlaybackMode::Shuffle => icons::MODE_SHUFFLE, + }; + let mode_active = + !matches!(playback_mode, crate::app::player::PlaybackMode::Normal); + let mode_btn = + ui.add_enabled(has_selected_track, player_button(mode_icon, mode_active)); + + ui.add_space(tokens::spacing::SM); + let lyrics_btn = + ui.add(player_button(icons::LYRICS_TOGGLE, desktop_lyrics_enabled)); + + // Volume hugs the right edge. + let mute_clicked; + let volume_changed; + let mut current_volume = volume; + let previous_vol = current_volume; + { + let resp = + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.style_mut().spacing.slider_width = tokens::size::SLIDER_VOLUME; + let slider = ui.add( + eframe::egui::Slider::new(&mut current_volume, 0.0_f32..=1.0_f32) + .volume_style(), ); - } - - // Only perform the actual seek when drag is stopped - if time_slider.drag_stopped() && has_selected_track { - let player = ctx.player_mut_ref(); - // We already updated seek_to_timestamp during dragging, - // now actually seek the audio playback - PlayerService::seek_to(player, current_seek); + let mute_icon = if is_muted { + icons::VOLUME_MUTE + } else { + icons::VOLUME + }; + let mute = ui.add(player_button(mute_icon, is_muted)); + (mute.clicked(), slider.dragged() || slider.changed()) + }); + (mute_clicked, volume_changed) = resp.inner; + } - // When seeking, make sure the track state is set to Playing - // This ensures the UI buttons match the actual state - PlayerService::play(player); + // Translate transport clicks into a single action. + let action: Option = if mode_btn.clicked() && has_selected_track { + Some(PlayerAction::ToggleMode) + } else if play_pause_btn.clicked() && has_selected_track { + Some(PlayerAction::TogglePlayPause) + } else if prev_btn.clicked() + && has_selected_track + && ctx.app_settings.playing_playlist_idx.is_some() + { + Some(PlayerAction::Previous) + } else if next_btn.clicked() + && has_selected_track + && ctx.app_settings.playing_playlist_idx.is_some() + { + Some(PlayerAction::Next) + } else if lyrics_btn.clicked() { + Some(PlayerAction::ToggleDesktopLyrics) + } else { + None + }; + + if let Some(action) = action { + let mut fetch_lyrics = false; + match action { + PlayerAction::ToggleMode => { + PlayerService::toggle_playback_mode(ctx.player_mut_ref()); } + PlayerAction::TogglePlayPause => { + if is_playing { + PlayerService::pause(ctx.player_mut_ref()); + } else { + PlayerService::play(ctx.player_mut_ref()); + } + } + PlayerAction::Previous => { + ctx.play_previous_track(); + fetch_lyrics = true; + } + PlayerAction::Next => { + ctx.play_next_track(); + fetch_lyrics = true; + } + PlayerAction::ToggleDesktopLyrics => { + ctx.ui_state.desktop_lyrics_enabled = + !ctx.ui_state.desktop_lyrics_enabled; + } + } + if fetch_lyrics { + ctx.fetch_lyrics_for_current_track(); + } + } - ui.label(format_time(current_seek)); - ui.label("/"); - ui.label(format_time(duration)); - }); - - ui.add_space(10.0); // Add margin at the bottom - - // Play/Pause, Previous, Next, Mode buttons - ui.horizontal(|ui| { - // Create buttons but disable them if no track is selected - let prev_btn = ui.add_enabled( - has_selected_track, - egui::Button::new("|◀").player_style(), - ); - - // Merge play/pause into a single button - let play_pause_btn = ui.add_enabled( - has_selected_track, - egui::Button::new(if is_playing { "⏸" } else { "▶" }).player_style(), - ); - - let next_btn = ui.add_enabled( - has_selected_track, - egui::Button::new("▶|").player_style(), + if volume_changed && current_volume != previous_vol { + ctx.ui_state.volume_before_mute = None; + let is_processing_ui_change = ctx.is_processing_ui_change(); + PlayerService::set_volume( + ctx.player_mut_ref(), + current_volume, + &is_processing_ui_change, + ); + } + if mute_clicked { + let is_processing_ui_change = ctx.is_processing_ui_change(); + if let Some(prev) = ctx.ui_state.volume_before_mute.take() { + PlayerService::set_volume( + ctx.player_mut_ref(), + prev, + &is_processing_ui_change, ); - - let mode_icon = match playback_mode { - crate::app::player::PlaybackMode::Normal => "➡", - crate::app::player::PlaybackMode::Repeat => "🔁", - crate::app::player::PlaybackMode::RepeatOne => "🔂", - crate::app::player::PlaybackMode::Shuffle => "🔀", - }; - - let mode_btn = ui.add_enabled( - has_selected_track, - egui::Button::new(mode_icon).player_style(), + } else { + ctx.ui_state.volume_before_mute = Some(volume); + PlayerService::set_volume( + ctx.player_mut_ref(), + 0.0, + &is_processing_ui_change, ); - - ui.vertical(|ui| { - // small buttons - ui.horizontal(|ui| { - // other small buttons - ui.add_enabled_ui(false, |ui| ui.button("1.0x")); - - if ui.button(t("playlist_btn")).clicked() { - ctx.ui_state.show_library_and_playlist = - !ctx.ui_state.show_library_and_playlist; - // Adjust window height based on visibility - let new_height = if ctx.ui_state.show_library_and_playlist { - ctx.ui_state.default_window_height as f32 - } else { - 200.0 // Compact height when library and playlist are hidden - }; - ui.ctx().send_viewport_cmd(egui::ViewportCommand::InnerSize( - vec2(ui.ctx().screen_rect().width(), new_height), - )); - }; - - if ui.button(t("lyrics")).clicked() { - ctx.ui_state.show_lyrics_panel = - !ctx.ui_state.show_lyrics_panel; - }; - - if ui.button(t("mini")).clicked() { - // Hide library and playlist - ctx.ui_state.show_library_and_playlist = false; - - // Set minimal window size - ui.ctx().send_viewport_cmd(egui::ViewportCommand::InnerSize( - vec2( - 300.0, // Minimal width - 200.0, // Same compact height as 列表 button - ), - )); - }; - - // Only enable the remove button if there's a selected track - if ui - .add_enabled( - has_selected_track, - egui::Button::new(t("remove_song")), - ) - .clicked() - && selected_track.is_some() - { - if let Some(removed_key) = - PlayerService::remove_current_track(ctx.player_mut_ref()) - { - // Remove from playlist if we have a current playlist - if let Some(playlist_idx) = - ctx.app_settings.current_playlist_idx - { - if let Some(playlist) = - ctx.playlists.get_mut(playlist_idx) - { - if let Some(track_position) = - playlist.get_pos_by_key(&removed_key) - { - playlist.remove(track_position); - } - } - } - } - }; - }); - - // volume slider - ui.horizontal(|ui| { - let mut current_volume = volume; - let previous_vol = current_volume; - ui.label("📢"); - ui.style_mut().spacing.slider_width = ui.available_width(); - let volume_slider = ui.add( - eframe::egui::Slider::new( - &mut current_volume, - 0.0_f32..=1.0_f32, - ) - .volume_style(), - ); - - if volume_slider.dragged() { - // Only send if the volume is actually changing - if current_volume != previous_vol { - let is_processing_ui_change = ctx.is_processing_ui_change(); - PlayerService::set_volume( - ctx.player_mut_ref(), - current_volume, - &is_processing_ui_change, - ); - } - } - - // Handle button clicks if a track is selected - let mut fetch_lyrics = false; - if has_selected_track { - // Check which action to take - let mut action = None; - - if mode_btn.clicked() { - action = Some("toggle_mode"); - } else if play_pause_btn.clicked() { - action = Some(if is_playing { "pause" } else { "play" }); - } else if prev_btn.clicked() - && ctx.app_settings.playing_playlist_idx.is_some() - { - action = Some("previous"); - fetch_lyrics = true; - } else if next_btn.clicked() - && ctx.app_settings.playing_playlist_idx.is_some() - { - action = Some("next"); - fetch_lyrics = true; - } - - // Execute the action - if let Some(action) = action { - match action { - "toggle_mode" => { - PlayerService::toggle_playback_mode( - ctx.player_mut_ref(), - ); - } - "pause" => { - PlayerService::pause(ctx.player_mut_ref()); - } - "play" => { - PlayerService::play(ctx.player_mut_ref()); - } - "previous" => { - ctx.play_previous_track(); - } - "next" => { - ctx.play_next_track(); - } - _ => {} - } - } - } - - if fetch_lyrics { - ctx.fetch_lyrics_for_current_track(); - } - }); - }); - }); - }, - ); + } + } + }); }); } } diff --git a/src/app/components/playlist_content.rs b/src/app/components/playlist_content.rs index 0bf42b7..a15ba96 100644 --- a/src/app/components/playlist_content.rs +++ b/src/app/components/playlist_content.rs @@ -2,7 +2,6 @@ use super::playlist_table::PlaylistTable; use super::playlist_tabs::PlaylistTabs; use super::AppComponent; use crate::app::App; -use eframe::egui; pub struct PlaylistContent; @@ -10,13 +9,12 @@ impl AppComponent for PlaylistContent { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - egui::ScrollArea::horizontal() - .auto_shrink([false, true]) - .show(ui, |ui| { - PlaylistTabs::add(ctx, ui); - }); - - ui.add_space(8.0); + // Header (tab strip) is rendered OUTSIDE any ScrollArea so its + // bottom rule stays on the same horizontal seam as the library / + // lyrics panel rules. + crate::app::style::panel_header(ui, |ui| { + PlaylistTabs::add(ctx, ui); + }); if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { ui.push_id(("playlist", current_playlist_idx), |ui| { diff --git a/src/app/components/playlist_table/columns.rs b/src/app/components/playlist_table/columns.rs index 82a514b..bce244e 100644 --- a/src/app/components/playlist_table/columns.rs +++ b/src/app/components/playlist_table/columns.rs @@ -23,7 +23,7 @@ pub(crate) fn render_number_column( ui.set_min_width(column_width); let number_str = format!("{}", idx + 1); - let mut text = egui::RichText::new(number_str).strong(); + let mut text = egui::RichText::new(number_str).size(crate::app::style::tokens::text::SM); if let Some(color) = highlight_color { text = text.color(color); } @@ -81,7 +81,8 @@ pub(crate) fn render_title_column( state.clear_edit(ui); } } else { - let mut text = egui::RichText::new(track_title); + let mut text = + egui::RichText::new(track_title).size(crate::app::style::tokens::text::SM); if let Some(color) = highlight_color { text = text.color(color); } @@ -155,7 +156,8 @@ pub(crate) fn render_artist_column( state.clear_edit(ui); } } else { - let mut text = egui::RichText::new(track_artist); + let mut text = + egui::RichText::new(track_artist).size(crate::app::style::tokens::text::SM); if let Some(color) = highlight_color { text = text.color(color); } @@ -225,7 +227,8 @@ pub(crate) fn render_album_column( state.clear_edit(ui); } } else { - let mut text = egui::RichText::new(track_album); + let mut text = + egui::RichText::new(track_album).size(crate::app::style::tokens::text::SM); if let Some(color) = highlight_color { text = text.color(color); } @@ -278,9 +281,9 @@ pub(crate) fn render_lyrics_column( if has_lyrics { ui.horizontal(|ui| { - ui.label("🎵"); + ui.label(crate::app::style::icons::LYRICS_PRESENT); if ui - .small_button("❌") + .small_button(crate::app::style::icons::CLOSE) .on_hover_text(localization.remove_lyrics()) .clicked() { @@ -295,7 +298,7 @@ pub(crate) fn render_lyrics_column( } }); } else { - ui.label("―"); + ui.label(crate::app::style::icons::LYRICS_NONE); } }); } @@ -333,7 +336,8 @@ pub(crate) fn render_genre_column( state.clear_edit(ui); } } else { - let mut text = egui::RichText::new(track_genre); + let mut text = + egui::RichText::new(track_genre).size(crate::app::style::tokens::text::SM); if let Some(color) = highlight_color { text = text.color(color); } diff --git a/src/app/components/playlist_table/drag.rs b/src/app/components/playlist_table/drag.rs index fbf9af7..95ed52e 100644 --- a/src/app/components/playlist_table/drag.rs +++ b/src/app/components/playlist_table/drag.rs @@ -70,8 +70,11 @@ pub(crate) fn render_drag_feedback( egui::pos2(target_rect.min.x, line_y - 1.0), egui::pos2(target_rect.max.x, line_y + 1.0), ); - ui.painter() - .rect_filled(line_rect, 0.0, egui::Color32::from_rgb(50, 150, 250)); + ui.painter().rect_filled( + line_rect, + 0.0, + crate::app::style::tokens::color::PLAYLIST_DRAG_INSERT_LINE, + ); } else { state.set_drop_target(ui, None); } @@ -90,7 +93,7 @@ pub(crate) fn render_drag_feedback( ui.painter().rect_filled( drag_rect, 4.0, - egui::Color32::from_rgba_premultiplied(100, 100, 180, 200), + crate::app::style::tokens::color::PLAYLIST_DRAG_GHOST_FILL, ); let drag_text = track.title_ref().unwrap_or(fallbacks.title()); diff --git a/src/app/components/playlist_table/row_highlight.rs b/src/app/components/playlist_table/row_highlight.rs index 612d75d..fe146dd 100644 --- a/src/app/components/playlist_table/row_highlight.rs +++ b/src/app/components/playlist_table/row_highlight.rs @@ -1,3 +1,4 @@ +use crate::app::style::tokens; use eframe::egui; pub(crate) fn paint_selection_background(ui: &egui::Ui, row_rect: egui::Rect, is_selected: bool) { @@ -5,6 +6,6 @@ pub(crate) fn paint_selection_background(ui: &egui::Ui, row_rect: egui::Rect, is return; } - let highlight_color = egui::Color32::from_rgba_premultiplied(100, 150, 255, 200); - ui.painter().rect_filled(row_rect, 0.0, highlight_color); + ui.painter() + .rect_filled(row_rect, 0.0, tokens::color::PLAYLIST_ROW_HIGHLIGHT); } diff --git a/src/app/components/playlist_table/view.rs b/src/app/components/playlist_table/view.rs index 4702a6f..b00d301 100644 --- a/src/app/components/playlist_table/view.rs +++ b/src/app/components/playlist_table/view.rs @@ -56,7 +56,7 @@ pub(super) fn render(ctx: &mut App, ui: &mut egui::Ui) { // Cache selection color once instead of fetching on every row let selection_bg_color = ui.style().visuals.selection.bg_fill; - let drag_color = egui::Color32::from_rgb(120, 120, 180); + let drag_color = crate::app::style::tokens::color::PLAYLIST_DRAG_BORDER; let current_track_idx = ctx .player_ref() @@ -82,37 +82,46 @@ pub(super) fn render(ctx: &mut App, ui: &mut egui::Ui) { egui::Grid::new("playlist_full") .striped(true) - .spacing([5.0, 5.0]) + .spacing([ + crate::app::style::tokens::spacing::MD, + crate::app::style::tokens::spacing::XS, + ]) .num_columns(num_columns) .show(ui, |ui| { + let header_color = ui.visuals().weak_text_color(); + let header_text = move |s: &str| { + egui::RichText::new(s) + .size(crate::app::style::tokens::text::SM) + .color(header_color) + }; ui.scope(|ui| { ui.set_min_width(column_widths[0]); - ui.strong(localization.column_number()); + ui.label(header_text(localization.column_number())); }); ui.scope(|ui| { ui.set_min_width(column_widths[1]); - ui.strong(localization.column_title()); + ui.label(header_text(localization.column_title())); }); ui.scope(|ui| { ui.set_min_width(column_widths[2]); - ui.strong(localization.column_artist()); + ui.label(header_text(localization.column_artist())); }); ui.scope(|ui| { ui.set_min_width(column_widths[3]); - ui.strong(localization.column_album()); + ui.label(header_text(localization.column_album())); }); ui.scope(|ui| { ui.set_min_width(column_widths[4]); - ui.strong(localization.column_lyrics()); + ui.label(header_text(localization.column_lyrics())); }); ui.scope(|ui| { ui.set_min_width(column_widths[5]); - ui.strong(localization.column_genre()); + ui.label(header_text(localization.column_genre())); }); ui.end_row(); diff --git a/src/app/components/playlist_tabs.rs b/src/app/components/playlist_tabs.rs index 6235bfc..a38ed7e 100644 --- a/src/app/components/playlist_tabs.rs +++ b/src/app/components/playlist_tabs.rs @@ -1,5 +1,6 @@ use super::AppComponent; use crate::app::services::PlaylistService; +use crate::app::style::{icons, tokens}; use crate::app::t; use crate::app::App; use eframe::egui; @@ -10,95 +11,105 @@ impl AppComponent for PlaylistTabs { type Context = App; fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { - ui.horizontal(|ui| { - // Add playlist tabs - for (idx, playlist) in ctx.playlists.iter_mut().enumerate() { - let is_selected = ctx.app_settings.current_playlist_idx == Some(idx); - let is_being_renamed = ctx.ui_state.playlist_being_renamed == Some(idx); + // Caller (PlaylistContent) wraps us in style::panel_header which + // already provides ui.horizontal + min_height — render directly. + // Add playlist tabs + for (idx, playlist) in ctx.playlists.iter_mut().enumerate() { + let is_selected = ctx.app_settings.current_playlist_idx == Some(idx); + let is_being_renamed = ctx.ui_state.playlist_being_renamed == Some(idx); - if is_being_renamed { - // Show text input for renaming - let mut name = playlist.get_name().unwrap_or_default(); - let response = ui.add( - egui::TextEdit::singleline(&mut name) - .desired_width(120.0) - .hint_text(t("enter_name")), - ); + if is_being_renamed { + // Show text input for renaming + let mut name = playlist.get_name().unwrap_or_default(); + let response = ui.add( + egui::TextEdit::singleline(&mut name) + .desired_width(120.0) + .hint_text(t("enter_name")), + ); - if response.changed() { - playlist.set_name(name.clone()); - } + if response.changed() { + playlist.set_name(name.clone()); + } - if response.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - if !name.is_empty() { - playlist.set_name(name); - } - PlaylistService::finish_renaming_playlist_ui( - &mut ctx.ui_state.playlist_being_renamed, - ); + if response.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + if !name.is_empty() { + playlist.set_name(name); } + PlaylistService::finish_renaming_playlist_ui( + &mut ctx.ui_state.playlist_being_renamed, + ); + } + } else { + // Show normal tab button. Selected tab paints with the + // brand fill (consumed via Visuals::selection) so it + // matches the rest of the highlighted-state language used + // by the player; unselected tabs render flat to keep the + // tab strip from looking like a row of buttons. + let mut tab_text = egui::RichText::new(playlist.get_name().unwrap_or_default()) + .size(tokens::text::SM); + if is_selected { + tab_text = tab_text.color(egui::Color32::WHITE); + } + + let mut button = egui::Button::new(tab_text).corner_radius(tokens::radius::SM); + if is_selected { + button = button.fill(tokens::color::BRAND).stroke(egui::Stroke::new( + tokens::size::STROKE_WIDTH, + tokens::color::BRAND_ACTIVE, + )); } else { - // Show normal tab button - let mut tab_text = - egui::RichText::new(playlist.get_name().unwrap_or_default()).size(12.0); - if is_selected { - tab_text = tab_text.strong(); - } + button = button.fill(egui::Color32::TRANSPARENT); + } + let tab_response = ui.add(button); - let tab_response = ui.add(egui::Button::new(tab_text).fill(if is_selected { - ui.style().visuals.selection.bg_fill - } else { - ui.style().visuals.widgets.inactive.bg_fill - })); + if tab_response.clicked() { + PlaylistService::select_playlist( + &mut ctx.app_settings.current_playlist_idx, + idx, + ); + } - if tab_response.clicked() { - PlaylistService::select_playlist( - &mut ctx.app_settings.current_playlist_idx, + // Show context menu on right-click + tab_response.context_menu(|ui| { + if ui.button(t("rename")).clicked() { + PlaylistService::start_renaming_playlist( + &mut ctx.ui_state.playlist_being_renamed, idx, ); + ui.close_menu(); } - - // Show context menu on right-click - tab_response.context_menu(|ui| { - if ui.button(t("rename")).clicked() { - PlaylistService::start_renaming_playlist( - &mut ctx.ui_state.playlist_being_renamed, - idx, - ); - ui.close_menu(); - } - if ui.button(t("delete")).clicked() { - ctx.ui_state.playlist_idx_to_remove = Some(idx); - ui.close_menu(); - } - }); - } + if ui.button(t("delete")).clicked() { + ctx.ui_state.playlist_idx_to_remove = Some(idx); + ui.close_menu(); + } + }); } + } - // Add the "+" button for creating new playlists - let create_btn = ui.add(egui::Button::new(egui::RichText::new("+").size(12.0))); + // Add the "+" button for creating new playlists — borderless to + // match the library's add-folder affordance. + let create_btn = ui.add(egui::Button::new(icons::PLUS).frame(false)); - if create_btn.clicked() { - PlaylistService::create_playlist( - &mut ctx.playlists, - &mut ctx.app_settings.current_playlist_idx, - &mut ctx.ui_state.playlist_being_renamed, - t("new_playlist").to_string(), - ); - } + if create_btn.clicked() { + PlaylistService::create_playlist( + &mut ctx.playlists, + &mut ctx.app_settings.current_playlist_idx, + &mut ctx.ui_state.playlist_being_renamed, + t("new_playlist").to_string(), + ); + } - // Handle playlist removal - if let Some(idx) = ctx.ui_state.playlist_idx_to_remove { - ctx.ui_state.playlist_idx_to_remove = None; + // Handle playlist removal + if let Some(idx) = ctx.ui_state.playlist_idx_to_remove { + ctx.ui_state.playlist_idx_to_remove = None; - let db_conn = ctx.db().connection(); - PlaylistService::delete_playlist( - &mut ctx.playlists, - &mut ctx.app_settings.current_playlist_idx, - idx, - &db_conn, - ); - } - }); + let db_conn = ctx.db().connection(); + PlaylistService::delete_playlist( + &mut ctx.playlists, + &mut ctx.app_settings.current_playlist_idx, + idx, + &db_conn, + ); + } } } diff --git a/src/app/components/window_chrome.rs b/src/app/components/window_chrome.rs index 09abd0f..a4cb7be 100644 --- a/src/app/components/window_chrome.rs +++ b/src/app/components/window_chrome.rs @@ -14,6 +14,11 @@ impl AppComponent for WindowChrome { fn add(ctx: &mut Self::Context, ui: &mut eframe::egui::Ui) { ui.horizontal(|ui| { + // Render the menu triggers (文件 / 播放 / ...) as borderless, + // hover-tinted buttons so they share a visual language with the + // window control buttons on the right edge of the same row. + crate::app::style::borderless_button_visuals(ui.visuals_mut()); + // Menu list ui.menu_button(t("file"), |ui| { if ui.button(t("open")).clicked() { @@ -90,10 +95,18 @@ impl AppComponent for WindowChrome { ui.separator(); // Show current play mode in the menu let mode_icon = match player.playback_mode { - crate::app::player::PlaybackMode::Normal => "➡", - crate::app::player::PlaybackMode::Repeat => "🔁", - crate::app::player::PlaybackMode::RepeatOne => "🔂", - crate::app::player::PlaybackMode::Shuffle => "🔀", + crate::app::player::PlaybackMode::Normal => { + crate::app::style::icons::MODE_NORMAL + } + crate::app::player::PlaybackMode::Repeat => { + crate::app::style::icons::MODE_REPEAT + } + crate::app::player::PlaybackMode::RepeatOne => { + crate::app::style::icons::MODE_REPEAT_ONE + } + crate::app::player::PlaybackMode::Shuffle => { + crate::app::style::icons::MODE_SHUFFLE + } }; if ui .button(crate::app::tf("play_mode", &[mode_icon])) @@ -115,7 +128,10 @@ impl AppComponent for WindowChrome { let _ = ui.button(t("previous")); let _ = ui.button(t("next")); ui.separator(); - let _ = ui.button(crate::app::tf("play_mode", &["➡"])); + let _ = ui.button(crate::app::tf( + "play_mode", + &[crate::app::style::icons::MODE_NORMAL], + )); }); } } @@ -136,6 +152,36 @@ impl AppComponent for WindowChrome { ctx.ui_state.show_lyrics_panel = !ctx.ui_state.show_lyrics_panel; ui.close_menu(); } + + ui.separator(); + + ui.menu_button("Desktop Lyrics", |ui| { + ui.checkbox(&mut ctx.ui_state.desktop_lyrics_enabled, "Enabled"); + ui.checkbox(&mut ctx.ui_state.desktop_lyrics_locked, "Locked"); + ui.horizontal(|ui| { + ui.label("Font size"); + ui.add( + egui::Slider::new( + &mut ctx.ui_state.desktop_lyrics_font_size, + 16.0..=120.0, + ) + .step_by(1.0), + ); + }); + ui.horizontal(|ui| { + ui.label("Color"); + let mut srgba = egui::Color32::from_rgba_unmultiplied( + ctx.ui_state.desktop_lyrics_color[0], + ctx.ui_state.desktop_lyrics_color[1], + ctx.ui_state.desktop_lyrics_color[2], + ctx.ui_state.desktop_lyrics_color[3], + ); + if ui.color_edit_button_srgba(&mut srgba).changed() { + ctx.ui_state.desktop_lyrics_color = + [srgba.r(), srgba.g(), srgba.b(), srgba.a()]; + } + }); + }); }); ui.menu_button(t("help"), |ui| { @@ -150,37 +196,77 @@ impl AppComponent for WindowChrome { // Take up remaining space ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Window operation buttons - let button_size = egui::vec2(30.0, 20.0); + use crate::app::style::icons; + + // Helper: a borderless chrome button with a subtle hover fill. + // `danger_hover` makes the hover state read red (used on close). + // + // We deliberately avoid `Button::fill()` / `Button::stroke()` — + // those overrides apply to every state (egui resolves them via + // `style.interact(&response)`), which is what made the close + // glyph vanish before: a hard-coded transparent fill stomped + // the red hover background, while `hovered.fg_stroke = WHITE` + // turned the X into white-on-white. Driving the colours through + // `WidgetVisuals` instead lets the inactive state stay clean + // and the hovered state pick up the danger / theme fill. + let chrome_button = |ui: &mut egui::Ui, + glyph: &str, + danger_hover: bool| + -> egui::Response { + let size = egui::vec2(32.0, 22.0); + let base_text = ui.visuals().widgets.inactive.fg_stroke.color; + let hover_fill = if danger_hover { + Color32::from_rgb(232, 17, 35) + } else { + ui.visuals().widgets.hovered.weak_bg_fill + }; + let hover_text = if danger_hover { + Color32::WHITE + } else { + ui.visuals().widgets.hovered.fg_stroke.color + }; + ui.scope(|ui| { + let widgets = &mut ui.visuals_mut().widgets; + // Inactive: fully transparent so the title bar shines through. + widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + widgets.inactive.bg_fill = Color32::TRANSPARENT; + widgets.inactive.bg_stroke = egui::Stroke::NONE; + widgets.inactive.fg_stroke.color = base_text; + // Hovered: tinted background, white glyph for danger. + widgets.hovered.weak_bg_fill = hover_fill; + widgets.hovered.bg_fill = hover_fill; + widgets.hovered.bg_stroke = egui::Stroke::NONE; + widgets.hovered.fg_stroke.color = hover_text; + // Active (mouse-down): keep same colour as hovered for stability. + widgets.active.weak_bg_fill = hover_fill; + widgets.active.bg_fill = hover_fill; + widgets.active.bg_stroke = egui::Stroke::NONE; + widgets.active.fg_stroke.color = hover_text; + ui.add(egui::Button::new(RichText::new(glyph).size(14.0)).min_size(size)) + }) + .inner + }; - // Close button with hover detection - let close_btn = egui::Button::new("x").min_size(button_size); - let close_response = ui.add(close_btn.fill(Color32::TRANSPARENT)); - if close_response.clicked() { + // Close — red on hover. + if chrome_button(ui, icons::WINDOW_CLOSE, true).clicked() { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); } - // Maximize button - let maximize_response = ui.add( - egui::Button::new(RichText::new("↗").size(14.0)) - .min_size(button_size) - .fill(Color32::TRANSPARENT), - ); - if maximize_response.clicked() { - // Toggle maximize + // Maximize / restore — swap the icon based on current state. + let maximize_icon = if ctx.ui_state.is_maximized { + icons::WINDOW_RESTORE + } else { + icons::WINDOW_MAXIMIZE + }; + if chrome_button(ui, maximize_icon, false).clicked() { + ctx.ui_state.is_maximized = !ctx.ui_state.is_maximized; ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized( - !ctx.ui_state.is_maximized, + ctx.ui_state.is_maximized, )); - ctx.ui_state.is_maximized = !ctx.ui_state.is_maximized; } - // Minimize button - let minimize_response = ui.add( - egui::Button::new(RichText::new("−").size(14.0)) - .min_size(button_size) - .fill(Color32::TRANSPARENT), - ); - if minimize_response.clicked() { + // Minimize. + if chrome_button(ui, icons::WINDOW_MINIMIZE, false).clicked() { ui.ctx() .send_viewport_cmd(egui::ViewportCommand::Minimized(true)); } diff --git a/src/app/core.rs b/src/app/core.rs index d2b07f2..39788c8 100644 --- a/src/app/core.rs +++ b/src/app/core.rs @@ -263,7 +263,8 @@ impl App { } // Spawns a background thread and imports files from a library path - pub fn import_library_paths(&self, lib_path: &LibraryPath) { + pub fn import_library_paths(&mut self, lib_path: &LibraryPath) { + self.ui_state.is_importing = true; let lib_cmd_tx = self.lib_cmd_tx().clone(); let album_art_dir = App::get_album_art_dir(); @@ -368,6 +369,92 @@ impl App { } } + /// Drain pending audio events from the player thread and apply them to + /// player / UI state. Called once per frame from `App::update`, so UI + /// components can render without owning event-loop logic. + pub fn pump_audio_events(&mut self) { + if self.runtime.is_none() { + return; + } + + // Drain in one pass to keep the borrow short + let cmds: Vec = { + let player = self.player_mut_ref(); + let mut buf = Vec::new(); + while let Ok(cmd) = player.ui_rx.try_recv() { + buf.push(cmd); + } + buf + }; + + if cmds.is_empty() { + return; + } + + use crate::app::services::PlayerService; + use crate::app::AudioEvent; + + for cmd in cmds { + match cmd { + AudioEvent::CurrentTimestamp(seek_timestamp) => { + let player = self.player_mut_ref(); + if let Some(since) = player.seeking_since { + if since.elapsed().as_millis() > 300 { + player.seeking_since = None; + player.seek_to_timestamp = seek_timestamp; + } + } else { + player.seek_to_timestamp = seek_timestamp; + } + + // Throttle player-state persistence to once every 30s while playing + let elapsed = self.ui_state.last_persistence_save.elapsed().as_secs(); + if elapsed > 30 { + self.ui_state.last_persistence_save = std::time::Instant::now(); + self.update_player_persistence(); + self.save_state(); + } + } + AudioEvent::TotalTrackDuration(dur) => { + tracing::info!("Received Duration: {}", dur); + PlayerService::set_duration(self.player_mut_ref(), dur); + } + AudioEvent::AudioFinished => { + tracing::info!("Track finished, getting next..."); + let playlist_clone = self + .app_settings + .current_playlist_idx + .and_then(|idx| self.playlists.get(idx).cloned()); + if let Some(playlist) = playlist_clone { + PlayerService::next_track(self.player_mut_ref(), &playlist); + } + self.fetch_lyrics_for_current_track(); + } + AudioEvent::PlaybackStateChanged(is_playing) => { + tracing::info!( + "Playback state changed to: {}", + if is_playing { "Playing" } else { "Paused" } + ); + if is_playing { + PlayerService::play(self.player_mut_ref()); + } else { + PlayerService::pause(self.player_mut_ref()); + } + } + AudioEvent::TechnicalInfo { + sample_rate, + channels, + codec, + } => { + let player = self.player_mut_ref(); + player.sample_rate = sample_rate; + player.channels = channels; + player.codec = codec; + } + } + } + } + /// Process a freshly received lyrics response. pub fn handle_lyrics_response(&mut self, should_show_panel: bool) { let track_key = self @@ -404,6 +491,11 @@ impl App { } pub fn process_library_command(&mut self, lib_cmd: LibraryCommand) { + if matches!(lib_cmd, LibraryCommand::AddPathId(_)) { + self.ui_state.is_importing = false; + // Also explicitly save state after completing an import! + self.save_state(); + } LibraryService::process_library_command(&mut self.library, lib_cmd); } diff --git a/src/app/db.rs b/src/app/db.rs index a669b6a..546c715 100644 --- a/src/app/db.rs +++ b/src/app/db.rs @@ -8,7 +8,7 @@ pub struct Database { impl Database { // The current schema version - increment this when making schema changes - const SCHEMA_VERSION: i32 = 4; + const SCHEMA_VERSION: i32 = 5; pub fn new() -> Result { // Get the app's configuration directory @@ -69,6 +69,54 @@ impl Database { return Ok(()); } + // Run migrations for incremental schema updates + if current_version == 4 && Self::SCHEMA_VERSION >= 5 { + tracing::info!("Running database migration from version 4 to 5"); + + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + // Check which columns already exist + let mut stmt = connection.prepare("PRAGMA table_info(playlists)")?; + let existing_columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()?; + + // Add new columns only if they don't exist + if !existing_columns.contains(&"description".to_string()) { + connection.execute("ALTER TABLE playlists ADD COLUMN description TEXT", [])?; + } + + if !existing_columns.contains(&"created_at".to_string()) { + connection.execute( + "ALTER TABLE playlists ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + + if !existing_columns.contains(&"updated_at".to_string()) { + connection.execute( + "ALTER TABLE playlists ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0", + [], + )?; + } + + // Set timestamps for existing playlists (only if they're still at default 0) + connection.execute( + "UPDATE playlists SET created_at = ?1, updated_at = ?1 WHERE created_at = 0", + [current_time], + )?; + + // Update schema version + connection.execute("UPDATE schema_version SET version = 5", [])?; + + tracing::info!("Database migration to version 5 completed"); + + return Ok(()); + } + // Drop existing tables if they exist to reset the schema Self::drop_tables_if_exist(connection)?; @@ -126,7 +174,10 @@ impl Database { connection.execute( "CREATE TABLE IF NOT EXISTS playlists ( id INTEGER PRIMARY KEY, - name TEXT + name TEXT, + description TEXT, + created_at INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL DEFAULT 0 )", [], )?; diff --git a/src/app/font.rs b/src/app/font.rs index b715902..a202e1b 100644 --- a/src/app/font.rs +++ b/src/app/font.rs @@ -40,6 +40,10 @@ pub fn setup_fonts() -> FontDefinitions { load_fallback_font(&mut fonts, &source); } + // Register the Phosphor icon font as a fallback so the icon constants in + // `style::icons` render in the same labels/buttons as ordinary text. + egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); + fonts } diff --git a/src/app/i18n.rs b/src/app/i18n.rs index 2d74c41..c2dc484 100644 --- a/src/app/i18n.rs +++ b/src/app/i18n.rs @@ -116,6 +116,7 @@ pub fn init() { en.insert("delete".to_string(), "Delete".to_string()); en.insert("new_playlist".to_string(), "New Playlist".to_string()); en.insert("enter_name".to_string(), "Enter name...".to_string()); + en.insert("playlist_label".to_string(), "Playlist".to_string()); // Playlist table component en.insert("column_number".to_string(), "#".to_string()); @@ -208,6 +209,7 @@ pub fn init() { zh.insert("delete".to_string(), "删除".to_string()); zh.insert("new_playlist".to_string(), "新播放列表".to_string()); zh.insert("enter_name".to_string(), "输入名称...".to_string()); + zh.insert("playlist_label".to_string(), "播放列表".to_string()); // Playlist table component zh.insert("column_number".to_string(), "#".to_string()); diff --git a/src/app/services/player_service.rs b/src/app/services/player_service.rs index 393c9e7..f1275f2 100644 --- a/src/app/services/player_service.rs +++ b/src/app/services/player_service.rs @@ -43,33 +43,16 @@ impl PlayerService { } /// Seek to a specific timestamp in the current track + #[allow(dead_code)] pub fn seek_to(player: &mut Player, timestamp: u64) { player.seek_to(timestamp); } - /// Set the seek timestamp (for UI updates without immediate seeking) - pub fn set_seek_to_timestamp(player: &mut Player, timestamp: u64) { - player.set_seek_to_timestamp(timestamp); - } - /// Set the total duration of the current track pub fn set_duration(player: &mut Player, duration: u64) { player.set_duration(duration); } - /// Remove the currently selected track from the playlist and handle playback continuation - /// Returns the track key that was removed, or None if no track was removed - pub fn remove_current_track(player: &mut Player) -> Option { - if let Some(track) = &player.selected_track { - let track_key = track.key(); - // Clear the selected track - the playlist removal will be handled by the caller - player.select_track(None); - Some(track_key) - } else { - None - } - } - /// Get the current playback state pub fn is_playing(player: &Player) -> bool { matches!(player.track_state, crate::app::player::TrackState::Playing) diff --git a/src/app/state/ui_state.rs b/src/app/state/ui_state.rs index cc7a283..a7b6f1c 100644 --- a/src/app/state/ui_state.rs +++ b/src/app/state/ui_state.rs @@ -1,11 +1,10 @@ use crate::app::libstate::lyrics_state::LyricsFetchState; use serde::{Deserialize, Serialize}; +use std::time::Instant; + /// UI-specific state that doesn't need to be persisted #[derive(Debug, Clone)] pub struct UiState { - /// Whether to show the library and playlist panel - pub show_library_and_playlist: bool, - /// Whether the library folders section is expanded pub library_folders_expanded: bool, @@ -27,6 +26,9 @@ pub struct UiState { /// Whether the lyrics panel is shown pub show_lyrics_panel: bool, + /// Whether the desktop lyrics mode is active + pub desktop_lyrics_enabled: bool, + /// Whether to fetch lyrics on init (after heavy data loaded) pub should_fetch_lyrics_on_init: bool, @@ -35,12 +37,32 @@ pub struct UiState { /// Last window title to avoid redundant updates pub last_window_title: Option, + + /// Whether library import is currently running + pub is_importing: bool, + + /// Volume value to restore when the user un-mutes via the speaker icon. + /// `None` while not muted. Not serialized: a fresh launch always starts + /// with whatever volume the player itself remembers. + pub volume_before_mute: Option, + + /// Last time player persistence was flushed (used to throttle disk writes + /// while a track is playing). Not serialized. + pub last_persistence_save: Instant, + + /// Font size for the desktop lyrics overlay. + pub desktop_lyrics_font_size: f32, + + /// Foreground color for the desktop lyrics overlay (sRGBA). + pub desktop_lyrics_color: [u8; 4], + + /// When true, the desktop lyrics overlay ignores drag input. + pub desktop_lyrics_locked: bool, } impl Default for UiState { fn default() -> Self { Self { - show_library_and_playlist: true, library_folders_expanded: false, show_about_dialog: false, playlist_idx_to_remove: None, @@ -48,9 +70,16 @@ impl Default for UiState { default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, is_maximized: false, show_lyrics_panel: false, + desktop_lyrics_enabled: false, should_fetch_lyrics_on_init: false, lyrics_fetch_state: LyricsFetchState::Idle, last_window_title: None, + is_importing: false, + volume_before_mute: None, + last_persistence_save: Instant::now(), + desktop_lyrics_font_size: 48.0, + desktop_lyrics_color: [0, 255, 255, 255], + desktop_lyrics_locked: false, } } } @@ -61,6 +90,21 @@ pub struct UiSettings { pub library_folders_expanded: bool, pub default_window_height: f64, pub show_lyrics_panel: bool, + pub desktop_lyrics_enabled: bool, + #[serde(default = "default_desktop_lyrics_font_size")] + pub desktop_lyrics_font_size: f32, + #[serde(default = "default_desktop_lyrics_color")] + pub desktop_lyrics_color: [u8; 4], + #[serde(default)] + pub desktop_lyrics_locked: bool, +} + +fn default_desktop_lyrics_font_size() -> f32 { + 48.0 +} + +fn default_desktop_lyrics_color() -> [u8; 4] { + [0, 255, 255, 255] } impl Default for UiSettings { @@ -69,6 +113,10 @@ impl Default for UiSettings { library_folders_expanded: false, default_window_height: crate::app::constants::DEFAULT_WINDOW_HEIGHT as f64, show_lyrics_panel: false, + desktop_lyrics_enabled: false, + desktop_lyrics_font_size: default_desktop_lyrics_font_size(), + desktop_lyrics_color: default_desktop_lyrics_color(), + desktop_lyrics_locked: false, } } } @@ -80,6 +128,10 @@ impl UiState { library_folders_expanded: self.library_folders_expanded, default_window_height: self.default_window_height, show_lyrics_panel: self.show_lyrics_panel, + desktop_lyrics_enabled: self.desktop_lyrics_enabled, + desktop_lyrics_font_size: self.desktop_lyrics_font_size, + desktop_lyrics_color: self.desktop_lyrics_color, + desktop_lyrics_locked: self.desktop_lyrics_locked, } } @@ -88,5 +140,9 @@ impl UiState { self.library_folders_expanded = settings.library_folders_expanded; self.default_window_height = settings.default_window_height; self.show_lyrics_panel = settings.show_lyrics_panel; + self.desktop_lyrics_enabled = settings.desktop_lyrics_enabled; + self.desktop_lyrics_font_size = settings.desktop_lyrics_font_size; + self.desktop_lyrics_color = settings.desktop_lyrics_color; + self.desktop_lyrics_locked = settings.desktop_lyrics_locked; } } diff --git a/src/app/style/icons.rs b/src/app/style/icons.rs new file mode 100644 index 0000000..45db180 --- /dev/null +++ b/src/app/style/icons.rs @@ -0,0 +1,44 @@ +//! Icon glyph constants. +//! +//! All icons resolve to glyphs from the bundled Phosphor regular font, which +//! is registered in `font::setup_fonts`. Keeping every icon string in one +//! place lets us swap variants (regular / fill / bold) globally by editing +//! this file alone. + +use egui_phosphor::regular as p; + +// ─── Playback transport ────────────────────────────────────────────────── +pub const PREV: &str = p::SKIP_BACK; +pub const NEXT: &str = p::SKIP_FORWARD; +pub const PLAY: &str = p::PLAY; +pub const PAUSE: &str = p::PAUSE; + +// ─── Playback modes ────────────────────────────────────────────────────── +pub const MODE_NORMAL: &str = p::ARROW_RIGHT; +pub const MODE_REPEAT: &str = p::REPEAT; +pub const MODE_REPEAT_ONE: &str = p::REPEAT_ONCE; +pub const MODE_SHUFFLE: &str = p::SHUFFLE; + +// ─── Misc controls ─────────────────────────────────────────────────────── +pub const VOLUME: &str = p::SPEAKER_HIGH; +pub const VOLUME_MUTE: &str = p::SPEAKER_X; +/// Toggles the desktop-lyrics floating viewport. +pub const LYRICS_TOGGLE: &str = p::MICROPHONE_STAGE; +pub const SEARCH: &str = p::MAGNIFYING_GLASS; +pub const CLOSE: &str = p::X; +/// Generic "add / new" affordance — used by library/playlist "+" buttons. +pub const PLUS: &str = p::PLUS; +/// Indicator shown in the playlist's lyrics column when a track has lyrics. +pub const LYRICS_PRESENT: &str = p::MUSIC_NOTES; + +// ─── Lyrics type indicators ────────────────────────────────────────────── +pub const LYRICS_SYNCED: &str = p::MICROPHONE; +pub const LYRICS_PLAIN: &str = p::NOTE_PENCIL; +pub const LYRICS_INSTRUMENTAL: &str = p::GUITAR; +pub const LYRICS_NONE: &str = p::MINUS; + +// ─── Window chrome ─────────────────────────────────────────────────────── +pub const WINDOW_MINIMIZE: &str = p::MINUS; +pub const WINDOW_MAXIMIZE: &str = p::CORNERS_OUT; +pub const WINDOW_RESTORE: &str = p::CORNERS_IN; +pub const WINDOW_CLOSE: &str = p::X; diff --git a/src/app/style/mod.rs b/src/app/style/mod.rs index fec79dc..9cb9c1b 100644 --- a/src/app/style/mod.rs +++ b/src/app/style/mod.rs @@ -1,18 +1,62 @@ use eframe::egui::{style::HandleShape, vec2, Button, Color32, Slider, Stroke}; +pub mod icons; +pub mod tokens; + pub trait ButtonExt { fn player_style(self) -> Self; } impl ButtonExt for Button<'_> { + /// Default styling for the player's icon buttons. Inherits stroke / fill + /// from the active egui theme so the button reads in both light and dark + /// modes. For an *active* (toggled-on) variant, use [`player_button`]. fn player_style(self) -> Self { - self.min_size(vec2(40.0, 40.0)) + self.min_size(vec2(tokens::size::ICON_BTN, tokens::size::ICON_BTN)) .fill(Color32::TRANSPARENT) - .stroke(Stroke::new(1.0, Color32::BLACK)) - .corner_radius(5.0) + .corner_radius(tokens::radius::SM) + } +} + +/// Convenience constructor for player icon buttons that need an *active* +/// state visual (e.g. desktop-lyrics on, non-default playback mode). When +/// `active` is true the button is filled with the brand colour and gets a +/// matching stroke; when false it falls back to the regular `player_style`. +pub fn player_button(label: &str, active: bool) -> Button<'_> { + let btn = Button::new(label).player_style(); + if active { + btn.fill(tokens::color::BRAND).stroke(Stroke::new( + tokens::size::STROKE_WIDTH, + tokens::color::BRAND_ACTIVE, + )) + } else { + btn } } +/// Apply brand-aware visual tweaks on top of the default egui visuals. +/// Used at boot via `egui_ctx.style_mut(...)` — see `bootstrap.rs`. +pub fn apply_brand_visuals(visuals: &mut eframe::egui::Visuals) { + visuals.selection.bg_fill = tokens::color::BRAND; + visuals.selection.stroke.color = tokens::color::BRAND_ACTIVE; +} + +/// Tighten egui's default whitespace so the UI reads as compact rather than +/// roomy. The defaults (item_spacing 8×3, button_padding 4×1, indent 18) are +/// tuned for desktop apps with low information density; for a music player +/// with lists, tabs, and a control band stacked vertically those defaults +/// translate to a lot of empty pixels. We pull them in by ~25 % so rows in +/// the library / playlist table sit closer together and the player band +/// stops dominating the viewport. +pub fn apply_compact_spacing(spacing: &mut eframe::egui::style::Spacing) { + spacing.item_spacing = eframe::egui::vec2(tokens::spacing::SM + 2.0, tokens::spacing::XS + 1.0); + spacing.button_padding = eframe::egui::vec2(tokens::spacing::SM + 2.0, tokens::spacing::XS); + spacing.menu_margin = + eframe::egui::Margin::symmetric(tokens::spacing::SM as i8, tokens::spacing::XS as i8); + spacing.indent = 14.0; + spacing.interact_size.y = 22.0; +} + pub trait SliderExt { fn volume_style(self) -> Self; } @@ -26,3 +70,41 @@ impl SliderExt for Slider<'_> { .handle_shape(HandleShape::Rect { aspect_ratio: 0.3 }) } } + +/// Mutate the current `Visuals` so any button rendered inside the calling +/// scope reads as borderless: transparent inactive, soft hover fill, no +/// outline. Used for chrome menus and accent buttons (e.g. lyrics upload) +/// so they match the borderless treatment we already apply to the window +/// chrome buttons. +pub fn borderless_button_visuals(visuals: &mut eframe::egui::Visuals) { + let widgets = &mut visuals.widgets; + let hover_fill = widgets.hovered.weak_bg_fill; + widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; + widgets.inactive.bg_fill = Color32::TRANSPARENT; + widgets.inactive.bg_stroke = Stroke::NONE; + widgets.hovered.weak_bg_fill = hover_fill; + widgets.hovered.bg_fill = hover_fill; + widgets.hovered.bg_stroke = Stroke::NONE; + widgets.active.bg_stroke = Stroke::NONE; +} + +/// Render a compact panel header row whose bottom rule extends edge-to-edge +/// across its parent panel. egui's `Frame::side_top_panel` (used by all +/// three columns since the central-panel fix) has an 8 px horizontal +/// `inner_margin`; a vanilla `ui.separator()` therefore stops 8 px short of +/// each panel border. `Separator::grow(8.0)` cancels exactly that inset so +/// the rule “seals” the header to the panel edges and the three column +/// header lines read as one continuous horizontal seam. +pub fn panel_header( + ui: &mut eframe::egui::Ui, + add_contents: impl FnOnce(&mut eframe::egui::Ui) -> R, +) -> R { + let inner = ui + .horizontal(|ui| { + ui.set_min_height(tokens::size::HEADER_HEIGHT); + add_contents(ui) + }) + .inner; + ui.add(eframe::egui::Separator::default().grow(8.0).spacing(0.0)); + inner +} diff --git a/src/app/style/tokens.rs b/src/app/style/tokens.rs new file mode 100644 index 0000000..b1f1797 --- /dev/null +++ b/src/app/style/tokens.rs @@ -0,0 +1,95 @@ +//! Design tokens — single source of truth for spacing, sizing, radii, and colours. +//! +//! Phase 1 of the UI redesign extracts every magic number / literal colour +//! into this module. Visual output is unchanged: every constant carries the +//! exact value previously hard-coded at its call site. Later phases will: +//! - swap the brand palette into theme-aware places (current line, buttons) +//! - replace emoji icon literals (see `icons.rs`) with a real icon font +//! - tighten the `spacing::*` scale once usage settles + +use eframe::egui::Color32; + +/// Whitespace scale (in egui logical pixels). +#[allow(dead_code)] // LG/XL reserved for upcoming layout work +pub mod spacing { + pub const XS: f32 = 2.0; + pub const SM: f32 = 4.0; + pub const MD: f32 = 8.0; + pub const LG: f32 = 12.0; + pub const XL: f32 = 20.0; +} + +/// Corner radii. +pub mod radius { + /// Used by the player control buttons today. + pub const SM: f32 = 5.0; + /// Used by the album-art placeholder card. + pub const LG: f32 = 8.0; +} + +/// Component sizes (logical pixels). +pub mod size { + /// Square icon-button side length used in the player control row. + pub const ICON_BTN: f32 = 32.0; + /// Album-art / cassette square side length. + pub const ALBUM: f32 = 88.0; + /// Volume slider width. + pub const SLIDER_VOLUME: f32 = 140.0; + /// Stroke width used by the player buttons and album-art card. + pub const STROKE_WIDTH: f32 = 1.0; + /// Shared header band height for the library / playlist tabs / lyrics + /// panels so the three side-by-side panels line up across the seam. + pub const HEADER_HEIGHT: f32 = 22.0; +} + +/// Typography sizes (in egui logical pixels). +#[allow(dead_code)] // LG reserved for phase-3 title hierarchy +pub mod text { + pub const SM: f32 = 12.0; + pub const MD: f32 = 14.0; + pub const LG: f32 = 16.0; +} + +/// Named colours. Phase 2 will start consuming `BRAND_*` in place of the +/// per-component blues currently in use; for now they are defined but not +/// yet wired in so that the visual output matches main exactly. +pub mod color { + use super::Color32; + + // ─── Brand palette ──────────────────────────────────────────────────── + /// Primary brand blue. Used for selection backgrounds and active toggle + /// buttons (desktop-lyrics on, non-default playback modes, etc.). + pub const BRAND: Color32 = Color32::from_rgb(0x2D, 0x7D, 0xEC); + /// Hover variant of the brand colour. Reserved for hover-state styling + /// once we push per-button visuals (see phase 3). + #[allow(dead_code)] + pub const BRAND_HOVER: Color32 = Color32::from_rgb(0x4A, 0x93, 0xF0); + /// Pressed/active variant of the brand colour. + pub const BRAND_ACTIVE: Color32 = Color32::from_rgb(0x1E, 0x68, 0xCF); + + // ─── Lyrics ─────────────────────────────────────────────────────────── + /// Soft blue used for the lyrics-type indicator (🎤/📝/🎸). + pub const LYRICS_TYPE_ICON: Color32 = Color32::from_rgb(100, 150, 255); + /// Red used for the "lyrics unavailable: …" status line. + pub const LYRICS_FAILED: Color32 = Color32::from_rgb(230, 80, 80); + /// Highlight colour for the synced-lyrics current line. + pub const LYRICS_CURRENT_LINE: Color32 = Color32::BLUE; + + // ─── Playlist (drag/highlight) ──────────────────────────────────────── + /// Border colour of the row-being-dragged ghost. + pub const PLAYLIST_DRAG_BORDER: Color32 = Color32::from_rgb(120, 120, 180); + /// Insertion-point line drawn between rows during drag. + pub const PLAYLIST_DRAG_INSERT_LINE: Color32 = Color32::from_rgb(50, 150, 250); + /// Translucent fill for the drag ghost row. + pub const PLAYLIST_DRAG_GHOST_FILL: Color32 = + Color32::from_rgba_premultiplied(100, 100, 180, 200); + /// Highlight overlay for selected/playing row. + pub const PLAYLIST_ROW_HIGHLIGHT: Color32 = + Color32::from_rgba_premultiplied(100, 150, 255, 200); + + // ─── Album-art placeholder ──────────────────────────────────────────── + pub const ALBUM_BG_DARK: Color32 = Color32::from_rgb(30, 30, 35); + pub const ALBUM_BG_LIGHT: Color32 = Color32::from_rgb(220, 220, 225); + pub const ALBUM_STROKE_DARK: Color32 = Color32::from_rgb(60, 60, 65); + pub const ALBUM_STROKE_LIGHT: Color32 = Color32::from_rgb(160, 160, 165); +} diff --git a/src/app/ui.rs b/src/app/ui.rs index 04cb4c2..baa28cf 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -30,6 +30,120 @@ impl App { } } + fn show_desktop_lyrics(&mut self, ctx: &egui::Context) { + if !self.ui_state.desktop_lyrics_enabled { + return; + } + + let mut current_lyric_text = "BIRD PLAYER".to_string(); + + let current_time_ms = if self.runtime.is_some() { + self.player_ref().seek_to_timestamp + } else { + 0 + }; + + if let Some(lyrics) = self.lyrics_service.manager().current_lyrics() { + for line in &lyrics.lines { + let is_current = + if let (Some(start), Some(end)) = (line.start_time_ms, line.end_time_ms) { + current_time_ms >= start && current_time_ms < end + } else if let Some(start) = line.start_time_ms { + current_time_ms >= start + } else { + false + }; + + if is_current { + if !line.text.trim().is_empty() { + current_lyric_text = line.text.clone(); + } + break; + } + } + } + + let viewport_builder = egui::ViewportBuilder::default() + .with_title("Desktop Lyrics") + .with_decorations(false) + .with_transparent(true) + .with_always_on_top() + .with_inner_size(egui::vec2(800.0, 100.0)); + + let font_size = self.ui_state.desktop_lyrics_font_size; + let [r, g, b, a] = self.ui_state.desktop_lyrics_color; + let fg = egui::Color32::from_rgba_unmultiplied(r, g, b, a); + let locked = self.ui_state.desktop_lyrics_locked; + + ctx.show_viewport_immediate( + egui::ViewportId::from_hash_of("desktop_lyrics_window"), + viewport_builder, + move |ctx, _class| { + let frame = egui::Frame::NONE.fill(egui::Color32::TRANSPARENT); + + egui::CentralPanel::default().frame(frame).show(ctx, |ui| { + let full_rect = ui.max_rect(); + + // A 16×16 drag handle in the top-left corner. Only this + // region starts a window drag, so the rest of the surface + // stays free for future interactions (e.g. text select). + // Drag is suppressed entirely when the overlay is locked. + if !locked { + let handle_rect = + egui::Rect::from_min_size(full_rect.min, egui::vec2(16.0, 16.0)); + let handle_resp = ui.interact( + handle_rect, + egui::Id::new("desktop_lyrics_drag_handle"), + egui::Sense::drag(), + ); + if handle_resp.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + // Visual hint — small dim square so the user can find it. + ui.painter().rect_filled( + handle_rect.shrink(4.0), + 2.0, + egui::Color32::from_white_alpha(if handle_resp.hovered() { + 160 + } else { + 80 + }), + ); + } + + // Render text twice for a cheap stroke/outline effect: + // a black drop-shadow underneath, and the coloured glyphs + // on top. Greatly improves readability over busy desktops. + let painter = ui.painter(); + let center = full_rect.center(); + let font_id = egui::FontId::proportional(font_size); + let shadow = egui::Color32::from_black_alpha(180); + for offset in [ + egui::vec2(-1.5, 0.0), + egui::vec2(1.5, 0.0), + egui::vec2(0.0, -1.5), + egui::vec2(0.0, 1.5), + ] { + painter.text( + center + offset, + egui::Align2::CENTER_CENTER, + ¤t_lyric_text, + font_id.clone(), + shadow, + ); + } + painter.text( + center, + egui::Align2::CENTER_CENTER, + ¤t_lyric_text, + font_id, + fg, + ); + }); + }, + ); + } + fn refresh_lyrics_display(&mut self) { let (lyrics_received, should_show_panel) = self.lyrics_service.check_pending_lyrics(&mut self.ui_state); @@ -40,13 +154,17 @@ impl App { } fn refresh_library_command_processor(&mut self) { - if let Ok(lib_cmd) = self.lib_cmd_rx().try_recv() { + while let Ok(lib_cmd) = self.lib_cmd_rx().try_recv() { self.process_library_command(lib_cmd); } } } impl eframe::App for App { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + [0.0, 0.0, 0.0, 0.0] // Fix macOS ghosting inside transparent windows + } + fn on_exit(&mut self, _ctx: Option<&eframe::glow::Context>) { tracing::info!("exiting and saving"); self.update_player_persistence(); @@ -58,8 +176,14 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } self.refresh_library_command_processor(); + self.pump_audio_events(); + + if self.ui_state.is_importing { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } self.refresh_lyrics_display(); self.refresh_window_title(ctx); + self.show_desktop_lyrics(ctx); MainShell::show(self, ctx); @@ -70,7 +194,5 @@ impl eframe::App for App { ctx.request_repaint_after(Duration::from_millis(100)); } } - - eprintln!("fps = {:.0}", 1.0 / ctx.input(|i| i.unstable_dt)); } } diff --git a/src/lib/audio/cmd.rs b/src/lib/audio/cmd.rs index 93eab90..33d0bea 100644 --- a/src/lib/audio/cmd.rs +++ b/src/lib/audio/cmd.rs @@ -23,9 +23,9 @@ pub fn process_audio_command( is_processing_ui_change: &Arc, ) -> Option> { match cmd { - AudioCommand::Seek(seconds) => { - tracing::info!("Processing SEEK command for {} seconds", seconds); - Some(Box::new(SeekToState::new(seconds))) + AudioCommand::Seek(timestamp_ms) => { + tracing::info!("Processing SEEK command for {} ms", timestamp_ms); + Some(Box::new(SeekToState::new(timestamp_ms))) } AudioCommand::Stop => { tracing::info!("Processing STOP command"); diff --git a/src/lib/audio/loader.rs b/src/lib/audio/loader.rs index a3f6ddc..d904c8e 100644 --- a/src/lib/audio/loader.rs +++ b/src/lib/audio/loader.rs @@ -13,6 +13,7 @@ pub fn load_file( audio_engine_state: &mut AudioEngineState, decoder: &mut Option>, seek_timestamp: u64, + ui_tx: &std::sync::mpsc::Sender, ) { let hint = Hint::new(); let source = Box::new(std::fs::File::open(path).expect("couldn't open file")); @@ -98,47 +99,88 @@ pub fn load_file( sample_rate ); + // Extract technical info for UI display + let channels = track.codec_params.channels.map(|ch| ch.count() as u8); + let codec = Some(format!("{:?}", track.codec_params.codec)); + + // Send technical info to UI + ui_tx + .send(crate::AudioEvent::TechnicalInfo { + sample_rate, + channels, + codec, + }) + .ok(); + // Store the timebase - use sample rate as the most reliable source if let Some(sample_rate) = track.codec_params.sample_rate { audio_engine_state.timebase = sample_rate as u64; tracing::debug!("Using sample rate {} as timebase", sample_rate); } else if let Some(time_base) = tb { - // Fallback to timebase calculation if sample_rate is not available - let tb_hz = time_base.numer as f64 / time_base.denom as f64; + // TimeBase { numer, denom } means 1 tick = numer/denom seconds + // So ticks per second = denom / numer + let tb_hz = time_base.denom as f64 / time_base.numer as f64; audio_engine_state.timebase = tb_hz as u64; tracing::debug!( "Using timebase calculation: {} Hz ({} / {})", tb_hz, - time_base.numer, - time_base.denom + time_base.denom, + time_base.numer ); } else { tracing::warn!("No timebase or sample rate available, using default 44100"); - audio_engine_state.timebase = 44100; // Common default for audio + audio_engine_state.timebase = 44100; } // Convert duration to milliseconds - // Primary method: estimate based on file size and typical bitrate - let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); - let mut estimated_duration_ms = 0; + let mut exact_duration_ms = None; + + // Primary method: extract precise duration using codec params + if let Some(n_frames) = track.codec_params.n_frames { + if let Some(tb) = tb { + // Calc duration via frames * (time_base_num / time_base_den) + + // Frac is scaled by tb.denom. Or rather, frac is directly + // proportional to the time_base denominator. But calc_time yields fractional + // part in terms of a rational but it has a specific fraction value. + // Instead of using 'frac', using raw arithmetic is absolutely precise: + let raw_duration_ms = + (n_frames as f64 * tb.numer as f64 / tb.denom as f64 * 1000.0) as u64; + exact_duration_ms = Some(raw_duration_ms); + } else if let Some(sample_rate) = track.codec_params.sample_rate { + // Fallback using sample rate + let raw_duration_ms = (n_frames as f64 / sample_rate as f64 * 1000.0) as u64; + exact_duration_ms = Some(raw_duration_ms); + } + } - if file_size_bytes > 100000 { - // Only use file size if it's a reasonable size (>100KB) - // Assume 160 kbps MP3 = 160 * 1024 / 8 = 20480 bytes per second - let bytes_per_second = 160 * 1024 / 8; // 20480 - estimated_duration_ms = (file_size_bytes * 1000) / bytes_per_second as u64; - tracing::debug!( - "Estimated duration from file size: {} ms (file size: {} bytes, {} bytes/sec)", - estimated_duration_ms, - file_size_bytes, - bytes_per_second - ); + // Fallback method: estimate based on file size and typical bitrate if exact isn't available + let mut estimated_duration_ms = 0; + if exact_duration_ms.is_none() { + let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0); + if file_size_bytes > 100000 { + // Only use file size if it's a reasonable size (>100KB) + // Assume 160 kbps MP3 = 160 * 1024 / 8 = 20480 bytes per second + let bytes_per_second = 160 * 1024 / 8; // 20480 + estimated_duration_ms = (file_size_bytes * 1000) / bytes_per_second as u64; + tracing::debug!( + "Estimated duration from file size: {} ms (file size: {} bytes, {} bytes/sec)", + estimated_duration_ms, + file_size_bytes, + bytes_per_second + ); + } } - // Ensure minimum duration for music files (2 minutes = 120,000 ms) - audio_engine_state.duration = estimated_duration_ms.max(120000); + // Set final duration, preferring exact duration if possible + if let Some(exact) = exact_duration_ms { + audio_engine_state.duration = exact; + } else { + // Ensure minimum duration for music files ONLY if we relied on fallback (2 minutes = 120,000 ms) + audio_engine_state.duration = estimated_duration_ms.max(120000); + } - if estimated_duration_ms < 120000 { + if exact_duration_ms.is_none() && estimated_duration_ms < 120000 { tracing::debug!( "Using minimum duration: {} ms (estimated was {} ms)", audio_engine_state.duration, diff --git a/src/lib/audio/reader.rs b/src/lib/audio/reader.rs index 630cd64..eea3114 100644 --- a/src/lib/audio/reader.rs +++ b/src/lib/audio/reader.rs @@ -27,15 +27,23 @@ pub fn setup_audio_reader(audio_engine_state: &mut AudioEngineState) -> Result return Ok(0), }; + // Get sample rate for ms-to-timebase conversion + let sample_rate = reader + .tracks() + .iter() + .find(|t| t.id == track_id) + .and_then(|t| t.codec_params.sample_rate) + .unwrap_or(44100) as u64; + // If seeking, seek the reader to the time or timestamp specified and get the timestamp of the // seeked position. All packets with a timestamp < the seeked position will not be played. - // - // Note: This is a half-baked approach to seeking! After seeking the reader, packets should be - // decoded and *samples* discarded up-to the exact *sample* indicated by required_ts. The - // current approach will discard excess samples if seeking to a sample within a packet. let seek_ts = if let Some(seek) = seek { let seek_to = match seek { - SeekPosition::Timestamp(ts) => SeekTo::TimeStamp { ts: *ts, track_id }, + SeekPosition::Timestamp(ms) => { + // Convert milliseconds to timebase units (samples) + let ts = *ms * sample_rate / 1000; + SeekTo::TimeStamp { ts, track_id } + } }; // Attempt the seek. If the seek fails, ignore the error and return a seek timestamp of 0 so @@ -53,13 +61,11 @@ pub fn setup_audio_reader(audio_engine_state: &mut AudioEngineState) -> Result { - // Don't give-up on a seek error. tracing::warn!("seek error: {}", err); 0 } } } else { - // If not seeking, the seek timestamp is 0. 0 }; diff --git a/src/lib/audio/state_machine.rs b/src/lib/audio/state_machine.rs index 88a6058..e812e9b 100644 --- a/src/lib/audio/state_machine.rs +++ b/src/lib/audio/state_machine.rs @@ -110,7 +110,13 @@ impl State for StoppedState { ctx.engine.audio_output = None; - loader::load_file(current_track_path, &mut ctx.engine, &mut ctx.decoder, 0); + loader::load_file( + current_track_path, + &mut ctx.engine, + &mut ctx.decoder, + 0, + &ctx.ui_tx, + ); ctx.ui_tx .send(AudioEvent::CurrentTimestamp(0)) @@ -284,7 +290,7 @@ impl State for LoadFileState { fn update(&mut self, ctx: &mut AudioContext) -> Transition { ctx.current_track_path = Some(self.path.clone()); - loader::load_file(&self.path, &mut ctx.engine, &mut ctx.decoder, 0); + loader::load_file(&self.path, &mut ctx.engine, &mut ctx.decoder, 0, &ctx.ui_tx); // 检查加载是否成功 if ctx.engine.reader.is_some() && ctx.engine.track_info.is_some() { @@ -338,6 +344,7 @@ impl State for SeekToState { &mut ctx.engine, &mut ctx.decoder, self.timestamp, + &ctx.ui_tx, ); // 检查加载是否成功 diff --git a/src/lib/messaging.rs b/src/lib/messaging.rs index 526551e..359b463 100644 --- a/src/lib/messaging.rs +++ b/src/lib/messaging.rs @@ -19,4 +19,9 @@ pub enum AudioEvent { TotalTrackDuration(u64), CurrentTimestamp(u64), PlaybackStateChanged(bool), + TechnicalInfo { + sample_rate: Option, + channels: Option, + codec: Option, + }, } diff --git a/src/lib/player.rs b/src/lib/player.rs index 9ff7481..64dd694 100644 --- a/src/lib/player.rs +++ b/src/lib/player.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; +use std::time::Instant; #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] pub enum PlaybackMode { @@ -25,6 +26,10 @@ pub struct Player { pub duration: u64, pub cursor: Arc, // This can "overflow" pub playback_mode: PlaybackMode, + pub sample_rate: Option, + pub channels: Option, + pub codec: Option, + pub seeking_since: Option, } impl Player { @@ -43,16 +48,20 @@ impl Player { duration: 0, cursor, playback_mode: PlaybackMode::Normal, + sample_rate: None, + channels: None, + codec: None, + seeking_since: None, } } pub fn select_track(&mut self, track: Option) { self.selected_track = track; + self.seeking_since = None; if let Some(track) = &self.selected_track { if let Err(e) = self.audio_tx.send(AudioCommand::LoadFile(track.path())) { tracing::error!("Failed to send select to audio thread: {}", e); - // Audio thread is likely dead, mark as stopped self.track_state = TrackState::Stopped; } } @@ -64,10 +73,11 @@ impl Player { pub fn seek_to(&mut self, seek_to_timestamp: u64) { self.seek_to_timestamp = seek_to_timestamp; + self.seeking_since = Some(Instant::now()); if let Err(e) = self.audio_tx.send(AudioCommand::Seek(seek_to_timestamp)) { tracing::error!("Failed to send seek to audio thread: {}", e); - // Audio thread is likely dead, mark as stopped self.track_state = TrackState::Stopped; + self.seeking_since = None; } } @@ -193,10 +203,6 @@ impl Player { } } - pub fn set_seek_to_timestamp(&mut self, seek_to_timestamp: u64) { - self.seek_to_timestamp = seek_to_timestamp; - } - pub fn set_duration(&mut self, duration: u64) { self.duration = duration; } diff --git a/src/lib/playlist.rs b/src/lib/playlist.rs index 644c0d0..f32e8cb 100644 --- a/src/lib/playlist.rs +++ b/src/lib/playlist.rs @@ -10,6 +10,9 @@ use std::sync::{Arc, Mutex}; pub struct Playlist { pub id: Option, name: Option, + description: Option, + created_at: i64, + updated_at: i64, pub tracks: Vec, pub selected: Option, #[serde(skip_serializing, skip_deserializing)] @@ -25,10 +28,27 @@ impl Default for Playlist { } impl Playlist { + fn current_timestamp_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64 + } + + fn touch(&mut self) { + self.updated_at = Self::current_timestamp_ms(); + self.is_dirty = true; + } + pub fn new() -> Self { + let now = Self::current_timestamp_ms(); + Self { id: None, name: None, + description: None, + created_at: now, + updated_at: now, tracks: vec![], selected: None, selected_indices: HashSet::new(), @@ -38,23 +58,40 @@ impl Playlist { pub fn set_name(&mut self, name: String) { self.name = Some(name); - self.is_dirty = true; + self.touch(); } pub fn get_name(&self) -> Option { self.name.clone() } + pub fn description(&self) -> Option { + self.description.clone() + } + + pub fn set_description(&mut self, description: Option) { + self.description = description; + self.touch(); + } + + pub fn created_at(&self) -> i64 { + self.created_at + } + + pub fn updated_at(&self) -> i64 { + self.updated_at + } + pub fn add(&mut self, track: LibraryItem) { self.tracks.push(track); - self.is_dirty = true; + self.touch(); } // TODO - should probably return a Result pub fn remove(&mut self, idx: usize) { self.tracks.remove(idx); self.selected_indices.remove(&idx); - self.is_dirty = true; + self.touch(); // Update indices greater than the removed index let mut to_remove = Vec::new(); @@ -80,7 +117,7 @@ impl Playlist { pub fn reorder(&mut self, current_pos: usize, destination_pos: usize) { let track = self.tracks.remove(current_pos); self.tracks.insert(destination_pos, track); - self.is_dirty = true; + self.touch(); // Update selected indices after reordering let mut new_selected = HashSet::new(); @@ -162,16 +199,16 @@ impl Playlist { Some(id) => { // Update existing playlist tx.execute( - "UPDATE playlists SET name = ?1 WHERE id = ?2", - rusqlite::params![self.name, id], + "UPDATE playlists SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![self.name, self.description, self.updated_at, id], )?; id } None => { // Insert new playlist tx.execute( - "INSERT INTO playlists (name) VALUES (?1)", - rusqlite::params![self.name], + "INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![self.name, self.description, self.created_at, self.updated_at], )?; tx.last_insert_rowid() } @@ -214,16 +251,16 @@ impl Playlist { Some(id) => { // Update existing playlist tx.execute( - "UPDATE playlists SET name = ?1 WHERE id = ?2", - rusqlite::params![self.name, id], + "UPDATE playlists SET name = ?1, description = ?2, updated_at = ?3 WHERE id = ?4", + rusqlite::params![self.name, self.description, self.updated_at, id], )?; id } None => { // Insert new playlist tx.execute( - "INSERT INTO playlists (name) VALUES (?1)", - rusqlite::params![self.name], + "INSERT INTO playlists (name, description, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![self.name, self.description, self.created_at, self.updated_at], )?; tx.last_insert_rowid() } @@ -260,18 +297,25 @@ impl Playlist { let conn_guard = conn.lock().unwrap(); // Get the playlist info - let mut stmt = conn_guard.prepare("SELECT id, name FROM playlists WHERE id = ?1")?; + let mut stmt = conn_guard.prepare( + "SELECT id, name, description, created_at, updated_at FROM playlists WHERE id = ?1", + )?; let mut playlist_rows = stmt.query(rusqlite::params![playlist_id])?; if let Some(row) = playlist_rows.next()? { let id: i64 = row.get(0)?; let name: Option = row.get(1)?; + let description: Option = row.get(2)?; + let created_at: i64 = row.get(3)?; + let updated_at: i64 = row.get(4)?; - // Create the playlist let mut playlist = Playlist { id: Some(id), name, + description, + created_at, + updated_at, tracks: vec![], selected: None, selected_indices: HashSet::new(), @@ -280,7 +324,7 @@ impl Playlist { // Get the tracks let mut items_stmt = conn_guard.prepare( - "SELECT li.key, li.library_id, li.path, li.title, li.artist, li.album, li.year, li.genre, li.track_number, li.lyrics + "SELECT li.key, li.library_path_id, li.path, li.title, li.artist, li.album, li.year, li.genre, li.track_number, li.lyrics FROM library_items li JOIN playlist_items pi ON li.key = pi.library_item_id WHERE pi.playlist_id = ?1 @@ -438,9 +482,17 @@ mod tests { let path2 = PathBuf::from(r"C:\music\song2.mp3"); let path3 = PathBuf::from(r"C:\music\song3.mp3"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let mut playlist = Playlist { id: None, name: Some("test".to_string()), + description: None, + created_at: now, + updated_at: now, tracks: vec![ LibraryItem::new(path1.clone(), LibraryPathId::new(0)), LibraryItem::new(path2.clone(), LibraryPathId::new(1)), @@ -466,9 +518,17 @@ mod tests { let path2 = PathBuf::from(r"C:\music\song2.mp3"); let path3 = PathBuf::from(r"C:\music\song3.mp3"); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + let mut playlist = Playlist { id: None, name: Some("test".to_string()), + description: None, + created_at: now, + updated_at: now, tracks: vec![ LibraryItem::new(path1.clone(), LibraryPathId::new(0)), LibraryItem::new(path2.clone(), LibraryPathId::new(1)), @@ -508,4 +568,92 @@ mod tests { // assert_eq!(playlist.selected, Some(track3)); // } + + #[test] + fn test_playlist_metadata_fields() { + let playlist = Playlist::new(); + + // Check that timestamps are initialized + assert!(playlist.created_at() > 0); + assert!(playlist.updated_at() > 0); + assert_eq!(playlist.created_at(), playlist.updated_at()); + + // Check that description is None by default + assert_eq!(playlist.description(), None); + } + + #[test] + fn test_set_description() { + let mut playlist = Playlist::new(); + let initial_updated_at = playlist.updated_at(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + playlist.set_description(Some("My favorite songs".to_string())); + + assert_eq!( + playlist.description(), + Some("My favorite songs".to_string()) + ); + assert!(playlist.updated_at() > initial_updated_at); + } + + #[test] + fn test_set_name_updates_timestamp() { + let mut playlist = Playlist::new(); + let initial_updated_at = playlist.updated_at(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + playlist.set_name("Test Playlist".to_string()); + + assert!(playlist.updated_at() > initial_updated_at); + } + + #[test] + fn test_add_updates_timestamp() { + let mut playlist = Playlist::new(); + let initial_updated_at = playlist.updated_at(); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + let track = LibraryItem::new(PathBuf::from(r"C:\music\song.mp3"), LibraryPathId::new(0)); + playlist.add(track); + + assert!(playlist.updated_at() > initial_updated_at); + } + + #[test] + fn test_remove_updates_timestamp() { + let mut playlist = Playlist::new(); + let track = LibraryItem::new(PathBuf::from(r"C:\music\song.mp3"), LibraryPathId::new(0)); + playlist.add(track); + + let initial_updated_at = playlist.updated_at(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + playlist.remove(0); + + assert!(playlist.updated_at() > initial_updated_at); + } + + #[test] + fn test_reorder_updates_timestamp() { + let mut playlist = Playlist::new(); + playlist.add(LibraryItem::new( + PathBuf::from(r"C:\music\song1.mp3"), + LibraryPathId::new(0), + )); + playlist.add(LibraryItem::new( + PathBuf::from(r"C:\music\song2.mp3"), + LibraryPathId::new(1), + )); + + let initial_updated_at = playlist.updated_at(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + playlist.reorder(0, 1); + + assert!(playlist.updated_at() > initial_updated_at); + } }