Polish player UI and playlist export - #37
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces several major features to Bird Player, including a YouTube discovery search, playlist exporting to ZIP archives, auto-fetching of missing lyrics, and various UI refinements like light scrollbars and scrollable playlist tabs. Key feedback focuses on preventing potential out-of-bounds panics by using safe indexing on playlists, avoiding unnecessary rebuilds in build.rs by checking for file existence, and cleaning up partial ZIP files on export errors. Additionally, performance optimizations are recommended to replace HashSet and to reduce main-thread allocations during library searches.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx { | ||
| let selection_count = ctx.playlists[current_playlist_idx].selected_indices.len(); | ||
| if selection_count > 0 { | ||
| ui.label(format!("{} selected", selection_count)); | ||
| let selection_response = | ||
| ui.label(RichText::new(format!("{} selected", selection_count)).weak()); | ||
| Self::start_drag_from_response(ctx, ui, &selection_response); | ||
|
|
||
| // Clear Selection button (disabled if no selection) | ||
| if ui.button("Clear Selection").clicked() { | ||
| let playlist = &mut ctx.playlists[current_playlist_idx]; | ||
| playlist.clear_selection(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Direct indexing ctx.playlists[current_playlist_idx] can panic if current_playlist_idx is out of bounds (e.g., if a playlist was deleted or state is corrupted). Using .get() and .get_mut() safely handles this case.
if let Some(current_playlist_idx) = ctx.app_settings.current_playlist_idx {
if let Some(playlist) = ctx.playlists.get(current_playlist_idx) {
let selection_count = playlist.selected_indices.len();
if selection_count > 0 {
let selection_response =
ui.label(RichText::new(format!("{} selected", selection_count)).weak());
Self::start_drag_from_response(ctx, ui, &selection_response);
if ui.button("Clear Selection").clicked() {
if let Some(playlist_mut) = ctx.playlists.get_mut(current_playlist_idx) {
playlist_mut.clear_selection();
}
}
}
}
}| if let Some(current_playlist_idx) = | ||
| &ctx.app_settings.current_playlist_idx | ||
| { | ||
| let current_playlist = | ||
| &mut ctx.playlists[*current_playlist_idx]; | ||
| if !current_playlist.tracks.contains(item) { | ||
| current_playlist.add((*item).clone()); | ||
| } | ||
| ui.close_menu(); | ||
| } |
There was a problem hiding this comment.
Direct indexing ctx.playlists[*current_playlist_idx] can panic if current_playlist_idx is out of bounds. Using .get_mut() safely handles this case.
if let Some(current_playlist_idx) =
&ctx.app_settings.current_playlist_idx
{
if let Some(current_playlist) =
ctx.playlists.get_mut(*current_playlist_idx)
{
if !current_playlist.tracks.contains(item) {
current_playlist.add((*item).clone());
}
}
ui.close_menu();
}| if let Some(current_playlist_idx) = | ||
| &ctx.app_settings.current_playlist_idx | ||
| { | ||
| let current_playlist = | ||
| &mut ctx.playlists[*current_playlist_idx]; | ||
|
|
||
| // Add context menu to the header response | ||
| section.header_response.context_menu(|ui| { | ||
| // Add context menu for the folder header | ||
| if ui.button(t("add_all_to_playlist")).clicked() { | ||
| if let Some(current_playlist_idx) = | ||
| &ctx.app_settings.current_playlist_idx | ||
| { | ||
| let current_playlist = &mut ctx.playlists[*current_playlist_idx]; | ||
|
|
||
| // Add all tracks from this folder to the playlist | ||
| if let Some(items) = folder_items.get(&path_id) { | ||
| for item in items { | ||
| if !current_playlist.tracks.contains(item) { | ||
| current_playlist.add((*item).clone()); | ||
| // Add all tracks from this folder to the playlist | ||
| if let Some(items) = folder_items.get(&path_id) { | ||
| for item in items { | ||
| if !current_playlist.tracks.contains(item) { | ||
| current_playlist.add((*item).clone()); | ||
| } | ||
| } | ||
| } | ||
| ui.close_menu(); | ||
| } |
There was a problem hiding this comment.
Direct indexing ctx.playlists[*current_playlist_idx] can panic if current_playlist_idx is out of bounds. Using .get_mut() safely handles this case.
if let Some(current_playlist_idx) =
&ctx.app_settings.current_playlist_idx
{
if let Some(current_playlist) =
ctx.playlists.get_mut(*current_playlist_idx)
{
// Add all tracks from this folder to the playlist
if let Some(items) = folder_items.get(&path_id) {
for item in items {
if !current_playlist.tracks.contains(item) {
current_playlist.add((*item).clone());
}
}
}
}
ui.close_menu();
}| println!("cargo:rerun-if-changed=.git/HEAD"); | ||
| println!("cargo:rerun-if-changed=.git/packed-refs"); | ||
| if let Some(ref_path) = current_git_ref_path() { | ||
| println!("cargo:rerun-if-changed={ref_path}"); | ||
| } |
There was a problem hiding this comment.
Cargo will always rerun the build script if any file specified in rerun-if-changed does not exist. In non-git checkouts or clean environments, .git/HEAD or .git/packed-refs might be missing, which triggers unnecessary rebuilds on every compilation. Checking for their existence before printing prevents this.
| println!("cargo:rerun-if-changed=.git/HEAD"); | |
| println!("cargo:rerun-if-changed=.git/packed-refs"); | |
| if let Some(ref_path) = current_git_ref_path() { | |
| println!("cargo:rerun-if-changed={ref_path}"); | |
| } | |
| if Path::new(".git/HEAD").exists() { | |
| println!("cargo:rerun-if-changed=.git/HEAD"); | |
| } | |
| if Path::new(".git/packed-refs").exists() { | |
| println!("cargo:rerun-if-changed=.git/packed-refs"); | |
| } | |
| if let Some(ref_path) = current_git_ref_path() { | |
| if Path::new(&ref_path).exists() { | |
| println!("cargo:rerun-if-changed={ref_path}"); | |
| } | |
| } |
| pub fn export_playlist( | ||
| playlist: &Playlist, | ||
| output_path: &Path, | ||
| ) -> Result<PlaylistExportResult, String> { | ||
| if playlist.tracks.is_empty() { | ||
| return Err("Playlist has no tracks to export.".to_string()); | ||
| } | ||
|
|
||
| let file = File::create(output_path) | ||
| .map_err(|err| format!("Failed to create export file: {}", err))?; | ||
| let writer = BufWriter::new(file); | ||
| let mut zip = ZipWriter::new(writer); | ||
| let options = FileOptions::default() | ||
| .compression_method(CompressionMethod::Deflated) | ||
| .unix_permissions(0o644); | ||
|
|
||
| let mut manifest_tracks = Vec::new(); | ||
|
|
||
| for (idx, track) in playlist.tracks.iter().enumerate() { | ||
| let source_path = track.path(); | ||
| if !source_path.is_file() { | ||
| return Err(format!( | ||
| "Track file was not found: {}", | ||
| source_path.display() | ||
| )); | ||
| } | ||
|
|
||
| let archive_path = format!( | ||
| "tracks/{:03}-{}", | ||
| idx + 1, | ||
| Self::safe_file_name(&source_path, idx + 1) | ||
| ); | ||
|
|
||
| zip.start_file(&archive_path, options) | ||
| .map_err(|err| format!("Failed to add track to export: {}", err))?; | ||
| let mut source = File::open(&source_path) | ||
| .map_err(|err| format!("Failed to read {}: {}", source_path.display(), err))?; | ||
| std::io::copy(&mut source, &mut zip) | ||
| .map_err(|err| format!("Failed to copy {}: {}", source_path.display(), err))?; | ||
|
|
||
| manifest_tracks.push(PlaylistExportTrack { | ||
| position: idx + 1, | ||
| file: archive_path, | ||
| original_path: source_path.to_string_lossy().to_string(), | ||
| key: track.key(), | ||
| file_hash: track.file_hash().to_string(), | ||
| title: track.title(), | ||
| artist: track.artist(), | ||
| album: track.album(), | ||
| year: track.year(), | ||
| genre: track.genre(), | ||
| track_number: track.track_number(), | ||
| lyrics: track.lyrics(), | ||
| }); | ||
| } | ||
|
|
||
| let manifest = PlaylistExportManifest { | ||
| format: "bird-player-playlist".to_string(), | ||
| format_version: 1, | ||
| name: playlist.get_name(), | ||
| description: playlist.description(), | ||
| avatar: None, | ||
| created_at: playlist.created_at(), | ||
| updated_at: playlist.updated_at(), | ||
| tracks: manifest_tracks, | ||
| }; | ||
|
|
||
| zip.start_file("playlist.json", options) | ||
| .map_err(|err| format!("Failed to add playlist metadata: {}", err))?; | ||
| let manifest_json = serde_json::to_vec_pretty(&manifest) | ||
| .map_err(|err| format!("Failed to serialize playlist metadata: {}", err))?; | ||
| zip.write_all(&manifest_json) | ||
| .map_err(|err| format!("Failed to write playlist metadata: {}", err))?; | ||
| zip.finish() | ||
| .map_err(|err| format!("Failed to finish export zip: {}", err))?; | ||
|
|
||
| Ok(PlaylistExportResult { | ||
| output_path: output_path.to_path_buf(), | ||
| track_count: playlist.tracks.len(), | ||
| }) | ||
| } |
There was a problem hiding this comment.
If an error occurs during the export process (e.g., a track file is missing or copy fails), a corrupted or partial ZIP file is left behind at output_path. Wrapping the export logic and cleaning up the file on error prevents leaving corrupted files. Additionally, explicitly calling flush() on the writer returned by zip.finish() ensures any trailing write errors (such as disk full) are caught and handled properly.
pub fn export_playlist(
playlist: &Playlist,
output_path: &Path,
) -> Result<PlaylistExportResult, String> {
let result = Self::export_playlist_inner(playlist, output_path);
if result.is_err() {
let _ = std::fs::remove_file(output_path);
}
result
}
fn export_playlist_inner(
playlist: &Playlist,
output_path: &Path,
) -> Result<PlaylistExportResult, String> {
if playlist.tracks.is_empty() {
return Err("Playlist has no tracks to export.".to_string());
}
let file = File::create(output_path)
.map_err(|err| format!("Failed to create export file: {}", err))?;
let writer = BufWriter::new(file);
let mut zip = ZipWriter::new(writer);
let options = FileOptions::default()
.compression_method(CompressionMethod::Deflated)
.unix_permissions(0o644);
let mut manifest_tracks = Vec::new();
for (idx, track) in playlist.tracks.iter().enumerate() {
let source_path = track.path();
if !source_path.is_file() {
return Err(format!(
"Track file was not found: {}",
source_path.display()
));
}
let archive_path = format!(
"tracks/{:03}-{}",
idx + 1,
Self::safe_file_name(&source_path, idx + 1)
);
zip.start_file(&archive_path, options)
.map_err(|err| format!("Failed to add track to export: {}", err))?;
let mut source = File::open(&source_path)
.map_err(|err| format!("Failed to read {}: {}", source_path.display(), err))?;
std::io::copy(&mut source, &mut zip)
.map_err(|err| format!("Failed to copy {}: {}", source_path.display(), err))?;
manifest_tracks.push(PlaylistExportTrack {
position: idx + 1,
file: archive_path,
original_path: source_path.to_string_lossy().to_string(),
key: track.key(),
file_hash: track.file_hash().to_string(),
title: track.title(),
artist: track.artist(),
album: track.album(),
year: track.year(),
genre: track.genre(),
track_number: track.track_number(),
lyrics: track.lyrics(),
});
}
let manifest = PlaylistExportManifest {
format: "bird-player-playlist".to_string(),
format_version: 1,
name: playlist.get_name(),
description: playlist.description(),
avatar: None,
created_at: playlist.created_at(),
updated_at: playlist.updated_at(),
tracks: manifest_tracks,
};
zip.start_file("playlist.json", options)
.map_err(|err| format!("Failed to add playlist metadata: {}", err))?;
let manifest_json = serde_json::to_vec_pretty(&manifest)
.map_err(|err| format!("Failed to serialize playlist metadata: {}", err))?;
zip.write_all(&manifest_json)
.map_err(|err| format!("Failed to write playlist metadata: {}", err))?;
let mut buffered_writer = zip.finish()
.map_err(|err| format!("Failed to finish export zip: {}", err))?;
buffered_writer.flush()
.map_err(|err| format!("Failed to flush export file: {}", err))?;
Ok(PlaylistExportResult {
output_path: output_path.to_path_buf(),
track_count: playlist.tracks.len(),
})
}| fn read_lines<R: std::io::Read + Send + 'static>( | ||
| stream: R, | ||
| line_tx: std::sync::mpsc::Sender<String>, | ||
| ) -> std::thread::JoinHandle<()> { | ||
| std::thread::spawn(move || { | ||
| use std::io::BufRead; | ||
|
|
||
| let reader = std::io::BufReader::new(stream); | ||
| for line in reader.lines().map_while(Result::ok) { | ||
| let _ = line_tx.send(line); | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
reader.lines().map_while(Result::ok) silently stops reading the stream on the first UTF-8 decoding error (e.g., if yt-dlp outputs a non-UTF-8 character in a file name or terminal sequence). This can cause the app to miss the rest of the output, including the crucial downloaded file path. Using read_until and lossy UTF-8 decoding ensures the stream is read to completion.
fn read_lines<R: std::io::Read + Send + 'static>(
stream: R,
line_tx: std::sync::mpsc::Sender<String>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
use std::io::BufRead;
let mut reader = std::io::BufReader::new(stream);
let mut buf = Vec::new();
while let Ok(n) = reader.read_until(b'\n', &mut buf) {
if n == 0 {
break;
}
if buf.ends_with(&[b'\n']) {
buf.pop();
if buf.ends_with(&[b'\r']) {
buf.pop();
}
}
let line = String::from_utf8_lossy(&buf).into_owned();
let _ = line_tx.send(line);
buf.clear();
}
})
}| let mut items = Vec::new(); | ||
| for item_result in item_rows { | ||
| items.push(item_result?); | ||
| let item = item_result?; | ||
| if !items | ||
| .iter() | ||
| .any(|existing: &LibraryItem| existing.path_ref() == item.path_ref()) | ||
| { | ||
| items.push(item); | ||
| } | ||
| } |
There was a problem hiding this comment.
Filtering out duplicate paths using items.iter().any(...) inside a loop over item_rows results in HashSet to track seen paths reduces the lookup complexity to
let mut items = Vec::new();
let mut seen_paths = HashSet::new();
for item_result in item_rows {
let item = item_result?;
if seen_paths.insert(item.path()) {
items.push(item);
}
}| fn search_library(library: &Library, query: &str) -> Vec<LibrarySearchResult> { | ||
| let query = query.to_lowercase(); | ||
| library | ||
| .items() | ||
| .iter() | ||
| .filter_map(|item| { | ||
| let title = item.title().unwrap_or_default(); | ||
| let artist = item.artist().unwrap_or_default(); | ||
| let album = item.album().unwrap_or_default(); | ||
| let genre = item.genre().unwrap_or_default(); | ||
| let path = item.path().to_string_lossy().to_string(); | ||
| let haystack = | ||
| format!("{} {} {} {} {}", title, artist, album, genre, path).to_lowercase(); | ||
|
|
||
| if !haystack.contains(&query) { | ||
| return None; | ||
| } | ||
| if !tech_line.is_empty() { | ||
| ui.label( | ||
| RichText::new(tech_line) | ||
| .size(tokens::text::SM) | ||
| .color(weak_color), | ||
| ); | ||
| } | ||
| }); | ||
|
|
||
| let source = item | ||
| .path_ref() | ||
| .parent() | ||
| .and_then(|path| path.file_name()) | ||
| .and_then(|name| name.to_str()) | ||
| .unwrap_or("Library") | ||
| .to_string(); | ||
|
|
||
| Some(LibrarySearchResult { | ||
| track: item.clone(), | ||
| title, | ||
| artist, | ||
| album, | ||
| source, | ||
| }) | ||
| }) | ||
| .take(50) | ||
| .collect() | ||
| } |
There was a problem hiding this comment.
search_library is called on every keystroke. Cloning all metadata fields (title, artist, album, genre, path) and formatting them into a single string for every single item in the library causes massive allocations on the main thread. Using references (title_ref(), etc.) and only cloning fields for matching items avoids allocations for non-matching items.
fn search_library(library: &Library, query: &str) -> Vec<LibrarySearchResult> {
let query = query.to_lowercase();
library
.items()
.iter()
.filter_map(|item| {
let title = item.title_ref().unwrap_or_default();
let artist = item.artist_ref().unwrap_or_default();
let album = item.album_ref().unwrap_or_default();
let genre = item.genre_ref().unwrap_or_default();
let path = item.path_ref().to_string_lossy();
let matches = title.to_lowercase().contains(&query)
|| artist.to_lowercase().contains(&query)
|| album.to_lowercase().contains(&query)
|| genre.to_lowercase().contains(&query)
|| path.to_lowercase().contains(&query);
if !matches {
return None;
}
let source = item
.path_ref()
.parent()
.and_then(|path| path.file_name())
.and_then(|name| name.to_str())
.unwrap_or("Library")
.to_string();
Some(LibrarySearchResult {
track: item.clone(),
title: title.to_string(),
artist: artist.to_string(),
album: album.to_string(),
source,
})
})
.take(50)
.collect()
}
Summary
Verification