kopuz-components 0.7.0

A modern, lightweight music player built with Rust and Dioxus.
use dioxus::prelude::*;
use hooks::db_reactivity::Table;
use hooks::use_player_controller::PlayerController;
use reader::Track;
use std::collections::HashSet;
use std::path::PathBuf;

#[derive(Props, Clone, PartialEq)]
pub struct TrackListViewProps {
    pub name: String,
    pub description: String,
    #[props(default)]
    pub on_description_click: Option<EventHandler<()>>,
    pub cover_url: Option<utils::CoverUrl>,
    pub back_label: String,
    pub tracks: Vec<Track>,
    #[props(default = false)]
    pub is_album: bool,
    pub on_close: EventHandler<()>,
    pub on_cover_click: Option<EventHandler<()>>,
    pub on_delete_track: Option<EventHandler<usize>>,
    pub on_selection_delete: Option<EventHandler<Vec<PathBuf>>>,
    pub on_remove_from_playlist: Option<EventHandler<usize>>,
    pub on_download_all: Option<EventHandler<()>>,
    pub on_delete_all: Option<EventHandler<()>>,
    pub on_download_track: Option<EventHandler<usize>>,
    #[props(default = false)]
    pub is_downloading_all: bool,
    #[props(default = false)]
    pub is_reorderable: bool,
    #[props(default)]
    pub on_move_up: EventHandler<usize>,
    #[props(default)]
    pub on_move_down: EventHandler<usize>,
    #[props(default = true)]
    pub show_delete_in_selection: bool,
    #[props(default = false)]
    pub enable_metadata: bool,
    pub actions: Option<Element>,
}

