kopuz-components 0.7.0

A modern, lightweight music player built with Rust and Dioxus.
#[cfg(all(not(target_arch = "wasm32"), target_os = "macos"))]
use dioxus::desktop::window;
use dioxus::prelude::*;
use kopuz_route::Route;

use crate::sidebar::SidebarProps;

#[derive(PartialEq, Clone)]
struct NavItem {
    key: &'static str,
    route: Route,
    icon: &'static str,
}

const SECTIONS: &[(&str, &[NavItem])] = &[
    (
        "discover",
        &[
            NavItem {
                key: "home",
                route: Route::Home,
                icon: "fa-solid fa-house",
            },
            NavItem {
                key: "search",
                route: Route::Search,
                icon: "fa-solid fa-magnifying-glass",
            },
            NavItem {
                key: "discover",
                route: Route::Discover,
                icon: "fa-solid fa-compass",
            },
            NavItem {
                key: "radio",
                route: Route::Radio,
                icon: "fa-solid fa-radio",
            },
        ],
    ),
    (
        "library",
        &[
            NavItem {
                key: "library",
                route: Route::Library,
                icon: "fa-solid fa-music",
            },
            NavItem {
                key: "albums",
                route: Route::Album,
                icon: "fa-solid fa-record-vinyl",
            },
            NavItem {
                key: "artists",
                route: Route::Artist,
                icon: "fa-solid fa-user",
            },
            NavItem {
                key: "favorites",
                route: Route::Favorites,
                icon: "fa-solid fa-heart",
            },
            NavItem {
                key: "activity",
                route: Route::Activity,
                icon: "fa-solid fa-chart-simple",
            },
            NavItem {
                key: "playlists",
                route: Route::Playlists,
                icon: "fa-solid fa-list",
            },
        ],
    ),
];

#[cfg(all(not(target_arch = "wasm32"), not(target_os = "android")))]
const TOOL_ITEMS: &[NavItem] = &[
    NavItem {
        key: "ytdlp",
        route: Route::Ytdlp,
        icon: "fa-solid fa-download",
    },
    NavItem {
        key: "settings",
        route: Route::Settings,
        icon: "fa-solid fa-gear",
    },
];

#[cfg(any(target_arch = "wasm32", target_os = "android"))]
const TOOL_ITEMS: &[NavItem] = &[NavItem {
    key: "settings",
    route: Route::Settings,
    icon: "fa-solid fa-gear",
}];

