//! The source switcher: Local + every configured server as a uniform list, pick
//! one to make it active. Replaces the old binary Local⇄Server toggle — no
//! local-vs-server branching, and it reaches any number of servers.
//!
//! A compact trigger (the active source's brand-tinted icon tile + name, with a
//! small connection dot on the tile corner) opens a flat popover that scrolls
//! past ~8 sources, so the control stays neat with one source or a dozen. Each
//! source carries its service's accent colour (a CSS `--accent` var the styles
//! read); the active row is highlighted. Styling matches the sidebar's flat,
//! single-line nav items rather than a glassy stand-alone widget.
use config::{AppConfig, MusicService, Source};
use dioxus::prelude::*;
use hooks::source_switch::ConnStatus;
/// Set by the switcher's "Manage sources" button to a Settings section's element
/// id, asking the Settings page to scroll there instead of restoring its last
/// scroll position. Provided by the app root.
#[derive(Clone, Copy)]
pub struct SettingsAnchor(pub Signal<Option<String>>);
/// Static styles for the switcher (keyframes + classes that read a per-element
/// `--accent`/`--active` CSS variable). Injected once; rendered with the trigger
/// so the closed control is styled too.
// Colours route through two indirection vars set on the wrapper: `--ss-surface`
// (the popover/tile base) and `--ss-fg` (the foreground). Both follow the active
// theme palette (`--color-neutral-900` / `--color-white`), and the dimmed tones
// are `--ss-fg`-based `color-mix` (foreground at reduced alpha), so the switcher
// harmonises with the theme (e.g. gruvbox) in both UI styles and tracks
// light/dark. Per-service brand accents stay fixed regardless.
const SWITCHER_CSS: &str = r#"
.ss-tr{width:100%;display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:transparent;border:1px solid color-mix(in oklab,var(--ss-fg) 9%,transparent);cursor:pointer;color:inherit;transition:background .15s,border-color .15s}
.ss-tr:hover{background:color-mix(in oklab,var(--ss-fg) 6%,transparent)}
.ss-tr.ss-open{background:color-mix(in oklab,var(--ss-fg) 6%,transparent);border-color:color-mix(in oklab,var(--ss-fg) 18%,transparent)}
.ss-mini{width:40px;height:40px;padding:0;justify-content:center;border-radius:8px}
.ss-ico{display:grid;place-items:center;width:18px;flex-shrink:0;color:var(--accent);font-size:15px}
.ss-svg{width:1.05em;height:1.05em;display:block}
.ss-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.ss-on{background:#3fb950}
.ss-off{background:#e5534b}
.ss-load{background:#d8a23a;animation:ss-pulse 1.1s ease-in-out infinite}
@keyframes ss-pulse{0%,100%{opacity:.4}50%{opacity:1}}
.ss-stk{flex:1;text-align:left;min-width:0}
.ss-tname{display:block;font-size:13.5px;font-weight:500;letter-spacing:-.01em;color:var(--ss-fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ss-chev{font-size:10px;color:color-mix(in oklab,var(--ss-fg) 38%,transparent);transition:transform .18s}
.ss-tr.ss-open .ss-chev{transform:rotate(180deg)}
.ss-pop{position:absolute;top:calc(100% + 6px);left:0;right:0;z-index:50;border-radius:10px;border:1px solid color-mix(in oklab,var(--ss-fg) 11%,transparent);overflow:hidden auto;max-height:60vh;background:var(--ss-surface);box-shadow:0 12px 30px -18px rgba(0,0,0,.55)}
.ss-pop-mini{left:calc(100% + 12px);right:auto;top:-4px;width:218px}
.ss-head{display:flex;align-items:center;justify-content:space-between;padding:10px 12px 8px;border-bottom:1px solid color-mix(in oklab,var(--ss-fg) 9%,transparent)}
.ss-head .t{font-size:12px;font-weight:600;color:color-mix(in oklab,var(--ss-fg) 70%,transparent)}
.ss-head .c{font-size:12px;color:color-mix(in oklab,var(--ss-fg) 38%,transparent)}
.ss-list{padding:5px}
.ss-row{position:relative;width:100%;display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:7px;cursor:pointer;color:inherit;background:none;border:0;text-align:left;transition:background .12s}
.ss-row:hover{background:color-mix(in oklab,var(--ss-fg) 7%,transparent)}
.ss-meta{flex:1;min-width:0}
.ss-rname{display:block;font-size:14px;font-weight:500;letter-spacing:-.01em;color:color-mix(in oklab,var(--ss-fg) 85%,transparent);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ss-rsub{display:block;font-size:11px;color:color-mix(in oklab,var(--ss-fg) 45%,transparent);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ss-row.ss-act{background:color-mix(in oklab,var(--ss-fg) 7%,transparent)}
.ss-row.ss-act .ss-rname{color:var(--ss-fg);font-weight:600}
.ss-check{font-size:11px;color:var(--accent)}
.ss-foot{padding:5px;border-top:1px solid color-mix(in oklab,var(--ss-fg) 9%,transparent)}
.ss-foot button{width:100%;display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:7px;color:color-mix(in oklab,var(--ss-fg) 60%,transparent);font-size:13px;font-weight:500;background:none;border:0;cursor:pointer;text-align:left;transition:background .12s,color .12s}
.ss-foot button:hover{background:color-mix(in oklab,var(--ss-fg) 7%,transparent);color:var(--ss-fg)}
.ss-foot button .ar{margin-left:auto;font-size:9px}
"#;
/// Local uses the active theme's accent so it reads as native (servers keep
/// their fixed brand colours).
const LOCAL_ACCENT: &str = "var(--color-indigo-500)";
/// One selectable source: key, label, icon class, accent colour, mono subline.
fn entries(config: &AppConfig) -> Vec<(Source, String, &'static str, &'static str, String)> {
let mut v = vec![(
Source::Local,
i18n::t("local").to_string(),
"fa-solid fa-hard-drive",
LOCAL_ACCENT,
i18n::t("source_on_this_device").to_string(),
)];
for local in &config.local_sources {
v.push((
Source::LocalLibrary(local.id.clone()),
local.name.clone(),
"fa-solid fa-folder-tree",
LOCAL_ACCENT,
i18n::t("source_on_this_device").to_string(),
));
}
for s in &config.servers {
let (icon, accent) = service_style(s.service);
v.push((
Source::Server(s.id.clone()),
s.name.clone(),
icon,
accent,
s.service.display_name().to_uppercase(),
));
}
v
}
/// Icon + accent colour per service, so each source reads at a glance.
fn service_style(service: MusicService) -> (&'static str, &'static str) {
match service {
MusicService::YtMusic => ("fa-brands fa-youtube", "#ff3355"),
MusicService::SoundCloud => ("fa-brands fa-soundcloud", "#ff7a33"),
MusicService::AppleMusic => ("fa-brands fa-apple", "#ffffff"),
MusicService::Spotify => ("fa-brands fa-spotify", "#1DB954"),
MusicService::Jellyfin => ("fa-solid fa-server", "#b277ee"),
MusicService::Subsonic | MusicService::Custom => ("fa-solid fa-compact-disc", "#f0a84b"),
MusicService::Nextcloud => (NEXTCLOUD_ICON, "#0082c9"),
}
}
/// Stands in for a Font Awesome class where the brand mark has to be drawn
/// instead: the vendored Font Awesome 6 Free carries no Nextcloud glyph.
const NEXTCLOUD_ICON: &str = "brand-nextcloud";
/// The Nextcloud mark, from the Simple Icons set (CC0). Sized in `em` and
/// filled with `currentColor`, so it takes the accent and scale of the font
/// glyph it stands in for.
const NEXTCLOUD_PATH: &str = "M12.018 6.537c-2.5 0-4.6 1.712-5.241 4.015-.56-1.232-1.793-2.105-3.225-2.105A3.569 3.569 0 0 0 0 12a3.569 3.569 0 0 0 3.552 3.553c1.432 0 2.664-.874 3.224-2.106.641 2.304 2.742 4.016 5.242 4.016 2.487 0 4.576-1.693 5.231-3.977.569 1.21 1.783 2.067 3.198 2.067A3.568 3.568 0 0 0 24 12a3.569 3.569 0 0 0-3.553-3.553c-1.416 0-2.63.858-3.199 2.067-.654-2.284-2.743-3.978-5.23-3.977zm0 2.085c1.878 0 3.378 1.5 3.378 3.378 0 1.878-1.5 3.378-3.378 3.378A3.362 3.362 0 0 1 8.641 12c0-1.878 1.5-3.378 3.377-3.378zm-8.466 1.91c.822 0 1.467.645 1.467 1.468s-.644 1.467-1.467 1.468A1.452 1.452 0 0 1 2.085 12c0-.823.644-1.467 1.467-1.467zm16.895 0c.823 0 1.468.645 1.468 1.468s-.645 1.468-1.468 1.468A1.452 1.452 0 0 1 18.98 12c0-.823.644-1.467 1.467-1.467z";
/// A service icon: a font glyph, or the drawn mark for brands the font lacks.
#[component]
fn ServiceGlyph(icon: &'static str) -> Element {
if icon == NEXTCLOUD_ICON {
rsx! {
svg {
class: "ss-svg",
view_box: "0 0 24 24",
fill: "currentColor",
"aria-hidden": "true",
path { d: NEXTCLOUD_PATH }
}
}
} else {
rsx! {
i { class: "{icon}" }
}
}
}
#[component]
pub fn SourceSwitcher(
config: Signal<AppConfig>,
#[props(default = false)] collapsed: bool,
#[props(default)] on_manage: Option<EventHandler<()>>,
) -> Element {
let mut open = use_signal(|| false);
// Full switch (loads server creds + syncs config.server), so a sidebar switch
// is identical to the Settings one — not just an active_source flip.
let switch = hooks::source_switch::use_switch_source();
// Live auth/connection status of the active source, for the status indicator.
let conn = hooks::source_switch::use_connection_status();
let sources = entries(&config.read());
let count = sources.len();
let active = config.read().active_source.clone();
// Follow the active theme palette in both UI styles (the chrome does too), so
// the switcher harmonises with the theme instead of a fixed dark.
let surface_vars = "--ss-surface:var(--color-neutral-900);--ss-fg:var(--color-white);";
let (active_label, active_icon, active_accent) = sources
.iter()
.find(|(s, ..)| *s == active)
.map(|(_, l, i, a, _)| (l.clone(), *i, *a))
.unwrap_or_else(|| {
(
i18n::t("local").to_string(),
"fa-solid fa-hard-drive",
LOCAL_ACCENT,
)
});
rsx! {
div {
class: if collapsed { "relative flex justify-center py-3 border-b border-white/5" } else { "relative px-3 pt-3 pb-2 border-b border-white/5" },
style: "--accent:{active_accent};{surface_vars}",
style { dangerous_inner_html: SWITCHER_CSS }
button {
class: match (collapsed, open()) {
(true, true) => "ss-tr ss-mini ss-open",
(true, false) => "ss-tr ss-mini",
(false, true) => "ss-tr ss-open",
(false, false) => "ss-tr",
},
title: "{active_label}",
onclick: move |_| open.set(!open()),
span { class: "ss-ico", ServiceGlyph { icon: active_icon } }
if !collapsed {
span { class: "ss-stk",
span { class: "ss-tname", "{active_label}" }
}
span {
class: match conn() {
ConnStatus::Online => "ss-dot ss-on",
ConnStatus::Connecting => "ss-dot ss-load",
_ => "ss-dot ss-off",
},
}
i { class: "fa-solid fa-chevron-down ss-chev" }
}
}
if open() {
div { class: "fixed inset-0 z-40", onclick: move |_| open.set(false) }
div {
class: if collapsed { "ss-pop ss-pop-mini" } else { "ss-pop" },
div { class: "ss-head",
span { class: "t", "{i18n::t(\"sources\")}" }
span { class: "c", "{count}" }
}
div { class: "ss-list",
for (src , label , icon , accent , sub) in sources.into_iter() {
{
let is_active = src == active;
let switch = switch.clone();
rsx! {
button {
key: "{src.as_str()}",
class: if is_active { "ss-row ss-act" } else { "ss-row" },
style: "--accent:{accent};",
onclick: move |_| {
switch(src.clone());
open.set(false);
},
span { class: "ss-ico", ServiceGlyph { icon } }
span { class: "ss-meta",
span { class: "ss-rname", "{label}" }
if !collapsed {
span { class: "ss-rsub", "{sub}" }
}
}
if is_active {
i { class: "fa-solid fa-check ss-check" }
}
}
}
}
}
}
if !collapsed && let Some(manage) = on_manage {
div { class: "ss-foot",
button {
onclick: move |_| {
// Ask Settings to land on the sources section instead of
// restoring its last scroll position.
if let Some(SettingsAnchor(mut anchor)) =
try_consume_context::<SettingsAnchor>()
{
anchor.set(Some("settings-media-servers".to_string()));
}
open.set(false);
manage.call(());
},
i { class: "fa-solid fa-sliders" }
"{i18n::t(\"manage_sources\")}"
i { class: "fa-solid fa-arrow-right ar" }
}
}
}
}
}
}
}
}