#[component]
pub fn TrackListView(props: TrackListViewProps) -> Element {
    let mut ctrl = use_context::<PlayerController>();
    let mut active_menu_track = use_signal(|| None::<reader::TrackId>);
    let mut show_playlist_modal = use_signal(|| false);
    let mut selected_track_for_playlist = use_signal(|| None::<reader::TrackId>);
    let mut is_selection_mode = use_signal(|| false);
    let mut selected_tracks = use_signal(HashSet::<reader::TrackId>::new);
    let mut metadata_track = use_signal(|| None::<Track>);
    let gens = hooks::db_reactivity::use_generations();

    let view_metadata_handler = if props.enable_metadata {
        let tracks_meta = props.tracks.clone();
        Some(EventHandler::new(move |idx: usize| {
            if let Some(t) = tracks_meta.get(idx) {
                metadata_track.set(Some(t.clone()));
                active_menu_track.set(None);
            }
        }))
    } else {
        None
    };

    let tracks_select_all = props.tracks.clone();
    let tracks_long_press = props.tracks.clone();
    let tracks_select = props.tracks.clone();
    let tracks_play_all = props.tracks.clone();
    let tracks_play = props.tracks.clone();
    let tracks_add = props.tracks.clone();
    let tracks_queue = props.tracks.clone();
    let tracks_menu = props.tracks.clone();
    let tracks_sel_delete = props.tracks.clone();
    let tracks_sel_queue = props.tracks.clone();

    rsx! {
        div { class: "w-full max-w-[1600px] mx-auto select-none flex-1 min-h-0 flex flex-col",
            if !cfg!(target_os = "android") {
                div { class: "flex items-center mb-8 shrink-0",
                    button {
                        class: "flex items-center gap-2 text-slate-400 hover:text-white transition-colors",
                        onclick: move |_| props.on_close.call(()),
                        i { class: "fa-solid fa-arrow-left" }
                        "{props.back_label}"
                    }
                }
            }

            crate::showcase::Showcase {
                name: props.name.clone(),
                description: props.description.clone(),
                on_description_click: props.on_description_click,
                cover_url: props.cover_url.clone(),
                tracks: props.tracks.clone(),
                is_album: props.is_album,
                is_selection_mode: is_selection_mode(),
                selected_tracks: selected_tracks.read().clone(),
                all_selected: !props.tracks.is_empty()
                    && props.tracks.iter().all(|t| selected_tracks.read().contains(&t.id)),
                on_cover_click: props.on_cover_click,
                actions: props.actions,
                on_select_all: move |selected: bool| {
                    if selected {
                        selected_tracks
                            .set(tracks_select_all.iter().map(|t| t.id.clone()).collect());
                        is_selection_mode.set(true);
                    }
                    else {
                        selected_tracks.write().clear();
                        is_selection_mode.set(false);
                    }
                },
                on_long_press: move |idx: usize| {
                    if let Some(t) = tracks_long_press.get(idx) {
                        is_selection_mode.set(true);
                        selected_tracks.write().insert(t.id.clone());
                    }
                },
                on_select: move |(idx, sel): (usize, bool)| {
                    if let Some(t) = tracks_select.get(idx) {
                        if sel {
                            is_selection_mode.set(true);
                            selected_tracks.write().insert(t.id.clone());
                        } else {
                            selected_tracks.write().remove(&t.id);
                            if selected_tracks.read().is_empty() {
                                is_selection_mode.set(false);
                            }
                        }
                    }
                },
                on_play_all: move |_| {
                    let is_shuffle = *ctrl.shuffle.peek();
                    if is_shuffle {
                        ctrl.play_queue_shuffled(tracks_play_all.clone());
                    } else {
                        ctrl.play_queue_linear(tracks_play_all.clone());
                    }
                },
                on_play: move |idx: usize| {
                    ctrl.queue.set(tracks_play.clone());
                    ctrl.play_track(idx);
                },
                on_add_to_playlist: move |idx: usize| {
                    if let Some(t) = tracks_add.get(idx) {
                        selected_track_for_playlist.set(Some(t.id.clone()));
                        show_playlist_modal.set(true);
                        active_menu_track.set(None);
                    }
                },
                on_queue: move |idx: usize| {
                    if let Some(t) = tracks_queue.get(idx) {
                        ctrl.add_to_queue(vec![t.clone()]);
                        active_menu_track.set(None);
                    }
                },
                active_track: active_menu_track.read().clone(),
                on_click_menu: move |idx: usize| {
                    if let Some(t) = tracks_menu.get(idx) {
                        if active_menu_track.read().as_ref() == Some(&t.id) {
                            active_menu_track.set(None);
                        } else {
                            active_menu_track.set(Some(t.id.clone()));
                        }
                    }
                },
                on_close_menu: move |_| active_menu_track.set(None),
                on_view_metadata: view_metadata_handler,
                on_delete_track: props.on_delete_track,
                on_remove_from_playlist: props.on_remove_from_playlist,
                on_download_all: props.on_download_all,
                on_delete_all: props.on_delete_all,
                on_download_track: props.on_download_track,
                is_downloading_all: props.is_downloading_all,
                is_reorderable: props.is_reorderable,
                on_move_up: props.on_move_up,
                on_move_down: props.on_move_down,
            }

            if is_selection_mode() {
                crate::selection_bar::SelectionBar {
                    count: selected_tracks.read().len(),
                    show_delete: props.show_delete_in_selection,
                    on_add_to_queue: move |_| {
                        let selected = selected_tracks.read().clone();
                        if selected.is_empty() {
                            return;
                        }
                        let tracks: Vec<_> = tracks_sel_queue
                            .iter()
                            .filter(|t| selected.contains(&t.id))
                            .cloned()
                            .collect();
                        if !tracks.is_empty() {
                            ctrl.add_to_queue(tracks);
                        }
                        selected_tracks.write().clear();
                        is_selection_mode.set(false);
                    },
                    on_add_to_playlist: move |_| show_playlist_modal.set(true),
                    on_delete: move |_| {
                        let paths: Vec<PathBuf> = tracks_sel_delete
                            .iter()
                            .filter(|t| selected_tracks.read().contains(&t.id))
                            .filter_map(|t| t.id.local_path().map(|p| p.to_path_buf()))
                            .collect();
                        if let Some(ref h) = props.on_selection_delete {
                            h.call(paths);
                        }
                        selected_tracks.write().clear();
                        is_selection_mode.set(false);
                    },
                    on_cancel: move |_| {
                        is_selection_mode.set(false);
                        selected_tracks.write().clear();
                    },
                }
            }

            if let Some(track) = metadata_track.read().clone() {
                crate::metadata_modal::MetadataModal {
                    track: track.clone(),
                    on_close: move |_| metadata_track.set(None),
                    on_save: move |edits: reader::models::TrackEdits| {
                        let Some(path) = track.id.local_path().map(|p| p.to_path_buf()) else {
                            return;
                        };
                        match reader::write_tags(&path, &edits) {
                            Ok(()) => {
                                let mut t = track.clone();
                                t.title = edits.title.trim().to_string();
                                t.artist = edits.artist.trim().to_string();
                                t.artists = edits
                                    .artist
                                    .split([';', ','])
                                    .map(|a| a.trim().to_string())
                                    .filter(|s| !s.is_empty())
                                    .collect();
                                t.album = edits.album.trim().to_string();
                                t.track_number = edits.track_number;
                                t.disc_number = edits.disc_number;
                                t.album_id = reader::metadata::make_album_id(
                                    edits.album.trim(),
                                    edits.artist.trim(),
                                );
                                let source = consume_context::<Signal<::server::source::ActiveSource>>().peek().clone();
                                spawn(async move {
                                    if source.upsert_tracks(&[t]).await.is_ok() {
                                        gens.bump(Table::Tracks);
                                    }
                                });
                                metadata_track.set(None);
                            }
                            Err(e) => {
                                tracing::error!("failed to write tags for {}: {}", path.display(), e);
                            }
                        }
                    },
                }
            }

            if *show_playlist_modal.read() {
                crate::playlist_modal::PlaylistModal {
                    on_close: move |_| {
                        show_playlist_modal.set(false);
                        if is_selection_mode() {
                            is_selection_mode.set(false);
                            selected_tracks.write().clear();
                        }
                    },
                    on_add_to_playlist: move |playlist_id: String| {
                        let mut paths = Vec::new();
                        if is_selection_mode() {
                            paths = selected_tracks.read().iter().cloned().collect();
                        } else if let Some(path) = selected_track_for_playlist.read().clone() {
                            paths.push(path);
                        }
                        if !paths.is_empty() {
                            let refs: Vec<String> = paths
                                .iter()
                                .map(|p| p.key().into_owned())
                                .collect();
                            let source = consume_context::<Signal<::server::source::ActiveSource>>().peek().clone();
                            spawn(async move {
                                if source.add_to_playlist(&playlist_id, &refs).await.is_ok() {
                                    gens.bump(Table::Playlists);
                                }
                            });
                        }
                        show_playlist_modal.set(false);
                        is_selection_mode.set(false);
                        selected_tracks.write().clear();
                    },
                    on_create_playlist: move |name: String| {
                        let mut paths = Vec::new();
                        if is_selection_mode() {
                            paths = selected_tracks.read().iter().cloned().collect();
                        } else if let Some(path) = selected_track_for_playlist.read().clone() {
                            paths.push(path);
                        }
                        if !paths.is_empty() {
                            let refs: Vec<String> = paths
                                .iter()
                                .map(|p| p.key().into_owned())
                                .collect();
                            let source = consume_context::<Signal<::server::source::ActiveSource>>().peek().clone();
                            spawn(async move {
                                if source.create_playlist(&name, &refs).await.is_ok() {
                                    gens.bump(Table::Playlists);
                                }
                            });
                        }
                        show_playlist_modal.set(false);
                        is_selection_mode.set(false);
                        selected_tracks.write().clear();
                    },
                }
            }
        }
    }
}