Skip to content

Refactor player UI#34

Open
RetricSu wants to merge 50 commits into
developfrom
refactor-player-ui
Open

Refactor player UI#34
RetricSu wants to merge 50 commits into
developfrom
refactor-player-ui

Conversation

@RetricSu
Copy link
Copy Markdown
Owner

No description provided.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a significant UI refactor, including a new desktop lyrics feature, a simplified cassette component that focuses on album art, and a more compact player layout. It also improves audio file duration detection by using codec parameters for precise calculations instead of relying solely on file size estimates. However, the refactor has introduced several regressions: the playback progress slider and seeking functionality have been removed from the player UI, and the dynamic text truncation for track metadata in the cassette component was deleted, which will likely cause layout overflows. Additionally, the player panel width is now hardcoded, reducing UI responsiveness, and the new desktop lyrics toggle lacks visual feedback to indicate its state.

Comment thread src/app/components/player_component.rs Outdated
Comment on lines +194 to +197
// Tightly packed controls
ui.add_space(2.0);
ui.separator();
ui.add_space(2.0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The time slider (progress bar) has been completely removed from the player UI. This is a significant regression in functionality, as users can no longer visually track playback progress or seek to specific parts of a song using a slider.

Comment on lines 151 to +176
// 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(),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The text truncation logic for the track title and artist has been removed in this refactor. For tracks with long metadata, the text will likely overflow the album art area or overlap with other UI elements. It is recommended to restore the truncation logic using ui.painter().layout_no_wrap to calculate the required width and append an ellipsis if the text exceeds the available space.

Comment thread src/app/components/player_component.rs Outdated
min_width
};
// Constrain middle pane width to make it a dense column
let panel_width = 320.0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The panel_width is now hardcoded to 320.0, whereas the previous implementation dynamically adjusted based on available width. This change reduces the responsiveness of the UI and may lead to layout issues on smaller window sizes or leave excessive empty space on larger ones.

Comment thread src/app/components/player_component.rs Outdated
let mode_btn = ui.add_enabled(has_selected_track, egui::Button::new(mode_icon).player_style());

ui.add_space(8.0);
let lyrics_icon = if ctx.ui_state.desktop_lyrics_enabled { "词" } else { "词" };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The lyrics_icon logic uses the same string "词" for both branches of the if statement. This provides no visual feedback to the user regarding whether desktop lyrics are enabled or disabled. Consider using distinct labels or colors to indicate the toggle state.

Suggested change
let lyrics_icon = if ctx.ui_state.desktop_lyrics_enabled { "词" } else { "词" };
let lyrics_icon = if ctx.ui_state.desktop_lyrics_enabled { "词 (On)" } else { "词 (Off)" };

RetricSu added 13 commits April 30, 2026 15:05
- Pump AudioEvent loop centrally in App::update via new
  App::pump_audio_events; PlayerComponent is now render-only.
- Move 30s persistence-save throttle from a thread_local in
  PlayerComponent into UiState.last_persistence_save.
- Replace string-keyed action dispatch in PlayerComponent with a
  type-checked PlayerAction enum.
- Drop dead code: CASSETTE_WIDTH const, unused min_width /
  available_width vars, unused LyricsComponent import in MainShell,
  PlayerService::remove_current_track, UiState.show_library_and_playlist.
- Fix clippy: deprecated allocate_ui_at_rect -> centered_and_justified,
  collapsed nested ifs, deduped identical lyrics_icon branches.
- Remove per-frame eprintln! fps spam in App::update.
Centralise every spacing/sizing/radius/colour/icon literal that the
player UI depends on into two new modules:

- src/app/style/tokens.rs — single source of truth for spacing,
  radii, sizes, typography and palette. Brand colours (BRAND,
  BRAND_HOVER, BRAND_ACTIVE) are defined but unused; phase 2 will
  wire them into the egui visuals.
- src/app/style/icons.rs — every emoji / glyph the UI prints today,
  ready to be swapped to an icon-font in phase 2.