#[component]
pub fn SidebarModern(props: SidebarProps) -> Element {
    let config = use_context::<Signal<config::AppConfig>>();
    let mut width = use_signal(|| 200i32);
    let mut is_collapsed = use_signal(|| false);
    let mut is_resizing = use_signal(|| false);

    let is_android = cfg!(target_os = "android");
    let fallback_collapse = use_signal(|| true);
    let mut mobile_collapsed = try_consume_context::<crate::sidebar::SidebarCollapsed>()
        .map(|c| c.0)
        .unwrap_or(fallback_collapse);

    let current_width = if *is_collapsed.read() {
        56
    } else {
        *width.read()
    };

    let onmousemove = move |evt: MouseEvent| {
        if *is_resizing.read() {
            let new_width = evt.client_coordinates().x as i32;
            if *is_collapsed.read() {
                if new_width > 160 {
                    is_collapsed.set(false);
                    width.set(new_width);
                }
            } else if new_width < 130 {
                is_collapsed.set(true);
            } else if new_width < 400 {
                width.set(new_width);
            }
        }
    };
    let onmouseup = move |_| is_resizing.set(false);

    // Discover is a capability of the active source (YT), not a config flag.
    let active_source = use_context::<Signal<::server::source::ActiveSource>>();
    let has_discover = use_memo(move || active_source.read().capabilities().discover);
    let collapsed = if is_android {
        false
    } else {
        *is_collapsed.read()
    };
    let current_route = *props.current_route.read();

    let root_class = if is_android {
        "h-full flex flex-col shrink-0 select-none relative border-r border-white/10 overflow-hidden transition-all duration-300 ease-out"
    } else {
        "h-full flex flex-col shrink-0 select-none relative border-r border-white/5"
    };
    let root_style = if is_android {
        if *mobile_collapsed.read() {
            "position: fixed; left: 0; top: 0; z-index: 100; height: 100%; width: 0px; background: rgba(10,10,10,0.97);"
                .to_string()
        } else {
            "position: fixed; left: 0; top: 0; z-index: 100; height: 100%; width: 280px; background: rgba(10,10,10,0.97);".to_string()
        }
    } else {
        // Theme-following surface (not a fixed black overlay) so the Vaxry chrome
        // harmonises with the active palette and the switcher text stays readable
        // on light themes.
        format!("width: {current_width}px; background: var(--color-neutral-900);")
    };

    rsx! {
        if *is_resizing.read() {
            div {
                class: "fixed inset-0 z-[100] cursor-col-resize",
                onmousemove,
                onmouseup,
            }
        }
        if is_android && !*mobile_collapsed.read() {
            div {
                class: "fixed inset-0 bg-black/80 backdrop-blur-[2px] z-[90]",
                onclick: move |_| mobile_collapsed.set(true),
            }
        }

        div {
            class: "{root_class}",
            style: "{root_style}",

            if is_android {
                div {
                    class: "flex items-center justify-between px-5 border-b border-white/5 bg-white/5 shrink-0",
                    style: "padding-top: max(env(safe-area-inset-top), 16px); padding-bottom: 16px;",
                    h2 {
                        class: "text-base font-bold tracking-widest text-white/90 uppercase",
                        style: "font-family: 'JetBrains Mono', monospace;",
                        "KOPUZ"
                    }
                    button {
                        class: "p-2 rounded-xl bg-white/10 text-white active:scale-95 transition-all flex items-center justify-center border border-white/10 w-9 h-9",
                        onclick: move |_| mobile_collapsed.set(true),
                        i { class: "fa-solid fa-xmark text-base" }
                    }
                }
            }

            if cfg!(all(not(target_arch = "wasm32"), target_os = "macos")) {
                div {
                    class: "h-10 flex-shrink-0",
                    onmousedown: move |_| {
                        #[cfg(all(not(target_arch = "wasm32"), target_os = "macos"))]
                        window().drag();
                    }
                }
            }

            if !cfg!(target_arch = "wasm32") && config.read().show_source_toggle {
                crate::source_switcher::SourceSwitcher {
                    config,
                    collapsed,
                    on_manage: move |_| props.on_navigate.call(Route::Settings),
                }
            }

            div { class: "flex-1 overflow-y-auto overflow-x-hidden py-2",
                for (section_key, items) in SECTIONS {
                    div { class: "mb-2",
                        if !collapsed {
                            div { class: "px-4 pt-3 pb-1",
                                span {
                                    class: "text-[10px] font-bold",
                                    style: "color: rgba(255,255,255,0.25);",
                                    "{i18n::t(section_key)}"
                                }
                            }
                        }
                        for item in *items {
                            if item.route != Route::Discover || has_discover() {
                                ModernNavItem {
                                    key: "{item.key}",
                                    item: item.clone(),
                                    active: current_route == item.route,
                                    collapsed,
                                    onclick: move |_| {
                                        props.on_navigate.call(item.route);
                                        if is_android { mobile_collapsed.set(true); }
                                    },
                                }
                            }
                        }
                    }
                }

                div { class: "mx-3 my-2 h-px", style: "background: rgba(255,255,255,0.06);" }
                for item in TOOL_ITEMS {
                    ModernNavItem {
                        key: "{item.key}",
                        item: item.clone(),
                        active: current_route == item.route,
                        collapsed,
                        onclick: move |_| {
                            props.on_navigate.call(item.route);
                            if is_android { mobile_collapsed.set(true); }
                        },
                    }
                }
            }

            div {
                class: "absolute top-0 right-0 w-2 h-full cursor-col-resize z-50",
                onmousedown: move |_| is_resizing.set(true),
            }
        }
    }
}

#[component]
fn ModernNavItem(
    item: NavItem,
    active: bool,
    collapsed: bool,
    onclick: EventHandler<MouseEvent>,
) -> Element {
    rsx! {
        a {
            class: "flex items-center gap-3 cursor-pointer transition-colors relative mx-1 rounded-lg",
            style: if active {
                "padding: 6px 10px; background: color-mix(in oklab, var(--color-indigo-500) 15%, transparent);"
            } else {
                "padding: 6px 10px;"
            },
            title: if collapsed { i18n::t(item.key) } else { String::new() },
            onclick: move |evt| onclick.call(evt),

            div {
                class: "w-5 h-5 flex items-center justify-center shrink-0 text-sm",
                style: if active {
                    "color: var(--color-indigo-500);"
                } else {
                    "color: rgba(255,255,255,0.4);"
                },
                i { class: "{item.icon}" }
            }

            if !collapsed {
                span {
                    class: "text-sm font-medium truncate",
                    style: if active {
                        "color: var(--color-indigo-500); font-weight: 600;"
                    } else {
                        "color: rgba(255,255,255,0.7);"
                    },
                    "{i18n::t(item.key)}"
                }
            }
        }
    }
}