Skip to content

Polish player UI and playlist export - #37

Merged
RetricSu merged 30 commits into
developfrom
fix/download-lyrics-bundle-followups
Jul 6, 2026
Merged

RetricSu merged 30 commits into
developfrom
fix/download-lyrics-bundle-followups

Conversation

@RetricSu

@RetricSu RetricSu commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • add playlist export as a .birdplaylist.zip archive with ordered track files and playlist metadata
  • polish player controls, search, discover/download flows, and app chrome details
  • persist download destination, improve bundle version metadata, and document user PATH bundling note

Verification

  • cargo check
  • cargo test
  • cargo bundle --release
  • installed /Applications/Bird Player.app

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

Copy link
Copy Markdown

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 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 $O(N^2)$ duplicate path filtering with a 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.

Comment on lines +24 to 36
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();
}
}
}

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

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

Comment thread src/app/components/library_component.rs Outdated
Comment on lines +198 to +207
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();
}

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

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

Comment thread src/app/components/library_component.rs Outdated
Comment on lines 218 to 233
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();
}

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

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

Comment thread build.rs Outdated
Comment on lines +9 to +13
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}");
}

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

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.

Suggested change
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}");
}
}

Comment on lines +59 to +139
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(),
})
}

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

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(),
        })
    }

Comment on lines +279 to +291
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);
}
})
}

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

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

Comment thread src/lib/library.rs
Comment on lines 366 to 375
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);
}
}

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

Filtering out duplicate paths using items.iter().any(...) inside a loop over item_rows results in $O(N^2)$ complexity. For large music libraries, this can cause a noticeable freeze on startup. Using a HashSet to track seen paths reduces the lookup complexity to $O(1)$ and the overall loop to $O(N)$.

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

Comment on lines +733 to +769
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()
}

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

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()
    }

@RetricSu
RetricSu merged commit c5c8c78 into develop Jul 6, 2026
2 checks passed
@RetricSu
RetricSu deleted the fix/download-lyrics-bundle-followups branch July 6, 2026 10:39
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