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);
+ }
}