diff --git a/lang/template.json b/lang/template.json index de9d2ce..4179590 100644 --- a/lang/template.json +++ b/lang/template.json @@ -431,6 +431,7 @@ "Next ▶": "", "No": "", "No MIDI controller found — plug one in and it's picked up within a few seconds.": "", + "No adverts ticked: this rule plays nothing.": "", "No adverts yet. Add one, pick its audio file and the dates it should run.": "", "No artist, title is \"Artist - Title\"": "", "No audio length": "", @@ -443,6 +444,7 @@ "No plugins yet. \"Open plugins folder\" adds a working example to start from.": "", "No port forwarding needed. The server runs Cloudflare's cloudflared program, which has to be installed on it.": "", "No rule plays from this list yet — in a rule, choose \"From: the Adverts list\".": "", + "No rule plays this advert.": "", "No rules yet. Add an advert or jingle rule above.": "", "No sound": "", "No tabs in this panel": "", @@ -731,6 +733,7 @@ "Templates": "", "Test API key": "", "Text file": "", + "The Adverts list is empty. Add adverts there first.": "", "The AzuraCast tool is optional. Now-playing info only needs the address and station; managing the station needs an API key.": "", "The best copy (lossless, then highest bitrate) is kept; pick another with its button. Untick a group to leave it alone.": "", "The library is being scanned; results may change when it finishes.": "", @@ -760,6 +763,7 @@ "This profile has no file paths.": "", "This report": "", "Threshold": "", + "Tick all": "", "Tick every device that should play the on-air mix. The first working device is the primary; the others get a copy (monitor / headphones / second sound card). Each has its own volume.": "", "Time": "", "Time announcement": "", @@ -790,6 +794,7 @@ "Type a time like 1:30 or 90": "", "Type one above (https://…) and press Enter.": "", "Type your own buffer size in milliseconds. The sound card may round it to a size it supports; the size in use is shown below.": "", + "Untick all": "", "Up next:": "", "Up one folder": "", "Up to 16 bands; each picks its filter, frequency, gain and width": "", @@ -864,6 +869,8 @@ "a folder or chosen files": "", "administrator": "", "advert": "", + "advert name": "", + "advertiser / company": "", "adverts": "", "after every": "", "after the playlist (instead of replacing it)": "", @@ -886,6 +893,7 @@ "egui · rodio · symphonia · LAME": "", "encoder": "", "every": "", + "every advert": "", "failed": "", "files": "", "files checked": "", @@ -934,6 +942,7 @@ "name": "", "new folder name": "", "no": "", + "no audio file": "", "none": "", "none: a quick tunnel": "", "not found (only needed for formats other than MP3)": "", @@ -945,6 +954,7 @@ "on the clock": "", "on the server": "", "one file at random": "", + "only the adverts I tick": "", "optional": "", "optional folder": "", "password": "", @@ -985,6 +995,7 @@ "this template": "", "this time of day": "", "this web panel": "", + "ticked": "", "to": "", "tools": "", "tracks": "", @@ -1113,6 +1124,7 @@ "📋 Rules": "", "📜 History": "", "📝 Notepad…": "", + "📢 Open the Adverts list": "", "📻 Overview": "", "🔀 Reshuffle": "", "🔌 Disconnect live DJ": "", diff --git a/src/adsui.rs b/src/adsui.rs index 000c0ab..b30a32f 100644 --- a/src/adsui.rs +++ b/src/adsui.rs @@ -55,6 +55,8 @@ fn kind_tag(ui: &mut egui::Ui, adverts: bool) { impl App { pub(super) fn tab_ads(&mut self, ui: &mut egui::Ui, core: &mut Playout) { + // adverts from older versions (or an older server) have no id yet; rules choose by id + core.cfg.ensure_ad_ids(); ui.horizontal(|ui| { ui.selectable_value(&mut self.ads.view, AdsView::Rules, RichText::new(tr("📋 Rules")).strong()); ui.selectable_value( @@ -202,6 +204,25 @@ impl App { fn rule_editor(&mut self, ui: &mut egui::Ui, core: &mut Playout, i: usize) { let ads_n = core.cfg.ads.len(); + let now = Local::now(); + // (id, label, file, state) of each advert in the list, for choosing which ones the rule plays + let ads: Vec<(u64, String, String, RichText)> = core + .cfg + .ads + .iter() + .map(|a| { + let state = if !a.enabled { + RichText::new(tr("off")).small().color(GRAY) + } else if a.path.trim().is_empty() { + RichText::new(tr("no audio file")).small().color(AMBER) + } else if a.active(&now) { + RichText::new(tr("● running today")).small().color(GREEN) + } else { + RichText::new(tr("○ not today")).small().color(AMBER) + }; + (a.id, a.label(), a.path.clone(), state) + }) + .collect(); let r = &mut core.cfg.ad_rules[i]; let mut act: Option<&str> = None; ui.horizontal(|ui| { @@ -235,6 +256,9 @@ impl App { true, format!("{} ({} {})", tr("the Adverts list — each advert only in its own dates and days"), ads_n, tr("adverts")), ); + if r.use_ad_list { + ui.indent("rule_ads", |ui| self.rule_ad_picker(ui, r, &ads)); + } ui.radio_value(&mut r.use_ad_list, false, tr("a folder or chosen files")); } if !(r.adverts && r.use_ad_list) { @@ -407,10 +431,61 @@ impl App { } } + /// Every advert in the list, or only the ticked ones (several can be ticked). + fn rule_ad_picker(&mut self, ui: &mut egui::Ui, r: &mut AdRule, ads: &[(u64, String, String, RichText)]) { + ui.horizontal(|ui| { + ui.radio_value(&mut r.ad_choose, false, tr("every advert")); + if ui.radio_value(&mut r.ad_choose, true, tr("only the adverts I tick")).clicked() && r.ad_ids.is_empty() { + // start with them all ticked; untick the ones this rule shouldn't play + r.ad_ids = ads.iter().map(|a| a.0).collect(); + } + }); + if !r.ad_choose { + return; + } + if ads.is_empty() { + ui.label(RichText::new(tr("The Adverts list is empty. Add adverts there first.")).small().color(AMBER)); + if ui.button(tr("📢 Open the Adverts list")).clicked() { + self.ads.view = AdsView::Adverts; + } + return; + } + ui.horizontal(|ui| { + if ui.small_button(tr("Tick all")).clicked() { + r.ad_ids = ads.iter().map(|a| a.0).collect(); + } + if ui.small_button(tr("Untick all")).clicked() { + r.ad_ids.clear(); + } + let n = ads.iter().filter(|a| r.ad_ids.contains(&a.0)).count(); + ui.label(RichText::new(format!("{n} / {} {}", ads.len(), tr("ticked"))).small().color(GRAY)); + }); + egui::ScrollArea::vertical().id_salt(("rule_ads_scroll", r.id)).max_height(220.0).show(ui, |ui| { + for (id, label, path, state) in ads { + ui.horizontal(|ui| { + let mut on = r.ad_ids.contains(id); + let hover = if path.is_empty() { tr("no audio file").to_string() } else { path.clone() }; + if ui.checkbox(&mut on, label.as_str()).on_hover_text(hover).changed() { + if on { + r.ad_ids.push(*id); + } else { + r.ad_ids.retain(|x| x != id); + } + } + ui.label(state.clone()); + }); + } + }); + if !ads.iter().any(|a| r.ad_ids.contains(&a.0)) { + ui.label(RichText::new(tr("No adverts ticked: this rule plays nothing.")).small().color(AMBER)); + } + } + fn ads_list(&mut self, ui: &mut egui::Ui, core: &mut Playout) { ui.horizontal(|ui| { if ui.button(tr("➕ Add advert")).clicked() { - core.cfg.ads.push(Ad::default()); + let ad = core.cfg.new_ad(); + core.cfg.ads.push(ad); } let users: Vec = core.cfg.ad_rules.iter().filter(|r| r.adverts && r.use_ad_list).map(|r| r.name.clone()).collect(); ui.label( @@ -426,6 +501,7 @@ impl App { ui.add_space(4.0); let now = Local::now(); let mut del = None; + let rules: Vec = core.cfg.ad_rules.iter().filter(|r| r.adverts && r.use_ad_list).cloned().collect(); egui::ScrollArea::vertical().id_salt("ads_list_scroll").auto_shrink([false, false]).show(ui, |ui| { for i in 0..core.cfg.ads.len() { let a: &mut Ad = &mut core.cfg.ads[i]; @@ -433,7 +509,8 @@ impl App { ui.set_width(ui.available_width()); ui.horizontal_wrapped(|ui| { ui.checkbox(&mut a.enabled, ""); - ui.add(egui::TextEdit::singleline(&mut a.name).desired_width(170.0).font(egui::TextStyle::Body)); + ui.add(egui::TextEdit::singleline(&mut a.advertiser).desired_width(150.0).hint_text(tr("advertiser / company"))); + ui.add(egui::TextEdit::singleline(&mut a.name).desired_width(170.0).hint_text(tr("advert name"))); let live = a.active(&now); ui.label(if !a.enabled { RichText::new(tr("off")).small().color(GRAY) @@ -460,6 +537,16 @@ impl App { ui.add_space(10.0); days_edit(ui, &mut a.days); }); + let by: Vec<&str> = rules.iter().filter(|r| r.plays_ad(a)).map(|r| r.name.as_str()).collect(); + ui.label( + RichText::new(if by.is_empty() { + tr("No rule plays this advert.").to_string() + } else { + format!("{} {}", tr("Played by:"), by.join(", ")) + }) + .small() + .color(GRAY), + ); }); } if core.cfg.ads.is_empty() { @@ -467,7 +554,7 @@ impl App { } }); if let Some(i) = del { - core.cfg.ads.remove(i); + core.cfg.remove_ad(i); } } } diff --git a/src/config.rs b/src/config.rs index 9ca716a..57c62db 100644 --- a/src/config.rs +++ b/src/config.rs @@ -328,6 +328,10 @@ pub struct AdRule { pub times: Vec<(u32, u32)>, /// adverts: take them from the Adverts list (each with its own dates and days) pub use_ad_list: bool, + /// with `use_ad_list`: play only the adverts in `ad_ids` instead of every advert in the list + pub ad_choose: bool, + /// adverts chosen for this rule (`Ad::id`), in Adverts list order when played in turn + pub ad_ids: Vec, pub folder: String, /// chosen files; when not empty these are used instead of the folder pub files: Vec, @@ -368,6 +372,8 @@ impl Default for AdRule { on_clock: true, times: vec![(12, 0)], use_ad_list: false, + ad_choose: false, + ad_ids: vec![], folder: String::new(), files: vec![], count: 1, @@ -417,6 +423,11 @@ impl AdRule { } } + /// Whether the rule may play advert `ad` from the Adverts list. + pub fn plays_ad(&self, ad: &Ad) -> bool { + self.adverts && self.use_ad_list && (!self.ad_choose || self.ad_ids.contains(&ad.id)) + } + /// Whether the rule interrupts the track on air (only set-time and every-N-minutes rules can). pub fn cuts_in(&self) -> bool { self.interrupt && self.trigger != Trigger::Songs @@ -464,7 +475,13 @@ impl AdRule { } let limits = if limits.is_empty() { String::new() } else { format!(" · {}", limits.join(" · ")) }; let cut = if self.cuts_in() { ", cutting in" } else { "" }; - format!("{pick} {when}{cut}{limits}") + let from = if self.adverts && self.use_ad_list && self.ad_choose { + let n = self.ad_ids.len(); + format!(" from {n} chosen advert{}", if n == 1 { "" } else { "s" }) + } else { + String::new() + }; + format!("{pick}{from} {when}{cut}{limits}") } } @@ -630,7 +647,11 @@ impl Default for Template { #[derive(Serialize, Deserialize, Clone)] #[serde(default)] pub struct Ad { + /// stays the same when adverts are added, removed or edited (rules choose adverts by it) + pub id: u64, pub name: String, + /// the company or client the advert is for + pub advertiser: String, pub enabled: bool, pub path: String, /// YYYY-MM-DD, empty = no limit @@ -641,7 +662,9 @@ pub struct Ad { impl Default for Ad { fn default() -> Self { Self { + id: 0, name: "New ad".into(), + advertiser: String::new(), enabled: true, path: String::new(), start_date: String::new(), @@ -651,6 +674,14 @@ impl Default for Ad { } } impl Ad { + /// "Advertiser — name", or just the name. + pub fn label(&self) -> String { + if self.advertiser.trim().is_empty() { + self.name.clone() + } else { + format!("{} — {}", self.advertiser.trim(), self.name) + } + } pub fn active(&self, now: &DateTime) -> bool { let today = now.format("%Y-%m-%d").to_string(); self.enabled @@ -851,9 +882,43 @@ impl Config { c.remote.port = 9258; } c.migrate_ad_rules(); + c.ensure_ad_ids(); c } + /// Give every advert a unique id (adverts saved before v0.2.11 have none). Returns whether + /// anything changed. + pub fn ensure_ad_ids(&mut self) -> bool { + let mut seen = std::collections::HashSet::new(); + let mut next = self.ads.iter().map(|a| a.id).max().unwrap_or(0) + 1; + let mut changed = false; + for a in &mut self.ads { + if a.id == 0 || !seen.insert(a.id) { + a.id = next; + seen.insert(next); + next += 1; + changed = true; + } + } + changed + } + + /// A new advert with an id not used yet. + pub fn new_ad(&self) -> Ad { + Ad { id: self.ads.iter().map(|a| a.id).max().unwrap_or(0) + 1, ..Default::default() } + } + + /// Remove advert `i` from the list and from the rules that chose it. + pub fn remove_ad(&mut self, i: usize) { + if i >= self.ads.len() { + return; + } + let id = self.ads.remove(i).id; + for r in &mut self.ad_rules { + r.ad_ids.retain(|x| *x != id); + } + } + /// v0.2.5: song-count rules and commercial breaks became one list of advert / jingle rules. pub fn migrate_ad_rules(&mut self) { if self.ad_rules_v >= 1 { @@ -973,4 +1038,18 @@ mod rule_tests { let t = AdRule { enabled: true, trigger: Trigger::Times, times: vec![(9, 0)], ..Default::default() }; assert!(t.due_now(&at(2026, 10, 20, 9, 0), None) && !t.due_now(&at(2026, 10, 20, 9, 1), None)); } + + #[test] + fn old_adverts_get_ids() { + let old = r#"{"ads":[{"name":"A","path":"a.mp3"},{"name":"B","path":"b.mp3"},{"id":2,"name":"C"}]}"#; + let mut c: Config = serde_json::from_str(old).unwrap(); + assert!(c.ensure_ad_ids()); + let ids: Vec = c.ads.iter().map(|a| a.id).collect(); + assert_eq!(ids, [3, 4, 2]); + assert!(!c.ensure_ad_ids()); + assert_eq!(c.new_ad().id, 5); + assert_eq!(c.ads[0].label(), "A"); + c.ads[0].advertiser = "Acme".into(); + assert_eq!(c.ads[0].label(), "Acme — A"); + } } diff --git a/src/playout.rs b/src/playout.rs index 29d0c30..6323858 100644 --- a/src/playout.rs +++ b/src/playout.rs @@ -1879,7 +1879,7 @@ impl Playout { self.cfg .ads .iter() - .filter(|a| a.active(&now) && !a.path.is_empty()) + .filter(|a| r.plays_ad(a) && a.active(&now) && !a.path.is_empty()) .map(|a| PathBuf::from(&a.path)) .filter(|p| p.exists()) .collect() @@ -3076,6 +3076,34 @@ mod tests { assert_eq!(&names[6..8], ["ad2.wav", "ad0.wav"]); } + #[test] + fn rules_play_only_the_adverts_chosen_from_the_list() { + use crate::config::{Ad, AdRule}; + let mut p = playout(); + let dir = std::env::temp_dir().join("rm_test_ad_list"); + let _ = std::fs::create_dir_all(&dir); + p.cfg.ads = (0..3) + .map(|i| { + let f = dir.join(format!("spot{i}.wav")); + crate::engine::write_wav(&f, &vec![0.0; 4410 * 2], 2, 44100).unwrap(); + Ad { id: i + 1, name: format!("Spot {i}"), path: f.to_string_lossy().to_string(), ..Default::default() } + }) + .collect(); + let mut r = AdRule { adverts: true, use_ad_list: true, ..Default::default() }; + let names = |p: &Playout, r: &AdRule| -> Vec { + p.rule_pool(r).iter().map(|f| f.file_name().unwrap().to_string_lossy().to_string()).collect() + }; + assert_eq!(names(&p, &r), ["spot0.wav", "spot1.wav", "spot2.wav"], "rules saved before stay on the whole list"); + r.ad_choose = true; + r.ad_ids = vec![3, 1]; + assert_eq!(names(&p, &r), ["spot0.wav", "spot2.wav"]); + p.cfg.ad_rules = vec![r.clone()]; + p.cfg.remove_ad(0); + assert_eq!(p.cfg.ad_rules[0].ad_ids, [3], "a deleted advert leaves the rules"); + r.ad_ids.clear(); + assert!(names(&p, &r).is_empty(), "nothing ticked plays nothing"); + } + #[test] fn interrupting_breaks_count_down_and_cut_in() { use crate::config::{AdRule, Trigger};