diff --git a/lang/template.json b/lang/template.json index 7555aeb..cc6daa7 100644 --- a/lang/template.json +++ b/lang/template.json @@ -114,7 +114,9 @@ "Bell lifts or cuts around a frequency; shelves move everything above or below it; cuts remove it.": "", "Between lines": "", "Bitrate (kbps)": "", + "Blue": "", "Broadcast / encoders": "", + "Brown": "", "Browse": "", "Browsers and apps trust it like any website. Port 80 of each domain name has to reach the server; it is renewed on its own.": "", "COMING UP NEXT": "", @@ -148,7 +150,7 @@ "Clear pad": "", "Clear playlist": "", "Click an item in the list to select it": "", - "Click to fire (plays over the main program); click a red, playing pad to stop it. Right-click a pad to assign or relabel.": "", + "Click to fire (plays over the main program); click a red, playing pad to stop it. Right-click a pad to assign audio, rename it or pick a colour.": "", "Click to pick a date": "", "Click ➕ and press the keys, or click Learn and press a pad, key or button on your MIDI controller. A key or control can only do one thing; giving it to another action moves it.": "", "Click ➕ to add a tab.": "", @@ -156,6 +158,7 @@ "Close": "", "Close all Additional windows": "", "Close tab": "", + "Colour:": "", "Command": "", "Comment": "", "Comments": "", @@ -187,6 +190,7 @@ "Day": "", "Days": "", "Default": "", + "Default colour": "", "Delete": "", "Delete from the station": "", "Delete request": "", @@ -285,6 +289,9 @@ "Go": "", "Go to (minutes:seconds)": "", "Go to a time in the track": "", + "Gold": "", + "Green": "", + "Grey": "", "HTTP GET URL": "", "HTTPS, certificate from a public authority": "", "HTTPS, the server's own certificate that you trusted": "", @@ -342,7 +349,6 @@ "L": "", "LAST TRACK": "", "LOG": "", - "Label:": "", "Language": "", "Language loaded, translated texts": "", "Last 6 s of this track into the next one, with the fades as set": "", @@ -457,6 +463,7 @@ "Off: no sound card is used and the station only streams": "", "Ogg Vorbis (ffmpeg)": "", "Ogg: some servers show titles only for MP3/AAC": "", + "Olive": "", "On": "", "On / off": "", "On air": "", @@ -473,11 +480,13 @@ "Open playlist…": "", "Open the Removed tracks folder": "", "Opus in Ogg (ffmpeg)": "", + "Orange": "", "Outputs": "", "Outro ends when it drops": "", "Outro sound": "", "Overlap on cold endings": "", "PLAYBACK QUEUE": "", + "Pad name:": "", "Parametric": "", "Password": "", "Password (12+ characters for setup)": "", @@ -490,6 +499,7 @@ "Pick a sound file": "", "Pick the audio file": "", "Pick the ffmpeg program": "", + "Pink": "", "Pitch": "", "Play": "", "Play adverts / jingles from a rule": "", @@ -531,6 +541,7 @@ "Previous": "", "Profile:": "", "Projected start times": "", + "Purple": "", "Put a playlist tab on air": "", "Put the web portal on the internet with a Cloudflare Tunnel": "", "Queue all": "", @@ -656,6 +667,7 @@ "Show every file in the folder, not only audio": "", "Show every file, not only audio": "", "Show every track in the library or playlist next to the editor": "", + "Shown on the pad": "", "Shuffle upcoming": "", "Sign in": "", "Sign in to access station controls.": "", @@ -709,6 +721,7 @@ "Take": "", "Take out of the library and playlists": "", "Talking points, chart facts, dedications…": "", + "Teal": "", "Teaser mode — play only the first": "", "Templates": "", "Test API key": "", diff --git a/src/app.rs b/src/app.rs index 1b4cfb3..9124ce0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -437,6 +437,62 @@ pub(super) fn date_edit(ui: &mut egui::Ui, id: impl std::hash::Hash, value: &mut changed } +/// Background colours offered in a cart pad's right-click menu. +fn cart_colors() -> [(&'static str, [u8; 3]); 10] { + [ + (tr("Blue"), [40, 80, 130]), + (tr("Teal"), [20, 120, 120]), + (tr("Green"), [46, 125, 50]), + (tr("Olive"), [110, 130, 30]), + (tr("Gold"), [190, 150, 20]), + (tr("Orange"), [205, 105, 30]), + (tr("Pink"), [185, 65, 125]), + (tr("Purple"), [110, 60, 160]), + (tr("Brown"), [120, 80, 50]), + (tr("Grey"), [90, 90, 90]), + ] +} + +/// A cart wall pad: always exactly `size`, whatever the name, so the grid stays even. +/// Long names wrap onto two lines and are cut off with "…" (the full name shows in the tooltip). +fn cart_pad(ui: &mut egui::Ui, size: [f32; 2], label: &str, fill: Color32, enabled: bool) -> egui::Response { + let (rect, resp) = ui.allocate_exact_size(egui::vec2(size[0], size[1]), egui::Sense::click()); + if ui.is_rect_visible(rect) { + let v = ui.style().interact(&resp); + let fill = if resp.is_pointer_button_down_on() { + fill.gamma_multiply(0.8) + } else if resp.hovered() && enabled { + fill.lerp_to_gamma(Color32::WHITE, 0.12) + } else { + fill + }; + // WCAG relative luminance: black text where it contrasts more than white (above about 0.18) + let lin = |c: u8| { + let c = c as f32 / 255.0; + if c <= 0.04045 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + let lum = 0.2126 * lin(fill.r()) + 0.7152 * lin(fill.g()) + 0.0722 * lin(fill.b()); + let text = if lum > 0.179 { Color32::BLACK } else { Color32::WHITE }; + ui.painter().rect(rect, v.corner_radius, fill, v.bg_stroke, egui::StrokeKind::Inside); + let font = egui::TextStyle::Button.resolve(ui.style()); + let mut job = egui::text::LayoutJob::simple(label.to_owned(), font, text, rect.width() - 10.0); + job.wrap.max_rows = 2; + job.wrap.break_anywhere = true; + job.halign = egui::Align::Center; + let galley = ui.painter().layout_job(job); + let pos = egui::pos2(rect.center().x, rect.center().y - galley.size().y / 2.0); + ui.painter().galley(pos, galley.clone(), text); + if galley.elided { + return resp.on_hover_text(label); + } + } + resp +} + fn fmt(secs: f32) -> String { let s = secs.max(0.0) as u64; if s >= 3600 { @@ -2710,25 +2766,46 @@ impl App { let pad: &mut CartPad = &mut core.cfg.carts[i]; let loaded = !pad.path.is_empty(); let on = loaded && playing.iter().any(|p| p.as_path() == std::path::Path::new(&pad.path)); - let label = if loaded { pad.label.clone() } else { format!("Pad {}", i + 1) }; + let label = if loaded && !pad.label.is_empty() { pad.label.clone() } else { format!("Pad {}", i + 1) }; let fill = if on { Color32::from_rgb(200, 60, 60) + } else if let Some([r, g, b]) = pad.color { + Color32::from_rgb(r, g, b) } else if loaded { Color32::from_rgb(40, 80, 130) } else { Color32::from_gray(48) }; - let b = ui.add_sized([pad_width, 54.0], egui::Button::new(RichText::new(label).strong()).fill(fill).wrap()); + let b = cart_pad(ui, [pad_width, 54.0], &label, fill, loaded); if b.clicked() && loaded && may_play { trigger = Some(i); } + let b = if loaded { b.on_hover_text(&pad.path) } else { b }; b.context_menu(|ui| { if ui.button(tr("Assign audio file…")).clicked() { self.pick(Pick::File(AUDIO.0, AUDIO.1), tr("Assign audio file"), move |_, v| Some(Dlg::Cart(i, v.into_iter().next()?))); ui.close_menu(); } - ui.label(tr("Label:")); - ui.add(egui::TextEdit::singleline(&mut pad.label).desired_width(220.0)); + ui.label(tr("Pad name:")); + ui.add(egui::TextEdit::singleline(&mut pad.label).desired_width(220.0).hint_text(tr("Shown on the pad"))); + ui.label(tr("Colour:")); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0); + for (name, [r, g, b]) in cart_colors() { + let c = Color32::from_rgb(r, g, b); + let picked = pad.color == Some([r, g, b]); + let sw = ui + .add(egui::Button::new("").fill(c).min_size(egui::vec2(20.0, 20.0)).selected(picked)) + .on_hover_text(name); + if sw.clicked() { + pad.color = Some([r, g, b]); + } + } + }); + if ui.add_enabled(pad.color.is_some(), egui::Button::new(tr("Default colour"))).clicked() { + pad.color = None; + } + ui.separator(); if ui.button(tr("Clear pad")).clicked() { *pad = CartPad::default(); ui.close_menu(); @@ -2743,7 +2820,7 @@ impl App { core.trigger_cart(i); } ui.label( - RichText::new(tr("Click to fire (plays over the main program); click a red, playing pad to stop it. Right-click a pad to assign or relabel.")) + RichText::new(tr("Click to fire (plays over the main program); click a red, playing pad to stop it. Right-click a pad to assign audio, rename it or pick a colour.")) .small() .color(GRAY), ); diff --git a/src/config.rs b/src/config.rs index f030be6..73e17a1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -248,6 +248,8 @@ impl Default for AzuraConfig { pub struct CartPad { pub label: String, pub path: String, + /// background colour (sRGB); `None` uses the default pad colour + pub color: Option<[u8; 3]>, } #[derive(Serialize, Deserialize, Clone)] @@ -915,6 +917,15 @@ mod rule_tests { assert!(c.rotations.is_empty() && c.ad_breaks.is_empty()); } + #[test] + fn cart_pads_keep_their_colour_and_old_pads_have_none() { + let old: CartPad = serde_json::from_str(r#"{"label":"ID","path":"/j/id.mp3"}"#).unwrap(); + assert_eq!(old.color, None); + let pad = CartPad { label: "News in".into(), path: "/j/news.mp3".into(), color: Some([200, 60, 60]) }; + let back: CartPad = serde_json::from_str(&serde_json::to_string(&pad).unwrap()).unwrap(); + assert_eq!((back.label.as_str(), back.color), ("News in", Some([200, 60, 60]))); + } + #[test] fn rules_follow_dates_days_hours_and_intervals() { // Tuesday 20 October 2026