843 lines
27 KiB
Rust
843 lines
27 KiB
Rust
use std::ops::RangeInclusive;
|
|
use std::path::PathBuf;
|
|
|
|
use iced_wgpu::core::widget::Id as WidgetId;
|
|
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme};
|
|
use iced_widget::{
|
|
button::{self, Status as ButtonStatus},
|
|
checkbox, column, container, image, lazy, mouse_area, progress_bar, row, scrollable, shader,
|
|
slider, stack, svg, text, Space,
|
|
};
|
|
|
|
use crate::visualizer::{VisualizerProgram, VizParams};
|
|
|
|
const PLAY_SVG: &[u8] = include_bytes!("../../assets/Play.svg");
|
|
const PAUSE_SVG: &[u8] = include_bytes!("../../assets/Pause.svg");
|
|
const BSKIP_SVG: &[u8] = include_bytes!("../../assets/BSkip.svg");
|
|
const FSKIP_SVG: &[u8] = include_bytes!("../../assets/FSkip.svg");
|
|
const SETTINGS_SVG: &[u8] = include_bytes!("../../assets/Settings.svg");
|
|
const LOADING_SVG: &str = include_str!("../../assets/Loading.svg");
|
|
|
|
const LOADING_DEG_PER_SEC: f32 = 120.0;
|
|
|
|
use crate::library::Track;
|
|
|
|
use super::app::{App, Message};
|
|
use super::theme::palette;
|
|
|
|
pub const SIDEBAR_W: f32 = 280.0;
|
|
pub const TOP_BAR_H: f32 = 44.0;
|
|
pub const TRANSPORT_H: f32 = 72.0;
|
|
const ROW_H: f32 = 56.0;
|
|
const THUMB: f32 = 40.0;
|
|
|
|
/// stable id of the sidebar scrollable.
|
|
pub fn sidebar_scroll_id() -> WidgetId {
|
|
WidgetId::new("yrx-sidebar-scroll")
|
|
}
|
|
|
|
/// stable id of the settings-panel scrollable.
|
|
pub fn settings_scroll_id() -> WidgetId {
|
|
WidgetId::new("yrx-settings-scroll")
|
|
}
|
|
|
|
/// assembles the top bar, sidebar, transport, and visualizer into the active layout.
|
|
pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.immersive {
|
|
visualiser(app)
|
|
} else {
|
|
column![
|
|
top_bar(app),
|
|
library_progress_strip(app),
|
|
row![sidebar(app), visualiser(app)].height(Length::Fill),
|
|
transport(app),
|
|
]
|
|
.into()
|
|
};
|
|
|
|
let body = if app.show_settings {
|
|
stack![body, settings_overlay(app)].into()
|
|
} else {
|
|
body
|
|
};
|
|
|
|
if app.coordinating_message.is_some() {
|
|
stack![body, coordinating_overlay(app)].into()
|
|
} else {
|
|
body
|
|
}
|
|
}
|
|
|
|
/// dimmed full-screen card showing the file-coordinator wait message while iOS caches the picked URL.
|
|
fn coordinating_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let msg = app.coordinating_message.clone().unwrap_or_default();
|
|
let card = container(
|
|
container(text(msg).size(16).color(palette::text()))
|
|
.padding(24)
|
|
.style(panel_style)
|
|
.max_width(420.0),
|
|
)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.style(scrim_style);
|
|
card.into()
|
|
}
|
|
|
|
fn scrim_style(_t: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.55 })),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// renders a thin progress bar under the top bar during an active library import.
|
|
fn library_progress_strip(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
match app.library_progress {
|
|
Some((current, total)) if total > 0 => {
|
|
let fraction = (current as f32 / total as f32).clamp(0.0, 1.0);
|
|
progress_bar(0.0..=1.0, fraction)
|
|
.length(Length::Fill)
|
|
.girth(Length::Fixed(3.0))
|
|
.into()
|
|
}
|
|
_ => Space::new().height(Length::Fixed(0.0)).into(),
|
|
}
|
|
}
|
|
|
|
/// builds the title row plus folder, file, and settings chip buttons.
|
|
fn top_bar(_app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let title = text("Yr Xtals").size(16).color(palette::text());
|
|
let folder_btn = chip_button("Folder", Message::OpenFolder);
|
|
#[cfg(target_os = "ios")]
|
|
let file_btn = chip_button("Library", Message::OpenFile);
|
|
#[cfg(not(target_os = "ios"))]
|
|
let file_btn = chip_button("File", Message::OpenFile);
|
|
let settings_btn = icon_chip_button(SETTINGS_SVG, Message::ToggleSettings);
|
|
|
|
let bar = row![
|
|
title,
|
|
Space::new().width(Length::Fixed(20.0)),
|
|
folder_btn,
|
|
Space::new().width(Length::Fixed(8.0)),
|
|
file_btn,
|
|
Space::new().width(Length::Fill),
|
|
settings_btn,
|
|
]
|
|
.padding(Padding::from([0, 16]))
|
|
.spacing(0)
|
|
.align_y(iced_wgpu::core::Alignment::Center)
|
|
.height(Length::Fill);
|
|
|
|
container(bar)
|
|
.width(Length::Fill)
|
|
.height(Length::Fixed(TOP_BAR_H))
|
|
.style(panel_style)
|
|
.into()
|
|
}
|
|
|
|
/// hash key feeding iced's lazy cache, redrawing only after a meaningful sidebar change.
|
|
#[derive(Hash)]
|
|
struct SidebarKey {
|
|
selected: Option<usize>,
|
|
rows: Vec<TrackRowKey>,
|
|
}
|
|
|
|
/// minimal per-row identity used inside the sidebar lazy hash.
|
|
#[derive(Hash)]
|
|
struct TrackRowKey {
|
|
path: PathBuf,
|
|
title: String,
|
|
artist: Option<String>,
|
|
has_art: bool,
|
|
}
|
|
|
|
/// builds the lazy-cached scrollable list of tracks down the left edge.
|
|
fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
if app.library.tracks.is_empty() {
|
|
return container(empty_library_hint())
|
|
.width(Length::Fixed(SIDEBAR_W))
|
|
.height(Length::Fill)
|
|
.style(sidebar_style)
|
|
.into();
|
|
}
|
|
|
|
let key = SidebarKey {
|
|
selected: app.selected_track,
|
|
rows: app
|
|
.library
|
|
.tracks
|
|
.iter()
|
|
.map(|t| TrackRowKey {
|
|
path: t.path.clone(),
|
|
title: t.title.clone(),
|
|
artist: t.artist.clone(),
|
|
has_art: t.art.is_some(),
|
|
})
|
|
.collect(),
|
|
};
|
|
|
|
let tracks: Vec<Track> = app.library.tracks.clone();
|
|
let selected = app.selected_track;
|
|
|
|
let inner = lazy(key, move |_| {
|
|
let mut col = column![].spacing(2).padding(Padding::from([8, 8]));
|
|
for (i, t) in tracks.iter().enumerate() {
|
|
col = col.push(track_row_owned(i, t.clone(), selected == Some(i)));
|
|
}
|
|
scrollable(col)
|
|
.id(sidebar_scroll_id())
|
|
.on_scroll(|vp| Message::SidebarScrolled(vp.absolute_offset()))
|
|
.height(Length::Fill)
|
|
});
|
|
|
|
container(inner)
|
|
.width(Length::Fixed(SIDEBAR_W))
|
|
.height(Length::Fill)
|
|
.style(sidebar_style)
|
|
.into()
|
|
}
|
|
|
|
/// placeholder shown inside the sidebar before any tracks load.
|
|
fn empty_library_hint<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
let copy = text("No tracks loaded.\n\nUse Folder or File above\nto load some music.")
|
|
.size(13)
|
|
.color(palette::text_dim());
|
|
container(copy)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding(Padding::from(24))
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// renders a single sidebar row over a cloned, owned Track.
|
|
fn track_row_owned(
|
|
idx: usize,
|
|
track: Track,
|
|
active: bool,
|
|
) -> Element<'static, Message, Theme, iced_wgpu::Renderer> {
|
|
let thumb: Element<'static, Message, Theme, iced_wgpu::Renderer> = match track.art {
|
|
Some(handle) => image(handle)
|
|
.width(Length::Fixed(THUMB))
|
|
.height(Length::Fixed(THUMB))
|
|
.into(),
|
|
None => container(Space::new())
|
|
.width(Length::Fixed(THUMB))
|
|
.height(Length::Fixed(THUMB))
|
|
.style(art_placeholder_style)
|
|
.into(),
|
|
};
|
|
|
|
let title_color = if active { palette::text() } else { palette::text_dim() };
|
|
let title_widget = text(track.title)
|
|
.size(13)
|
|
.color(title_color)
|
|
.width(Length::Fill);
|
|
let artist_line = match track.artist {
|
|
Some(a) => text(a).size(11).color(palette::text_dim()),
|
|
None => text(String::new()).size(11).color(palette::text_dim()),
|
|
};
|
|
let info = column![title_widget, artist_line]
|
|
.spacing(2)
|
|
.width(Length::Fill);
|
|
|
|
let inner = row![thumb, info]
|
|
.spacing(10)
|
|
.align_y(iced_wgpu::core::Alignment::Center)
|
|
.padding(Padding::from([6, 8]))
|
|
.width(Length::Fill)
|
|
.height(Length::Fixed(ROW_H));
|
|
|
|
let btn = iced_widget::button(inner)
|
|
.padding(0)
|
|
.on_press(Message::SelectTrack(idx))
|
|
.width(Length::Fill)
|
|
.style(move |_t: &Theme, status: ButtonStatus| {
|
|
let bg = match (active, status) {
|
|
(true, _) => Some(Background::Color(Color {
|
|
a: 0.20,
|
|
..palette::accent()
|
|
})),
|
|
(false, ButtonStatus::Hovered) => Some(Background::Color(Color {
|
|
a: 0.06,
|
|
..palette::accent()
|
|
})),
|
|
_ => None,
|
|
};
|
|
button::Style {
|
|
background: bg,
|
|
text_color: palette::text(),
|
|
border: Border {
|
|
color: Color::TRANSPARENT,
|
|
width: 0.0,
|
|
radius: 6.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
});
|
|
btn.into()
|
|
}
|
|
|
|
/// shader-backed visualizer surface stacked under loading and empty-state overlays.
|
|
fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let no_track = app.selected_track.is_none();
|
|
|
|
let viz: Element<'_, Message, Theme, iced_wgpu::Renderer> = shader(
|
|
VisualizerProgram::new(
|
|
app.frame_data.clone(),
|
|
params_from(&app.settings),
|
|
app.current_palette.clone(),
|
|
),
|
|
)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into();
|
|
|
|
let overlay: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.track_loading {
|
|
loading_overlay()
|
|
} else if no_track {
|
|
centered_overlay_text("Pick a track from the sidebar", palette::text())
|
|
} else {
|
|
Space::new().width(Length::Fill).height(Length::Fill).into()
|
|
};
|
|
|
|
let layered = stack![viz, overlay];
|
|
|
|
let bordered = container(layered)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.style(|_t: &Theme| container::Style {
|
|
background: Some(Background::Color(palette::bg())),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
});
|
|
|
|
mouse_area(bordered).on_press(Message::ToggleChrome).into()
|
|
}
|
|
|
|
/// animated cog and label overlay shown during track decode.
|
|
fn loading_overlay<'a>() -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
|
|
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
|
|
let elapsed = START.get_or_init(std::time::Instant::now).elapsed().as_secs_f32();
|
|
let angle = elapsed * LOADING_DEG_PER_SEC;
|
|
let bytes = loading_svg_at_angle(angle);
|
|
let handle = svg::Handle::from_memory(bytes);
|
|
let spinner = svg::Svg::new(handle)
|
|
.width(Length::Fixed(160.0))
|
|
.height(Length::Fixed(160.0));
|
|
let label = text("Loading.").size(36).color(palette::text());
|
|
let stack = column![spinner, label]
|
|
.spacing(12)
|
|
.align_x(iced_wgpu::core::Alignment::Center);
|
|
container(stack)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// rewrites the cog and nautilus rotate transforms inside the loading SVG.
|
|
fn loading_svg_at_angle(angle_deg: f32) -> Vec<u8> {
|
|
let mut s = LOADING_SVG.to_string();
|
|
rewrite_rotate_angle(&mut s, "Cog", angle_deg);
|
|
rewrite_rotate_angle(&mut s, "Nautilus", -angle_deg);
|
|
s.into_bytes()
|
|
}
|
|
|
|
/// patches the angle inside an SVG element's rotate transform without touching surrounding markup.
|
|
fn rewrite_rotate_angle(s: &mut String, id: &str, angle_deg: f32) {
|
|
let id_marker = format!(r#"id="{}""#, id);
|
|
let Some(id_pos) = s.find(&id_marker) else { return };
|
|
let Some(elem_start) = s[..id_pos].rfind('<') else { return };
|
|
let Some(elem_end_rel) = s[id_pos..].find('>') else { return };
|
|
let elem_end = id_pos + elem_end_rel;
|
|
let key = r#"transform="rotate("#;
|
|
let Some(off) = s[elem_start..elem_end].find(key) else { return };
|
|
let inner_start = elem_start + off + key.len();
|
|
let Some(close_rel) = s[inner_start..elem_end].find(')') else { return };
|
|
let inner_end = inner_start + close_rel;
|
|
let parts: Vec<&str> = s[inner_start..inner_end].split_whitespace().collect();
|
|
let new_inner = match parts.len() {
|
|
1 => format!("{:.2}", angle_deg),
|
|
2 => format!("{:.2} {}", angle_deg, parts[1]),
|
|
_ => format!("{:.2} {} {}", angle_deg, parts[1], parts[2]),
|
|
};
|
|
s.replace_range(inner_start..inner_end, &new_inner);
|
|
}
|
|
|
|
/// fills the visualizer area with a single centered string.
|
|
fn centered_overlay_text<'a>(
|
|
label: &'a str,
|
|
color: Color,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
container(text(label).size(15).color(color))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// projects the App settings struct down to the visualizer parameter subset.
|
|
fn params_from(s: &super::app::Settings) -> VizParams {
|
|
VizParams {
|
|
glass: s.glass,
|
|
entropy_on: s.entropy_on,
|
|
entropy_strength: s.entropy_strength,
|
|
album_colors: s.album_colors,
|
|
mirrored: s.mirrored,
|
|
inverted: s.inverted,
|
|
hue: s.hue,
|
|
contrast: s.contrast,
|
|
brightness: s.brightness,
|
|
}
|
|
}
|
|
|
|
pub const SETTINGS_W: f32 = 340.0;
|
|
|
|
/// right-aligned settings panel built from grouped slider and toggle rows.
|
|
fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let s = &app.settings;
|
|
|
|
let body = column![
|
|
Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)),
|
|
text("Settings").size(15).color(palette::text()),
|
|
Space::new().height(Length::Fixed(10.0)),
|
|
section_label("style"),
|
|
toggle_row("glass", s.glass, Message::SetGlass),
|
|
toggle_row("album colors", s.album_colors, Message::SetAlbumColors),
|
|
toggle_row("mirrored", s.mirrored, Message::SetMirrored),
|
|
toggle_row("inverted", s.inverted, Message::SetInverted),
|
|
Space::new().height(Length::Fixed(8.0)),
|
|
section_label("color"),
|
|
slider_row(
|
|
"hue",
|
|
s.hue,
|
|
0.0..=1.0,
|
|
0.01,
|
|
format!("{:.2}", s.hue),
|
|
Message::SetHue,
|
|
),
|
|
slider_row(
|
|
"contrast",
|
|
s.contrast,
|
|
0.0..=2.0,
|
|
0.01,
|
|
format!("{:.2}", s.contrast),
|
|
Message::SetContrast,
|
|
),
|
|
slider_row(
|
|
"brightness",
|
|
s.brightness,
|
|
0.1..=2.0,
|
|
0.01,
|
|
format!("{:.2}", s.brightness),
|
|
Message::SetBrightness,
|
|
),
|
|
Space::new().height(Length::Fixed(8.0)),
|
|
section_label("entropy filter"),
|
|
toggle_row("enabled", s.entropy_on, Message::SetEntropy),
|
|
slider_row(
|
|
"strength",
|
|
s.entropy_strength,
|
|
-1.5..=1.5,
|
|
0.05,
|
|
format!("{:+.2}", s.entropy_strength),
|
|
Message::SetEntropyStrength,
|
|
),
|
|
Space::new().height(Length::Fixed(8.0)),
|
|
section_label("dsp"),
|
|
slider_row(
|
|
"bins",
|
|
s.num_bins as f32,
|
|
8.0..=128.0,
|
|
1.0,
|
|
format!("{}", s.num_bins),
|
|
|v| Message::SetNumBins(v as u32),
|
|
),
|
|
|
|
pow2_slider_row("fft", s.fft, 9, 16, Message::SetFft),
|
|
pow2_slider_row(
|
|
"hop",
|
|
s.hop,
|
|
6,
|
|
(s.fft / 2).max(64).trailing_zeros(),
|
|
Message::SetHop,
|
|
),
|
|
Space::new().height(Length::Fixed(8.0)),
|
|
section_label("cepstral smoothing"),
|
|
slider_row(
|
|
"granularity",
|
|
s.granularity as f32,
|
|
1.0..=100.0,
|
|
1.0,
|
|
format!("{}", s.granularity),
|
|
|v| Message::SetGranularity(v as i32),
|
|
),
|
|
slider_row(
|
|
"detail",
|
|
s.detail as f32,
|
|
1.0..=100.0,
|
|
1.0,
|
|
format!("{}", s.detail),
|
|
|v| Message::SetDetail(v as i32),
|
|
),
|
|
slider_row(
|
|
"strength",
|
|
s.strength,
|
|
0.0..=1.0,
|
|
0.01,
|
|
format!("{:.2}", s.strength),
|
|
Message::SetStrength,
|
|
),
|
|
Space::new().height(Length::Fixed(8.0)),
|
|
section_label("fft engine blend"),
|
|
slider_row(
|
|
"cpu ↔ gpu",
|
|
s.gpu_blend,
|
|
0.0..=1.0,
|
|
0.01,
|
|
format!("{:.2}", s.gpu_blend),
|
|
Message::SetGpuBlend,
|
|
),
|
|
]
|
|
.spacing(8)
|
|
.padding(Padding::from(16))
|
|
.width(Length::Fixed(SETTINGS_W));
|
|
|
|
let scroll = scrollable(body)
|
|
.id(settings_scroll_id())
|
|
.on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset()))
|
|
.height(Length::Fill);
|
|
|
|
let close = container(icon_chip_button(SETTINGS_SVG, Message::ToggleSettings))
|
|
.width(Length::Fill)
|
|
.height(Length::Fixed(TOP_BAR_H))
|
|
.padding(Padding::from([0, 16]))
|
|
.align_x(iced_wgpu::core::alignment::Horizontal::Right)
|
|
.align_y(iced_wgpu::core::alignment::Vertical::Center);
|
|
|
|
let panel = container(stack![scroll, close])
|
|
.width(Length::Fixed(SETTINGS_W))
|
|
.height(Length::Fill)
|
|
.style(settings_panel_style);
|
|
|
|
container(panel)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.align_right(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// label + slider + value-text trio used by every numeric setting.
|
|
fn slider_row<'a, F>(
|
|
label: &'a str,
|
|
value: f32,
|
|
range: RangeInclusive<f32>,
|
|
step: f32,
|
|
value_text: String,
|
|
on_change: F,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
|
|
where
|
|
F: 'a + Fn(f32) -> Message,
|
|
{
|
|
let label_w = 110.0;
|
|
let value_w = 60.0;
|
|
container(
|
|
row![
|
|
container(text(label).size(13).color(palette::text_dim()))
|
|
.width(Length::Fixed(label_w)),
|
|
slider(range, value, on_change).step(step).width(Length::Fill).height(28.0),
|
|
container(
|
|
text(value_text)
|
|
.size(13)
|
|
.color(palette::text())
|
|
.align_x(iced_wgpu::core::alignment::Horizontal::Right)
|
|
)
|
|
.width(Length::Fixed(value_w))
|
|
.align_right(Length::Fixed(value_w)),
|
|
]
|
|
.spacing(12)
|
|
.align_y(iced_wgpu::core::Alignment::Center)
|
|
)
|
|
.height(Length::Fixed(44.0))
|
|
.into()
|
|
}
|
|
|
|
/// slider that snaps to powers of two, exposed as a log2 axis underneath.
|
|
fn pow2_slider_row<'a, F>(
|
|
label: &'a str,
|
|
current: u32,
|
|
min_log2: u32,
|
|
max_log2: u32,
|
|
on_change: F,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
|
|
where
|
|
F: 'a + Fn(u32) -> Message,
|
|
{
|
|
let max_log2 = max_log2.max(min_log2);
|
|
let resolved = current.max(1).next_power_of_two();
|
|
let cur_log2 = resolved.trailing_zeros().clamp(min_log2, max_log2);
|
|
let value_text = format!("{}", 1u32 << cur_log2);
|
|
slider_row(
|
|
label,
|
|
cur_log2 as f32,
|
|
min_log2 as f32..=max_log2 as f32,
|
|
1.0,
|
|
value_text,
|
|
move |lv| on_change(1u32 << (lv as u32)),
|
|
)
|
|
}
|
|
|
|
/// label + checkbox pair used by every boolean setting.
|
|
fn toggle_row<'a, F>(
|
|
label: &'a str,
|
|
value: bool,
|
|
on_change: F,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer>
|
|
where
|
|
F: 'a + Fn(bool) -> Message,
|
|
{
|
|
container(
|
|
row![
|
|
container(text(label).size(13).color(palette::text_dim()))
|
|
.width(Length::Fixed(110.0)),
|
|
checkbox(value).on_toggle(on_change).size(26),
|
|
]
|
|
.spacing(12)
|
|
.align_y(iced_wgpu::core::Alignment::Center)
|
|
)
|
|
.height(Length::Fixed(40.0))
|
|
.into()
|
|
}
|
|
|
|
/// dim small-caps heading separating groups of settings rows.
|
|
fn section_label<'a>(label: &'a str) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
container(text(label).size(10).color(palette::text_dim()))
|
|
.padding(Padding::from([0, 0]))
|
|
.into()
|
|
}
|
|
|
|
/// translucent backdrop styling for the settings overlay.
|
|
fn settings_panel_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color {
|
|
a: 0.96,
|
|
..palette::sidebar()
|
|
})),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// bottom transport bar with skip, play/pause, scrub slider, and position readout.
|
|
fn transport(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
|
|
let prev = transport_button(BSKIP_SVG, Message::Prev, app.selected_track.is_some());
|
|
let engine_playing = app
|
|
.engine
|
|
.as_ref()
|
|
.map(|e| e.is_playing())
|
|
.unwrap_or(app.playing);
|
|
let play_glyph = if engine_playing { PAUSE_SVG } else { PLAY_SVG };
|
|
let play = transport_button(play_glyph, Message::TogglePlayPause, app.selected_track.is_some());
|
|
let next = transport_button(FSKIP_SVG, Message::Next, app.selected_track.is_some());
|
|
|
|
let pos = app.position();
|
|
let scrub = slider(0.0..=1.0, pos, Message::Seek)
|
|
.step(0.001_f32)
|
|
.width(Length::Fill);
|
|
let pos_label = text(format!("{:>5.1}%", pos * 100.0))
|
|
.size(11)
|
|
.color(palette::text_dim());
|
|
|
|
let bar = row![
|
|
prev,
|
|
Space::new().width(Length::Fixed(6.0)),
|
|
play,
|
|
Space::new().width(Length::Fixed(6.0)),
|
|
next,
|
|
Space::new().width(Length::Fixed(20.0)),
|
|
scrub,
|
|
Space::new().width(Length::Fixed(12.0)),
|
|
pos_label,
|
|
]
|
|
.padding(Padding::from([0, 16]))
|
|
.align_y(iced_wgpu::core::Alignment::Center)
|
|
.height(Length::Fill);
|
|
|
|
container(bar)
|
|
.width(Length::Fill)
|
|
.height(Length::Fixed(TRANSPORT_H))
|
|
.style(panel_style)
|
|
.into()
|
|
}
|
|
|
|
/// small soft accent-tinted text-label button.
|
|
fn chip_button<'a>(
|
|
label: &'a str,
|
|
msg: Message,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
iced_widget::button(text(label).size(13).color(palette::text()))
|
|
.padding(Padding::from([6, 12]))
|
|
.on_press(msg)
|
|
.style(|_t: &Theme, status: ButtonStatus| {
|
|
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
|
|
button::Style {
|
|
background: Some(Background::Color(if hovered {
|
|
Color {
|
|
a: 0.12,
|
|
..palette::accent()
|
|
}
|
|
} else {
|
|
Color {
|
|
a: 0.04,
|
|
..palette::accent()
|
|
}
|
|
})),
|
|
text_color: palette::text(),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 6.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// chip button variant carrying an inline SVG glyph.
|
|
fn icon_chip_button<'a>(
|
|
glyph: &'static [u8],
|
|
msg: Message,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
let handle = svg::Handle::from_memory(glyph);
|
|
let icon = svg::Svg::new(handle)
|
|
.width(Length::Fixed(16.0))
|
|
.height(Length::Fixed(16.0))
|
|
.style(|_t: &Theme, _status| svg::Style {
|
|
color: Some(palette::text()),
|
|
});
|
|
|
|
iced_widget::button(icon)
|
|
.padding(Padding::from([4, 10]))
|
|
.on_press(msg)
|
|
.style(|_t: &Theme, status: ButtonStatus| {
|
|
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
|
|
button::Style {
|
|
background: Some(Background::Color(if hovered {
|
|
Color {
|
|
a: 0.12,
|
|
..palette::accent()
|
|
}
|
|
} else {
|
|
Color {
|
|
a: 0.04,
|
|
..palette::accent()
|
|
}
|
|
})),
|
|
text_color: palette::text(),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 6.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// large icon button used by the transport bar, dimmed and disabled without a track.
|
|
fn transport_button<'a>(
|
|
glyph: &'static [u8],
|
|
msg: Message,
|
|
enabled: bool,
|
|
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
|
|
let handle = svg::Handle::from_memory(glyph);
|
|
let tint = if enabled { palette::text() } else { palette::text_dim() };
|
|
let icon = svg::Svg::new(handle)
|
|
.width(Length::Fixed(22.0))
|
|
.height(Length::Fixed(22.0))
|
|
.style(move |_t: &Theme, _status| svg::Style { color: Some(tint) });
|
|
|
|
let mut btn = iced_widget::button(icon).padding(Padding::from([6, 12]));
|
|
if enabled {
|
|
btn = btn.on_press(msg);
|
|
}
|
|
btn.style(|_t: &Theme, status: ButtonStatus| {
|
|
let hovered = matches!(status, ButtonStatus::Hovered | ButtonStatus::Pressed);
|
|
button::Style {
|
|
background: if hovered {
|
|
Some(Background::Color(Color {
|
|
a: 0.10,
|
|
..palette::accent()
|
|
}))
|
|
} else {
|
|
None
|
|
},
|
|
text_color: palette::text(),
|
|
border: Border {
|
|
color: Color::TRANSPARENT,
|
|
width: 0.0,
|
|
radius: 8.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// flat sidebar-tinted background with a single hairline border.
|
|
fn panel_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(palette::sidebar())),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// track-list sidebar background and border styling.
|
|
fn sidebar_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(palette::sidebar())),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 0.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// faded square shown in place of cover art before the art worker resolves a track.
|
|
fn art_placeholder_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(Background::Color(Color {
|
|
a: 0.20,
|
|
..palette::text_dim()
|
|
})),
|
|
border: Border {
|
|
color: palette::border(),
|
|
width: 1.0,
|
|
radius: 4.0.into(),
|
|
},
|
|
..Default::default()
|
|
}
|
|
}
|