Existing call sites (player, cassette, lyrics, playlist table, footer,
window chrome, main shell, desktop-lyrics overlay) now reference these
constants instead of inline literals. Each value is preserved exactly,
so this commit is a no-op visually — it just cleans the surface so the
remaining UI redesign phases (button polish, progress bar, layout
split, desktop-lyrics polish) can iterate on tokens rather than
rewriting magic numbers.
- Adopt egui-phosphor 0.9 (regular variant) as the icon font; rewrite
  src/app/style/icons.rs to point each constant at the corresponding
  phosphor codepoint. Every icon in the player, footer, lyrics panel
  and window chrome now renders from the same font, replacing the
  previous mix of emoji and ASCII glyphs.
- Apply a brand-aware visuals tweak at boot via
  apply_brand_visuals(): selection backgrounds and slider/select
  strokes now pick up tokens::color::BRAND.
- Add a player_button(label, active) helper in style/mod.rs that
  produces a brand-filled button when toggled on. Wire it through the
  player control row so:
    • play/pause turns brand-blue while audio is playing,
    • the mode button highlights for any non-Normal playback mode,
    • the desktop-lyrics toggle highlights while the floating viewport
      is enabled.
- Convert the old static speaker label into a clickable mute toggle.
  It stores the pre-mute volume in UiState::volume_before_mute (not
  persisted) and restores it on the next click; manually dragging the
  slider clears the sticky mute. Mute state also drives a speaker-x
  glyph and the brand active fill.
…se 3)

- Replace the four-line track-info block with a clearer hierarchy:
  title is rendered as the heading (tokens::text::LG, bold), artist
  uses default weight with a muted tint, and time + playlist collapse
  into a single small dim row separated by a middle dot.
- Drop the redundant separator above the controls in favour of an
  always-visible thin timeline scrubber. Dragging the slider calls
  PlayerService::seek_to() and is automatically disabled when no
  track is selected or the duration is unknown.
- Make CassetteComponent's cover responsive: it still tops out at
  tokens::size::ALBUM (180 px) but now shrinks with the available
  height/width down to 64 px, so the player remains usable in narrow
  or short layouts.
- Drop the in-player lyrics column. PlayerComponent is now strictly
  cover + track info + transport controls; the trailing separator
  and allocate_ui block that hosted LyricsComponent are gone.
- Render lyrics in a dedicated egui::SidePanel::right (280 px default,
  220 px min, user-resizable) shown by MainShell whenever
  ui_state.show_lyrics_panel is true. The existing View > Show/Hide
  Lyrics menu entry already drives that flag, and lyrics-fetch flows
  continue to flip it on automatically when new lyrics arrive.
- The 词 transport button keeps its phase-2 behaviour: it toggles the
  borderless desktop lyrics overlay only, independent of the side
  panel.
…I phase 5)

- Replace the click-anywhere-to-drag behaviour on the desktop lyrics
  overlay with a small 16x16 corner drag handle. The rest of the
  surface no longer intercepts pointer events, and the handle is
  hidden entirely when the overlay is locked.
- Render the lyrics text via Painter::text with a four-direction black
  drop-shadow underneath the foreground glyphs, giving a cheap stroke
  effect that stays readable over busy desktops.
- Read font size and color from the new UiState fields:
  desktop_lyrics_font_size (default 48), desktop_lyrics_color
  (default cyan), and desktop_lyrics_locked (default false). All three
  are persisted via UiSettings with serde defaults so older configs
  upgrade cleanly.
- Add a 'View > Desktop Lyrics' submenu exposing the enabled/locked
  toggles, a 16-120 px font-size slider, and an sRGBA color picker.
- Drop the now-unused DESKTOP_LYRICS_FG/BG tokens; defaults live with
  the runtime state instead.
- Route the minimize / maximize / close glyphs through phosphor regular
  via new icons::WINDOW_MINIMIZE, WINDOW_MAXIMIZE, WINDOW_RESTORE, and
  WINDOW_CLOSE constants. The maximize button now swaps to a CORNERS_IN
  glyph while the window is maximized so the action stays self-explanatory.
- Replace the three ad-hoc Button widgets with a small chrome_button
  helper that strips strokes, enlarges the hit area to 32x22, and gives
  the close button a danger-red hover fill (Win/macOS standard) while
  the other two pick up the theme's normal hovered.weak_bg_fill.
