Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lang/template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand All @@ -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": "",
Expand Down Expand Up @@ -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.": "",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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": "",
Expand Down Expand Up @@ -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)": "",
Expand All @@ -886,6 +893,7 @@
"egui · rodio · symphonia · LAME": "",
"encoder": "",
"every": "",
"every advert": "",
"failed": "",
"files": "",
"files checked": "",
Expand Down Expand Up @@ -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)": "",
Expand All @@ -945,6 +954,7 @@
"on the clock": "",
"on the server": "",
"one file at random": "",
"only the adverts I tick": "",
"optional": "",
"optional folder": "",
"password": "",
Expand Down Expand Up @@ -985,6 +995,7 @@
"this template": "",
"this time of day": "",
"this web panel": "",
"ticked": "",
"to": "",
"tools": "",
"tracks": "",
Expand Down Expand Up @@ -1113,6 +1124,7 @@
"📋 Rules": "",
"📜 History": "",
"📝 Notepad…": "",
"📢 Open the Adverts list": "",
"📻 Overview": "",
"🔀 Reshuffle": "",
"🔌 Disconnect live DJ": "",
Expand Down
93 changes: 90 additions & 3 deletions src/adsui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Comment thread
epell-development marked this conversation as resolved.
Resolved
} 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<String> = core.cfg.ad_rules.iter().filter(|r| r.adverts && r.use_ad_list).map(|r| r.name.clone()).collect();
ui.label(
Expand All @@ -426,14 +501,16 @@ impl App {
ui.add_space(4.0);
let now = Local::now();
let mut del = None;
let rules: Vec<AdRule> = 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];
egui::Frame::group(ui.style()).show(ui, |ui| {
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)
Expand All @@ -460,14 +537,24 @@ 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() {
ui.label(RichText::new(tr("No adverts yet. Add one, pick its audio file and the dates it should run.")).color(GRAY));
}
});
if let Some(i) = del {
core.cfg.ads.remove(i);
core.cfg.remove_ad(i);
}
}
}
Expand Down
81 changes: 80 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u64>,
pub folder: String,
/// chosen files; when not empty these are used instead of the folder
pub files: Vec<String>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}")
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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(),
Expand All @@ -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<Local>) -> bool {
let today = now.format("%Y-%m-%d").to_string();
self.enabled
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u64> = 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");
}
}
Loading
Loading