- Tighten the top chrome panel padding to 2 px vertical and SM/XS
  horizontal so the menu row no longer floats inside excessive gutter.
Bugs fixed:
- Chrome buttons now actually paint a hover background. Previously the
  helper called Button::fill(Color32::TRANSPARENT) and Button::stroke(NONE),
  but egui resolves those overrides for every interaction state, so the
  red close-hover and the grey min/max-hover were both stomped flat.
  Worse, the hovered fg_stroke I set to white turned the X glyph into
  white-on-white. Drop the per-button overrides and drive every state
  through WidgetVisuals (inactive transparent, hovered/active tinted),
  so close goes red-on-white-glyph and the others get the theme grey.
- The timeline scrubber now seeks on click-on-rail too. It was using
  Response::dragged(), which only fires while a drag is in progress.
  Switch to Response::changed() so any value change (drag, click, or
  keyboard) calls PlayerService::seek_to.
- Long song titles can no longer push the player panel taller. Both
  the title and artist Labels are now built with .truncate(), so they
  render as a single line that ellipsises on overflow.

Layout polish:
- Restructure PlayerComponent into three vertical bands:
    1. cover + track-info row (cover keeps its responsive sizing,
       info expands to fill the rest of the panel width),
    2. full-width timeline scrubber,
    3. transport row with prev/play/next/mode/lyrics on the left and
       mute + volume hugging the right edge via a right-to-left
       layout. Volume no longer floats in its own short stub row.
- Drop the now-unused tokens::size::PLAYER_PANEL since the player
  panel sizes itself off ui.available_width() instead of a fixed
  320 px column.
- Add icons::PLUS (phosphor regular plus) and icons::LYRICS_PRESENT
  (phosphor music notes) so the rest of the UI can route through the
  same icon set the player already uses.
- Library 'add folder' button now renders icons::PLUS in a frame-less
  Button so it sits flush against the 'Music Files' label, matching the
  visual weight of the new chrome buttons.
- Playlist tabs:
    * font scaled from a hard-coded 12 px to tokens::text::MD (14 px),
    * selected tab paints with tokens::color::BRAND + BRAND_ACTIVE
      stroke and bold white text, mirroring the active player_button
      treatment;
    * unselected tabs are fully transparent so the tab strip reads as
      a strip of links rather than a row of buttons,
    * the trailing '+' uses icons::PLUS in a frame-less Button to match
      the library affordance.
- Footer 'Search' button replaces its 🔍 emoji with icons::SEARCH; the
  'No matches found' label switches from raw Color32::RED to the
  shared tokens::color::LYRICS_FAILED warning red.
- Playlist table lyrics column drops the 🎵 / ❌ / ― emoji trio in
  favour of icons::LYRICS_PRESENT, icons::CLOSE, and icons::LYRICS_NONE
  so the column reads in the same phosphor weight as everything else.
- Playback menu's mode glyphs (➡ 🔁 🔂 🔀) are routed through
  icons::MODE_* so the menu and the transport button always show the
  same icon for the same mode.
The previous defaults made the player look more like a tablet hero card
than a desktop music app — 180 px album art, 40 px transport buttons,
and egui's stock 8 px item spacing meant every list / tab / control
band burnt a generous chunk of vertical real estate. Pull everything
in by ~25 % so the UI reads compactly without changing the visual
language:

- new style::apply_compact_spacing(): item_spacing 6×3, button_padding
  6×2, menu_margin 4×3, indent 14 (was 18), interact_size.y 22. Wired
  in alongside apply_brand_visuals at boot.
- tokens::size::ICON_BTN 40 → 32 (transport buttons, mute, lyrics
  toggle, etc. all auto-shrink with the player_style helper).
- tokens::size::ALBUM 180 → 120 (cassette / album art card; cassette's
  responsive min/clamp logic keeps shrinking gracefully).
- tokens::size::SLIDER_VOLUME 160 → 140 to match the tighter band.
- Playlist Grid spacing 5×5 → tokens::spacing::MD × XS so rows hug
  each other vertically while keeping legible horizontal gutters.
- Library: replace ad-hoc 'SM + 1.0' add_space() pairs with
  tokens::spacing::XS to take a few pixels off the heading and the
  folder list separator.
Iteration on the compact pass. The album art was still a touch larger
than the info column, and three of the side-by-side panels (library,
playlist tabs, lyrics) read as one continuous wall because there was
nothing visually anchoring their headers to the body beneath:

- tokens::size::ALBUM 120 \u2192 88. Closer to the natural height of the
  three-line info column (title / artist / time-and-playlist), so the
  cover stops dangling below the text baseline.
- cassette_component: drop the .shrink(10) margin and re-anchor the
  cover rect to the available height's vertical centre instead of its
  top. With ui.horizontal()'s default Align::Center the info column
  centres too, so the cover and the text now share a midline.
- library_component: add ui.separator() right under the 'Music Files'
  header. Mirrors the separator the lyrics panel already draws under
  its track header so the two side panels read consistently.
- playlist_content: replace the post-tabs add_space(MD) with a
  XS-spaced ui.separator(), giving the tab strip its own underline
  and visually tying it to the table grid below.
The previous attempt computed the cover's y-position from
available_rect_before_wrap().center().y. Inside ui.horizontal() that
rect's height extends all the way to the bottom of the parent panel
(it's the *available* slot, not the row's content height), so half
of side was subtracted from a midpoint that lived well below the
actual row \u2014 pushing the artwork up over the title bar and clipping
the title line entirely.

Replace the manual rect with allocate_exact_size: a horizontal strip
defaults to Align::Center, so the layout itself vertically centres the
cover against the info column on its right. No more guess-the-row-height
math, and the title now sits where it belongs.
Three refinements that pull the visual language together:

- style: introduce borderless_button_visuals(visuals) helper that
  flattens any button rendered inside the calling scope to transparent
  inactive / hover-tinted / no outline. Single function so we don't
  duplicate the WidgetVisuals dance from window_chrome's chrome_button.
- window_chrome: apply the helper at the top of the menu strip so
  '\u6587\u4ef6 / \u64ad\u653e / \u89c6\u56fe / \u5e2e\u52a9' read with the same borderless aesthetic as
  the min / max / close buttons sitting next to them on the right edge.
- lyrics_component: reskin the '\u4e0a\u4f20\u6b4c\u8bcd' button via the same helper
  so it stops looking like a chunky bordered chip in the otherwise
  light-touch lyrics header.
- player_component: drop the two add_space(XS) gaps between cover/info,
  scrubber, and transport. Egui's compact item_spacing already gives
  each row breathing room; the explicit pads were stacking on top of
  it, leaving the band feeling airy. main_shell: trailing pad after
  the player drops from SM+1 to XS for the same reason.
RetricSu and others added 21 commits April 30, 2026 21:46
A long playlist name was running into the elapsed/total counter on
the same line. Break the row into two:
  - line 3: 'mm:ss / mm:ss' (truncated)
  - line 4 (only when a playlist is actually selected): 'Playlist:
    <name>' / '播放列表:<name>' so it's labelled rather than just
    sitting there as a bare string.
i18n: add 'playlist_label' key (en: 'Playlist', zh: '播放列表').
Two visual unifications across the three side-by-side panels:

- tokens::size::HEADER_HEIGHT (28 px). Library, playlist tabs, and
  the lyrics header all set_min_height to this value, so their bottom
  separators line up across the panel seams instead of stair-stepping.
  The playlist tab buttons used to push the tab strip taller than the
  library's plain heading; with a shared minimum row height the
  borderless library heading just stretches to match.
- lyrics ScrollArea now uses auto_shrink([false, false]). The default
  shrink-to-content behaviour was making the scroll area only as wide
  as its longest line, which left the vertical scrollbar floating
  inside the panel rather than flush with the panel's right edge.
  Lyrics text now extends edge-to-edge and the scrollbar lives where
  it should.
- lyrics: post-header pad standardised to spacing::XS to match the
  other two panels (was MD - XS = 6, off by 4 px).
The library / playlist tabs / lyrics headers all read with the same
visual weight now \u2014 the previous inconsistencies came from each panel
relying on a different default:

- library_component: '\u97f3\u4e50\u5e93' was using egui's default body size
  (~14 px) with .strong(). It looked chunkier than the playlist tab
  text because the tabs go through tokens::text::MD which is the same
  number, but library was inheriting the system body font weight.
  Pinned explicitly to size(MD).strong() to match.
- lyrics_component: track header was a plain ui.label() at body size,
  no weight \u2014 visually lighter than the other two panels. Bumped to
  RichText::size(MD).strong() and switched the separator from a hyphen
  to an em-dash for the title \u2014 artist split.
- playlist_table/view: column headers were ui.strong() at body size
  (14 px), which made them read like a second tab strip stacked under
  the playlist tabs. They are *secondary* labels so demoted them to
  size(SM).strong() in weak_text_color() \u2014 still scannable but visually
  subordinate to the heading row above and consistent with the table's
  own SM body text.
…line separator

The three panel headers were still landing at slightly different y
positions because:

- library_component had its header rendered INSIDE ScrollArea::both(),
  so the heading row + bottom separator scrolled with content. Even at
  scrollTop=0 the area reserves a couple of pixels for the horizontal
  scrollbar gutter, which pushed the library separator a few px below
  the playlist tabs / lyrics separators. Hoisted the header
  (ui.horizontal { music_label + add-folder button }) and its trailing
  add_space(XS) + separator() OUT of the ScrollArea, then wrap only
  the folder list in ScrollArea::both. Header now stays pinned and its
  separator lands on the same horizontal seam as the other two.
- lyrics show_lyrics_type() was emitting an inline ui.separator()
  after the type icon, which the other two panels don't have. The
  vertical hairline made the lyrics header read as denser / taller
  than 'jay 许嵩 南拳妈妈' and '音乐库'. Removed; the icon now sits next
  to the title with the standard horizontal item_spacing.
Same root cause as the library header fix one commit ago: PlaylistTabs
were rendered inside ScrollArea::horizontal(...), which reserves a
few pixels of vertical space for the horizontal scrollbar gutter even
when no scrollbar is currently visible. That gutter pushed the tab
strip's bottom separator down, so the middle column's separator sat
~4 px below the library and lyrics separators on either side.

Removed the wrapper — tab strip + add_space(XS) + separator() now
render directly into the side-panel ui, identical structure to the
library header. The three panel separators now share the same y, and
visually the three columns line up. The trade-off: if a user creates
enough playlists to overflow the panel width, tabs will clip rather
than horizontally scroll. That's an acceptable trade for a desktop
music player at this density (and the current 3-tab demo case fits
comfortably); revisiting later with a clip-rect+wheel-scroll variant
is preferable to keeping the gutter just for scroll.
…der aligns

Real root cause of the misaligned middle separator across multiple
attempts: the three columns use DIFFERENT panel types with different
default frames. SidePanel::left and SidePanel::right both render with
Frame::side_top_panel, whose inner_margin has a small top component.
CentralPanel::default() uses Frame::central_panel, which has a
LARGER top inner_margin (egui pads central panels more generously
than sides). That extra top padding is what kept pushing the playlist
tab strip — and therefore its bottom separator — a few pixels below
the library and lyrics separators no matter what we did inside the
header rows themselves.

Fix: explicitly set the central panel's frame to side_top_panel so
all three columns share the same outer chrome. Result: identical top
inner_margin across the three side-by-side panels, and the three
header separators land on the same horizontal seam.
The three side-by-side panels were rendering body text at three
different sizes/weights:

- library folder names: body size + .strong()
- library track items: body size, no weight
- playlist table number column: body size + .strong()
- playlist table title/artist/album/genre: body size, no weight
- lyrics current line: tokens::text::MD + .strong()
- lyrics non-current synced lines: body size, no weight
- lyrics plain-text and placeholder lines: body size

Standardised everything that's not a panel header to
tokens::text::SM (12 px) with no .strong(). Headers stay at MD strong
so the three columns still have a visible heading hierarchy, but the
body lists now read at a consistent, denser, calmer scale.

Exceptions kept by design:
- lyrics current line keeps .strong() + brand colour. Size matches
  surrounding lines (SM) so the highlight rides on weight + colour
  rather than scaling the type.
- playlist tab text stays at MD because tabs ARE the panel header for
  the middle column; demoting them would visually lose the heading
  row.
- playlist table column headers stay at SM strong + weak colour
  (subordinate to the tab header above).
Per user direction: panel headers should read at the same scale as
their body lists, not as a louder heading row.

- library label: MD strong \u2192 SM, no strong
- lyrics 'title \u2014 artist': MD strong \u2192 SM, no strong
- playlist tabs: MD + selected.strong() \u2192 SM, no strong (selected tab
  still distinguishes via brand fill + white text)
- playlist table column headers: SM strong + weak \u2192 SM + weak

Visual hierarchy now comes from layout (separators, selected-tab
brand fill, current-row highlight) rather than type size/weight.
Per user direction: list headers should be more compact and the
horizontal rule under them should 'seal' to the panel edges instead
of stopping short on each side.

- New crate::app::style::panel_header(ui, contents) helper:
  ui.horizontal { set_min_height(HEADER_HEIGHT); ... }
  + Separator::default().grow(8.0).spacing(0.0)
  Separator::grow(8) cancels Frame::side_top_panel's 8 px horizontal
  inner_margin so the rule extends fully to each panel border. All
  three columns share Frame::side_top_panel since the central-panel
  fix two commits ago, so the rules now meet at the panel seams and
  read as one continuous horizontal line across the window.
- HEADER_HEIGHT 28 \u2192 22. The previous value was tuned around the
  taller PlaylistTabs button row; with tabs now demoted to SM text
  the header was reading too tall. 22 px gives a tight band that
  still clears the borderless+ button comfortably.
- library_component / lyrics_component / playlist_content all switch
  from the manual 'ui.horizontal + add_space(XS) + ui.separator()'
  pattern to a single panel_header(ui, |ui| { ... }) call.
- playlist_tabs no longer wraps itself in ui.horizontal; the caller
  (PlaylistContent via panel_header) now provides that scope, which
  avoids a nested horizontal + double min_height inside the header.
Add design specification for playlist metadata fields (description, timestamps) and contextual playback information display in player UI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add explicit conditions for when to show playlist vs single-track info.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… panel

Detailed step-by-step plan covering:
- Playlist metadata fields (description, timestamps)
- Database schema migration to version 2
- Audio technical info extraction
- PlaybackInfoPanel UI component
- Integration into player component

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pdates

Add current_timestamp_ms() and touch() helpers to Playlist, then replace
the 6 inline timestamp+is_dirty patterns across new(), set_name(),
set_description(), add(), remove(), reorder(), and load_from_db() with
calls to these helpers.
Implements database migration from schema version 4 to 5, adding description, created_at, and updated_at columns to the playlists table. Updates save/load methods to persist and retrieve these new metadata fields. Migration is idempotent and sets current timestamps for existing playlists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…els, codec)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace byte-based string slicing with character-based truncation to avoid panics when the 30th byte falls in the middle of a multi-byte UTF-8 character (e.g., Chinese, emoji).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add PlaybackInfoPanel to the player's top row layout, positioned on the right side. The layout now displays: cassette (left), track info (middle 60%), and playback info panel (right). The panel shows contextual information based on playback mode - playlist metadata when playing from a playlist, or album/technical details for single tracks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The progress bar would jump back to the original position after seeking
because stale CurrentTimestamp events from the audio thread overwrote
the seek target. Added a time-based guard (seeking_since) to suppress
stale timestamps for 300ms after a seek.

Also fixed a critical unit mismatch: the UI passes milliseconds but
symphonia's SeekTo::TimeStamp expects timebase units (samples). Added
ms-to-samples conversion in reader.rs. This also fixes the issue where
playback position was not correctly restored on app restart.

Additionally fixed the inverted timebase fallback calculation in
loader.rs (numer/denom → denom/numer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant