diff --git a/lang/template.json b/lang/template.json
index 823a22f..968b509 100644
--- a/lang/template.json
+++ b/lang/template.json
@@ -3,6 +3,7 @@
" OK ": "",
" OK ": "",
"\"Fit each screen\" keeps the layout you see on a 1920×1080 screen, so moving the window to a laptop or a 1440p or 4K screen shows the same amount of everything. \"Same size in pixels\" ignores the display scaling set in Windows.": "",
+ "(all day)": "",
"(chosen files are used instead of the folder)": "",
"(deleted rule)": "",
"(not connected)": "",
@@ -10,6 +11,9 @@
"12-band": "",
"16 bands at most": "",
"A DovaFM Digital Entertainment Group brand. Free and open source under the MIT licence.": "",
+ "A copy of this event to change": "",
+ "A copy of this template to change": "",
+ "A different pattern for part of the day. The first one that covers the time is used; outside them the pattern above is.": "",
"A newer RadioMaster is on GitHub — click to see what's new and install it": "",
"A plugin controls RadioMaster by printing lines such as RM: VOLUME 60 or RM: PLAYLIST Evening.": "",
"AAC (ffmpeg)": "",
@@ -70,6 +74,7 @@
"Also appended to history.csv in the RadioMaster data folder.": "",
"Also the same song in different files": "",
"Another folder to look for moved files in (besides the library folders)": "",
+ "Another running order from the same template": "",
"Appearance / workzones": "",
"Append to playlist": "",
"Applies configuration changes; listeners are briefly disconnected": "",
@@ -80,6 +85,7 @@
"Artist separation": "",
"Assign audio file…": "",
"At": "",
+ "At the top of every hour": "",
"Attack": "",
"Audio FX": "",
"Audio devices": "",
@@ -144,6 +150,7 @@
"Connect to Remote Server…": "",
"Connect to Server…": "",
"Connect without saving": "",
+ "Connected to a server: Generate makes a new running order there.": "",
"Connection": "",
"Content type": "",
"Copies host + server type into the first encoder": "",
@@ -200,9 +207,11 @@
"Events": "",
"Every": "",
"Every 30 minutes on the clock = at :00 and :30. Off = 30 minutes after the last time.": "",
+ "Every 60 minutes on the clock = at the top of each hour. Off = counted from the start of the playlist.": "",
"Every change is listed in cleanup-log.csv in the data folder.": "",
"Every day": "",
"Every file is there and can be read.": "",
+ "Every hour on the hour, after the song that's playing": "",
"Every play (CSV / Excel)…": "",
"Every track overlaps the next by this much. Tracks with cues set in the track editor still use their own.": "",
"Everything": "",
@@ -273,12 +282,14 @@
"Input": "",
"Input (microphone / line in)": "",
"Insert": "",
+ "Insert a playlist item (time announcement, speech, stream…)": "",
"Insert after selected row": "",
"Insert as next": "",
"Insert file as next": "",
"Insert playlist": "",
"Insert tracks from playlist…": "",
"Insert voice track…": "",
+ "Inserts": "",
"Install now": "",
"Interface size": "",
"Intro sound": "",
@@ -287,7 +298,7 @@
"JINGLE": "",
"Jingles": "",
"Jingles & adverts (their rule's files)": "",
- "Jingles, adverts and other inserts by time (counted along the generated running order):": "",
+ "Jingles, adverts, talk or a time announcement, like the advert / jingle rules: after a number of songs, every so often, or at set times. Timed ones go in after the song that's playing when the time comes.": "",
"Keep": "",
"Keep the music at full level": "",
"Keep the playing track near the top as songs finish (the last one played stays in view)": "",
@@ -305,6 +316,7 @@
"Leave out": "",
"Leave these empty to keep the password. At least 12 characters.": "",
"Length": "",
+ "Length and separation": "",
"Level the track to a common loudness": "",
"Library": "",
"Library cleanup": "",
@@ -425,7 +437,7 @@
"Parametric": "",
"Password": "",
"Password (12+ characters for setup)": "",
- "Pattern (repeats until the length is reached):": "",
+ "Pattern": "",
"Pause": "",
"Pause the music, carry on after": "",
"Performance": "",
@@ -469,6 +481,7 @@
"Presenter notes are local text; they are not automatically broadcast.": "",
"Presets…": "",
"Press a key… (Esc cancels)": "",
+ "Preview": "",
"Preview (track editor)": "",
"Previous": "",
"Profile:": "",
@@ -507,11 +520,14 @@
"Remove the selected rows": "",
"Remove this MIDI control": "",
"Remove this key": "",
+ "Remove this step": "",
"Remove this time": "",
"Removed files:": "",
"Removing files": "",
"Render playlist to WAV…": "",
"Reorder, or drop on another playlist's tab to move them there": "",
+ "Repeat every": "",
+ "Repeats until the length is reached:": "",
"Report": "",
"Report (CSV / Excel)…": "",
"Reports": "",
@@ -556,6 +572,7 @@
"Seconds of each": "",
"Security": "",
"Security and web portal": "",
+ "See the running order first; nothing changes until you use it": "",
"Select a track in the playlist, then:": "",
"Select all": "",
"Select none": "",
@@ -571,6 +588,7 @@
"Set to the playhead / cursor position": "",
"Set volume (0-1)": "",
"Settings": "",
+ "Several genres: separate them with commas (Rock, Pop)": "",
"Shared by every song by this artist": "",
"Shortcuts and MIDI": "",
"Shortcuts and MIDI…": "",
@@ -594,6 +612,7 @@
"Song": "",
"Song Requests": "",
"Song requests come from AzuraCast. Set it up in Tools → AzuraCast → Connection.": "",
+ "Song separation": "",
"Songs": "",
"Sort by": "",
"Sound card buffer": "",
@@ -647,8 +666,9 @@
"The queue is empty.": "",
"The server has no sound cards.": "",
"The server was started with --no-audio, so it streams only until it is started without it.": "",
+ "The template's times of day and timed inserts follow the clock from where the new tracks start.": "",
"These belong to the server itself. Station settings (playlists, streams, adverts…) are in the usual places while you're connected.": "",
- "Things that happen at a set time: a new playlist from a template, adverts, a playlist going on air, encoders, volume…": "",
+ "Things that happen at a set time, or again and again: a time announcement or any other playlist item, a new playlist from a template, adverts, a playlist going on air, encoders, volume…": "",
"This copy wasn't installed with the setup program, so download the new zip (or the installer) from the page.": "",
"This is what the stream hears. Outputs marked Studio monitor are always muted while the mic is on.": "",
"This page needs an AzuraCast API key.": "",
@@ -656,7 +676,9 @@
"Threshold": "",
"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": "",
"Time until the next hour marker in the playlist": "",
+ "Times of day": "",
"Tip: create a Streamer/DJ account in AzuraCast, then use the “AzuraCast DJ preset” on the Streaming tab.": "",
"Title": "",
"Today": "",
@@ -666,6 +688,7 @@
"Track List Repeat Protection…": "",
"Track Title": "",
"Track editor": "",
+ "Tracks whose artist, title, genre or file name has one of these words are left out": "",
"Transitions": "",
"Translate RadioMaster: export the template, fill in the right-hand side of each line, save it as e.g. Deutsch.json in the lang folder, then pick it here. Share it on GitHub so everyone gets it.": "",
"Trim silence": "",
@@ -740,6 +763,9 @@
"a folder or chosen files": "",
"advert": "",
"adverts": "",
+ "after every": "",
+ "after the playlist (instead of replacing it)": "",
+ "after the track on air": "",
"all day": "",
"all files in order": "",
"all of them": "",
@@ -779,7 +805,6 @@
"https://radio.example.com/live.mp3": "",
"id:secret (for skip/upload)": "",
"identical files": "",
- "insert": "",
"is lowered (set how far with the slider next to the Mic volume)": "",
"is muted (it keeps playing silently)": "",
"is paused, and carries on when the mic goes off": "",
@@ -791,6 +816,7 @@
"keeping": "",
"keeps playing at full level": "",
"last:": "",
+ "leave out": "",
"length": "",
"library": "",
"lower ↔ higher": "",
@@ -806,6 +832,7 @@
"none": "",
"not found (only needed for formats other than MP3)": "",
"not on air": "",
+ "now (the track on air moves on)": "",
"of each track (0 = off)": "",
"off": "",
"on air": "",
@@ -817,6 +844,7 @@
"password (12+ characters)": "",
"pause": "",
"pick a rule": "",
+ "pick an item": "",
"playlist row(s)": "",
"playlist rows removed": "",
"plays": "",
@@ -834,15 +862,19 @@
"target": "",
"text in the title": "",
"the Adverts list — each advert only in its own dates and days": "",
+ "the next time the clock reaches it": "",
"the same artist played in the last": "",
+ "the same song doesn't come back sooner": "",
"the same track played in the last": "",
"this advert": "",
"this band": "",
"this category": "",
"this event": "",
+ "this insert": "",
"this rule": "",
"this shortcut": "",
"this template": "",
+ "this time of day": "",
"this web panel": "",
"to": "",
"tools": "",
@@ -858,6 +890,8 @@
"voices on this computer": "",
"wait (silence) until the time": "",
"when under": "",
+ "words, e.g. live, remix": "",
+ "years": "",
"yes": "",
"{}": "",
"…or choose files": "",
@@ -908,6 +942,7 @@
"✏ Track editor…": "",
"✔ = cues set by hand": "",
"✔ Do it": "",
+ "✔ Put it in the playlist": "",
"✔ RadioMaster is up to date.": "",
"✖ Close": "",
"✖ not found — needed for AAC, Opus, Vorbis and FLAC streams": "",
@@ -924,11 +959,15 @@
"➕ Add pattern step": "",
"➕ Add streamer": "",
"➕ Add time": "",
+ "➕ Add time announcement": "",
+ "➕ Add time of day": "",
"➕ Advert rule": "",
"➕ Folder…": "",
+ "➕ Hourly time announcement": "",
"➕ Jingle rule": "",
"➕ New": "",
"➕ Playlist": "",
+ "⟳ Again": "",
"⟳ Refresh": "",
"⟳ Refresh devices": "",
"⟳ Reload plugins": "",
@@ -944,6 +983,7 @@
"🎤 Streamers/DJs": "",
"🎲 Playlist Generator Pro…": "",
"🏷 Genres": "",
+ "👁 Preview": "",
"👥 Listeners": "",
"👯 Duplicates": "",
"💾 Save as M3U…": "",
diff --git a/site/guides/event-scheduler/index.html b/site/guides/event-scheduler/index.html
index 1f2ae33..c79416b 100644
--- a/site/guides/event-scheduler/index.html
+++ b/site/guides/event-scheduler/index.html
@@ -113,8 +113,8 @@
RadioMaster / Guides / Event scheduler
Guide · Run the station
How to automate with the event scheduler
- The event scheduler does things at set times so you don't have to be there: make a fresh playlist every morning, put Friday night's show on air at 19:00, start the second stream for a sports match, or run any station command.
- Add an event Actions Station commands Recipes Events in the playlist
+ The event scheduler does things at set times so you don't have to be there: make a fresh playlist every morning, put Friday night's show on air at 19:00, read the time every hour, start the second stream for a sports match, or run any station command.
+ Add an event Repeat Actions Insert a playlist item Station commands Recipes Events in the playlist
@@ -128,10 +128,24 @@ Add an event
Choose what to Do (the actions are below) and fill in what it needs, such as a template, a playlist tab or a rule.
Press ▶ Run now to try it straight away.
- Each event has its own on/off tick, so you can park one without deleting it.
+ Each event has its own on/off tick, so you can park one without deleting it, and Duplicate makes a copy to change.
+
+ Repeat an event
+ Tick Repeat every to run an event again and again: choose minutes or hours and the time it stops. The event's time becomes the first run. For example, from 06:00, repeat every 30 minutes until 22:00 runs at 06:00, 06:30, 07:00 … 22:00. A stop time earlier than the start runs over midnight (22:00 until 02:00).
+ ➕ Hourly time announcement adds a ready-made event that reads the time at the top of every hour, every day.
Actions
- Action What it does Generate a new playlist from a template Replaces the playlist with a fresh one from a generator template Add tracks from a template after the playlist Appends a template's tracks to the end Play adverts / jingles from a rule Queues an advert or jingle rule after the song on air Put a playlist tab on air Switches to another playlist tab, for example a show you prepared earlier Play the on-air playlist again from the top Restarts the on-air playlist, which keeps a playlist looping Load playlist (M3U) and play Opens an M3U file and plays it Play file now Plays one file straight away Insert file as next Puts a file after the song on air Stop playback Stops the station Start encoder # / Stop encoder # Starts or stops one of your streams . Here the first encoder is #0 Set volume (0-1) Sets the master volume, 1 being full Run a station command Any command below
+ Action What it does Insert a playlist item Puts a time announcement, text to speech or another item from the Playlist menu after the song on air, or plays it now. More below Generate a new playlist from a template Replaces the playlist with a fresh one from a generator template Add tracks from a template after the playlist Appends a template's tracks to the end Play adverts / jingles from a rule Queues an advert or jingle rule after the song on air Put a playlist tab on air Switches to another playlist tab, for example a show you prepared earlier Play the on-air playlist again from the top Restarts the on-air playlist, which keeps a playlist looping Load playlist (M3U) and play Opens an M3U file and plays it Play file now Plays one file straight away Insert file as next Puts a file after the song on air Stop playback Stops the station Start encoder # / Stop encoder # Starts or stops one of your streams , counting from 1 like the Streams tab Set volume (0-1) Sets the master volume, 1 being full Run a station command Any command below
+
+ Insert a playlist item
+ Pick Insert a playlist item , then choose the item from the list next to it. These are the same items as in the Playlist menu, set up in the same dialogs:
+
+ Time announcement : reads the current time
+ Text to speech : any text in a computer voice, with {time}, {next} and the other fill-ins, for example "You're listening to Radio X, it's {time}"
+ Track List : one random track from a folder, with repeat protection, which suits station IDs
+ Container , URL (an internet stream), Line Input , Teaser , Timed pause and Command
+
+ Use ✏ Edit… to change it later. Choose whether it plays after the track on air (the song isn't cut) or now . If the last one is still waiting to play, for example while playback is stopped, another copy isn't added.
Station commands
The same commands work in an event, in a playlist Command row (Alt+C ) and from a plugin .
@@ -144,8 +158,10 @@ Recipes
A sponsored news intro : 07:59, weekdays, Play adverts / jingles from a rule → News sponsor .
A second stream for a match : Saturday 14:45, Run a station command → ENCODER START 2, and 17:15, ENCODER STOP 2.
Quieter overnight : 00:00, Set volume 0.8; 06:00, Set volume 1.
+ The time every hour : ➕ Hourly time announcement, or from 00:00 repeat every 1 hour, Insert a playlist item → Time announcement.
+ A spoken station ID every half hour in the daytime : from 06:30, repeat every 30 minutes until 22:00, Insert a playlist item → Text to speech.
- Something every hour? An event runs at one time of day. For a station ID or a top-of-the-hour jingle every hour, make a jingle rule that plays every 1 hour on the clock in the advertisement scheduler , or an insert in your generator template .
+ Jingles on the clock? For jingles and adverts with play counts and reports, use a rule that plays every 1 hour on the clock in the advertisement scheduler , or an insert in your generator template .
Events in the playlist
Playlist → Add event puts an Event row in the running order. When playback reaches it, it runs a scheduler action, for example ending a show's playlist with "Put a playlist tab on air → Main" so Main carries on where it left off instead of dead air.
diff --git a/site/guides/playlist-generator/index.html b/site/guides/playlist-generator/index.html
index 05c277b..d0d6a2a 100644
--- a/site/guides/playlist-generator/index.html
+++ b/site/guides/playlist-generator/index.html
@@ -113,8 +113,8 @@
RadioMaster / Guides / Playlist generator
Guide · Run the station
How to use the playlist generator
- The playlist generator builds hours of programming from your music library in one click. You tell it what pools of songs you have (categories) and how an hour should sound (a template), and it fills the playlist, keeps artists apart and drops in jingles and hour markers for you.
- Before you start Categories Templates Generate Auto-playlist On a schedule Tips
+ The playlist generator builds hours of programming from your music library in one click. You tell it what pools of songs you have (categories) and how an hour should sound (a template), and it fills the playlist, keeps artists and songs apart, changes the sound for different times of day and drops in jingles, talk and time announcements on the clock.
+ Before you start Categories Templates Times of day Inserts Preview and generate Auto-playlist On a schedule Tips
@@ -124,8 +124,8 @@ Before you start
Can't open it? The playlist generator is a tool an administrator can allow or block per user. See users and permissions .
1. Make your categories
- A category is a pool of tracks the generator can pick from. Each one has a name and up to three filters; leave a filter empty to mean "any".
- Filter What it matches folder Tracks whose file is inside this folder (or any folder in it). Use … to pick it. genre Tracks whose genre tag contains this text, ignoring capitals. rock matches Rock, Soft Rock and Rock & Roll. length Shortest and longest track in seconds. A maximum of 0 means no limit. Handy for keeping long album versions or short IDs out.
+ A category is a pool of tracks the generator can pick from. Each one has a name and some filters; leave a filter empty to mean "any".
+ Filter What it matches folder Tracks whose file is inside this folder (or any folder in it). Use … to pick it. genre Tracks whose genre tag contains this text, ignoring capitals. rock matches Rock, Soft Rock and Rock & Roll. Separate several with commas: rock, pop takes either. length Shortest and longest track in seconds. A maximum of 0 means no limit. Handy for keeping long album versions or short IDs out. years The year tag, from and to (any = no limit). 1980 – 1989 makes an 80s category. leave out Words, separated by commas, that keep a track out when its artist, title, genre or file name has one of them, for example live, remix, instrumental.
A typical music station might have:
Category Filters Current folder D:\Music\Current Recurrent folder D:\Music\Recurrent Gold folder D:\Music\Gold, length 120–420 s Station IDs folder D:\Jingles\IDs, length 0–30 s
Press ➕ Add category for each one. The delete button asks Delete! / Keep first, so a stray click won't lose anything.
@@ -134,20 +134,29 @@ 2. Write a template
A template describes how the programming should sound. Press ➕ New under Templates, then set:
Name : what you'll pick it by, for example Daytime or Overnight .
- Length : how much to make, from 5 minutes to 24 hours (1440 minutes).
+ Length : how much to make, from 5 minutes to 24 hours (1440 minutes), or until a time of day, such as until 22:00, the next time the clock gets there.
Artist separation : how many tracks must pass before the same artist can play again. 8 to 12 suits most music stations.
+ Song separation : how many minutes before the same song can come back. Off means a song can return once its category has been played through.
The pattern
The pattern is the running order the generator repeats until the length is reached. Each step is a count and a source: a category, or the files of one of your advert or jingle rules . Press ➕ Add pattern step for each step. For example:
Step Count From 1. 2 × Current 2. 1 × Gold 3. 1 × Recurrent 4. 1 × Station IDs
That gives Current, Current, Gold, Recurrent, ID, then round again.
- Inserts by time
- Below the pattern you can add things that go in by time instead of by position, such as "every 15 min insert 1 × Station IDs". Press ➕ Add insert , then set the minutes, the count and the source. ➕ Add jingles is a shortcut that adds your first jingle rule every 15 minutes.
- Times are counted along the generated running order using each track's length, so an insert every 30 minutes lands roughly every half hour of airtime wherever the playlist starts.
+ 3. Times of day
+ A station rarely sounds the same all day. Under Times of day , press ➕ Add time of day to give some hours their own pattern: pick the hours (from 06:00 to 10:00, for example), the days, and the steps. The first time of day that covers the clock is used, and outside them the main pattern is. 22:00 to 06:00 runs over midnight.
+ Time of day Hours and days Pattern Breakfast 06:00–10:00, Mon–Fri 1 × Talk, 2 × Current Overnight 00:00–06:00, every day 3 × Gold, 1 × Station IDs
+ The clock follows the new tracks from where they will start on air, so a template that runs from 05:00 switches to the breakfast pattern at about 06:00.
+
+ 4. Inserts
+ Inserts are things that go in on their own schedule instead of by position in the pattern, set up like advert and jingle rules . Press ➕ Add insert , pick what to insert and how many, then when:
+ When Example after every N songs 1 × Station IDs after every 4 songs every N minutes A jingle every 15 minutes. Tick on the clock to count from the top of the hour, so every 60 minutes means at :00. at set times 1 × Talk at 18:00 and 20:30
+ Each insert can be limited to some hours (untick all day ). An insert can come from a category, from the files of an advert or jingle rule, or be a ⏰ Time announcement : ➕ Add time announcement adds one at the top of every hour. ➕ Add jingles is a shortcut that adds your first jingle rule every 15 minutes.
+ Timed inserts go in after the song that's playing when the time comes, so songs are never cut.
Hour markers
Tick Hour marker at the top of every hour and the generator places an hour marker where each new hour starts, so the station stays on the clock even if the songs run long or short.
- 3. Generate
+ 5. Preview and generate
+ 👁 Preview shows the running order with the time each row starts, the number of tracks and the total length, without touching the playlist. Press ⟳ Again for another mix, tick after the playlist to see it as an addition, and ✔ Put it in the playlist when you like it. Or go straight to:
▶ Generate (replace playlist) clears the playlist and fills it with the template.
Append to playlist adds the new tracks after the last row, keeping what's already there.
@@ -167,7 +176,7 @@ Tips
Within one generated playlist, every track in a category plays once before any of them repeats, so big categories stay fresh.
If artist separation can't be met (a small category with few artists), the generator still picks a track rather than leaving a gap.
Timed inserts (jingles, IDs, adverts) don't count towards artist separation.
- Make a template per daypart (Breakfast, Daytime, Evening, Overnight) and switch between them with the event scheduler.
+ Times of day let one template cover a whole day. For very different shows you can still make a template each and switch between them with the event scheduler.
While connected to RadioMaster Server , the library and folders are the server's, so a category folder is typed the way the server sees it, for example /srv/music/current.
diff --git a/src/app.rs b/src/app.rs
index d7a4794..a287aa4 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1,6 +1,6 @@
use crate::azuracast::Client;
use crate::browser::Browsers;
-use crate::config::{data_dir, CartPad, Category, Marker, PatternItem, ServerKind, Template};
+use crate::config::{data_dir, CartPad, ServerKind};
use crate::i18n::tr;
use crate::items::PlEntry;
use crate::library::Track;
@@ -151,6 +151,8 @@ mod cleanupui;
mod controlsui;
#[path = "equi.rs"]
mod equi;
+#[path = "genui.rs"]
+mod genui;
#[path = "panes.rs"]
mod panes;
#[path = "pltools.rs"]
@@ -232,6 +234,7 @@ pub struct App {
/// playback was paused by the mic (Pause mode) and resumes when it goes off
mic_paused: bool,
tpl_sel: usize,
+ gen: genui::GenUi,
rep: reportui::RepUi,
/// where ffmpeg was found (None = look again)
ffmpeg_found: Option>,
@@ -399,6 +402,8 @@ pub(super) struct ActionOpts {
pub rules: Vec<(u64, String)>,
pub tabs: Vec,
pub n_enc: usize,
+ /// offer "Insert a playlist item" (scheduler events; playlist event rows can't hold one)
+ pub items: bool,
}
impl ActionOpts {
@@ -408,6 +413,7 @@ impl ActionOpts {
rules: core.cfg.ad_rules.iter().map(|r| (r.id, r.name.clone())).collect(),
tabs: core.tabs.iter().map(|t| t.name.clone()).collect(),
n_enc: core.cfg.encoders.len(),
+ items: false,
}
}
}
@@ -425,7 +431,9 @@ pub(super) fn action_edit(
let mut browse = false;
egui::ComboBox::from_id_salt(("act", id)).selected_text(tr(action.label())).width(290.0).height(400.0).show_ui(ui, |ui| {
for a in ActionKind::ALL {
- ui.selectable_value(action, a, tr(a.label()));
+ if a != ActionKind::InsertItem || o.items {
+ ui.selectable_value(action, a, tr(a.label()));
+ }
}
});
if action.needs_template() {
@@ -474,10 +482,12 @@ pub(super) fn action_file_dialog(action: ActionKind) -> rfd::FileDialog {
}
/// Where generator tracks come from: a category, or the files of an advert / jingle rule
-/// (stored as "rule:"). `rules` = (id, name, adverts).
-fn source_combo(ui: &mut egui::Ui, id: impl std::hash::Hash, cur: &mut String, cats: &[String], rules: &[(u64, String, bool)]) {
+/// (stored as "rule:"). `rules` = (id, name, adverts). `time` also offers a time announcement
+/// (for inserts).
+fn source_combo(ui: &mut egui::Ui, id: impl std::hash::Hash, cur: &mut String, cats: &[String], rules: &[(u64, String, bool)], time: bool) {
let shown = match cur.strip_prefix("rule:").and_then(|s| s.parse::().ok()) {
Some(rid) => rules.iter().find(|r| r.0 == rid).map(|r| format!("⚡ {}", r.1)).unwrap_or_else(|| tr("(deleted rule)").to_string()),
+ None if cur == crate::generator::TIME_SOURCE => format!("⏰ {}", tr("Time announcement")),
None => cur.clone(),
};
egui::ComboBox::from_id_salt(id).selected_text(shown).width(170.0).show_ui(ui, |ui| {
@@ -493,6 +503,10 @@ fn source_combo(ui: &mut egui::Ui, id: impl std::hash::Hash, cur: &mut String, c
ui.selectable_value(cur, format!("rule:{rid}"), format!("⚡ {name} ({tag})"));
}
}
+ if time {
+ ui.separator();
+ ui.selectable_value(cur, crate::generator::TIME_SOURCE.to_string(), format!("⏰ {}", tr("Time announcement")));
+ }
});
}
@@ -574,6 +588,7 @@ impl App {
mic_on: false,
mic_paused: false,
tpl_sel: 0,
+ gen: Default::default(),
rep: Default::default(),
ffmpeg_found: None,
ads: Default::default(),
@@ -2400,13 +2415,25 @@ impl App {
}
fn tab_scheduler(&mut self, ui: &mut egui::Ui, core: &mut Playout) {
- ui.horizontal(|ui| {
+ ui.horizontal_wrapped(|ui| {
if ui.button(tr("➕ Add event")).clicked() {
core.cfg.events.push(Event::default());
}
+ if ui.button(tr("➕ Hourly time announcement")).on_hover_text(tr("Every hour on the hour, after the song that's playing")).clicked() {
+ core.cfg.events.push(Event {
+ name: tr("Time announcement").to_string(),
+ hour: 0,
+ minute: 0,
+ repeat_mins: 60,
+ until: (23, 59),
+ action: ActionKind::InsertItem,
+ item: Some(crate::items::PlEntry::special(crate::items::ItemKind::TimeAnnounce, "", 4.0)),
+ ..Default::default()
+ });
+ }
ui.label(
RichText::new(tr(
- "Things that happen at a set time: a new playlist from a template, adverts, a playlist going on air, encoders, volume…",
+ "Things that happen at a set time, or again and again: a time announcement or any other playlist item, a new playlist from a template, adverts, a playlist going on air, encoders, volume…",
))
.small()
.color(GRAY),
@@ -2415,7 +2442,10 @@ impl App {
ui.add_space(4.0);
let mut del = None;
let mut run_now = None;
- let opts = ActionOpts::of(core);
+ let mut dup = None;
+ let mut item_edit: Option<(usize, Option)> = None;
+ let mut opts = ActionOpts::of(core);
+ opts.items = true;
let now = core.now();
egui::ScrollArea::vertical().show(ui, |ui| {
if core.cfg.events.is_empty() {
@@ -2428,27 +2458,56 @@ impl App {
ui.horizontal_wrapped(|ui| {
ui.checkbox(&mut e.enabled, "");
ui.add(egui::TextEdit::singleline(&mut e.name).desired_width(170.0));
- ui.label(tr("at"));
+ ui.label(if e.repeat_mins == 0 { tr("at") } else { tr("from") });
ui.add(egui::DragValue::new(&mut e.hour).range(0..=23).custom_formatter(|v, _| format!("{:02}", v as u32)));
ui.label(":");
ui.add(egui::DragValue::new(&mut e.minute).range(0..=59).custom_formatter(|v, _| format!("{:02}", v as u32)));
if ui.button(tr("▶ Run now")).on_hover_text(tr("Do it now, as a test")).clicked() {
run_now = Some(i);
}
+ if ui.button(tr("Duplicate")).on_hover_text(tr("A copy of this event to change")).clicked() {
+ dup = Some(i);
+ }
if delete_button(ui, ("event", i), tr("this event")) {
del = Some(i);
}
});
+ ui.horizontal_wrapped(|ui| {
+ let mut repeat = e.repeat_mins > 0;
+ if ui.checkbox(&mut repeat, tr("Repeat every")).changed() {
+ e.repeat_mins = if repeat { 60 } else { 0 };
+ }
+ if repeat {
+ let hours = e.repeat_mins.is_multiple_of(60);
+ if hours {
+ let mut h = (e.repeat_mins / 60).max(1);
+ if ui.add(egui::DragValue::new(&mut h).range(1..=23)).changed() {
+ e.repeat_mins = h * 60;
+ }
+ } else {
+ ui.add(egui::DragValue::new(&mut e.repeat_mins).range(1..=1439));
+ }
+ let mut unit_hours = hours;
+ if ui.selectable_value(&mut unit_hours, false, tr("minutes")).clicked() && hours {
+ e.repeat_mins = 30;
+ }
+ if ui.selectable_value(&mut unit_hours, true, tr("hours")).clicked() && !hours {
+ e.repeat_mins = 60;
+ }
+ ui.label(tr("until"));
+ ui.add(egui::DragValue::new(&mut e.until.0).range(0..=23).custom_formatter(|v, _| format!("{:02}", v as u32)));
+ ui.label(":");
+ ui.add(egui::DragValue::new(&mut e.until.1).range(0..=59).custom_formatter(|v, _| format!("{:02}", v as u32)));
+ ui.label(RichText::new(e.when_text()).small().color(GRAY));
+ }
+ });
adsui::days_edit(ui, &mut e.days);
ui.horizontal_wrapped(|ui| {
ui.label(tr("Dates"));
date_edit(ui, ("ev_from", i), &mut e.from_date, true);
ui.label(tr("to"));
date_edit(ui, ("ev_to", i), &mut e.to_date, true);
- let mut probe = e.clone();
- probe.hour = now.hour();
- probe.minute = now.minute();
- if e.enabled && !probe.due(&now) {
+ if e.enabled && !e.on_day(&now) {
ui.label(RichText::new(tr("○ not today")).small().color(AMBER));
}
});
@@ -2458,14 +2517,50 @@ impl App {
let a = e.action;
self.dlg(move || action_file_dialog(a).pick_file().map(|p| Dlg::SchedPath(i, p)));
}
+ if e.action == ActionKind::InsertItem {
+ let shown = match &e.item {
+ Some(it) if it.title().contains(tr(it.type_label())) => it.title(),
+ Some(it) => format!("{} · {}", tr(it.type_label()), it.title()),
+ None => tr("pick an item").to_string(),
+ };
+ egui::ComboBox::from_id_salt(("ev_item", i)).selected_text(shown).width(240.0).height(400.0).show_ui(
+ ui,
+ |ui| {
+ for (k, label) in pltools::EVENT_ITEMS {
+ if ui.selectable_label(false, tr(label)).clicked() {
+ item_edit = Some((i, Some(k)));
+ }
+ }
+ },
+ );
+ if e.item.as_ref().is_some_and(|it| !matches!(it.kind, crate::items::ItemKind::TimeAnnounce))
+ && ui.button(tr("✏ Edit…")).clicked()
+ {
+ item_edit = Some((i, None));
+ }
+ }
});
+ if e.action == ActionKind::InsertItem {
+ ui.horizontal_wrapped(|ui| {
+ ui.radio_value(&mut e.play_now, false, tr("after the track on air"));
+ ui.radio_value(&mut e.play_now, true, tr("now (the track on air moves on)"));
+ });
+ }
});
}
});
+ if let Some((i, kind)) = item_edit {
+ self.edit_event_item(core, i, kind);
+ }
if let Some(i) = run_now {
let e = core.cfg.events[i].clone();
core.run_event(&e);
}
+ if let Some(i) = dup {
+ let mut e = core.cfg.events[i].clone();
+ e.name = format!("{} (copy)", e.name);
+ core.cfg.events.push(e);
+ }
if let Some(i) = del {
core.cfg.events.remove(i);
}
@@ -2988,151 +3083,6 @@ impl App {
});
}
- fn tab_generator(&mut self, ui: &mut egui::Ui, core: &mut Playout) {
- let cat_names: Vec = core.cfg.categories.iter().map(|c| c.name.clone()).collect();
- let tpl_names: Vec = core.cfg.templates.iter().map(|t| t.name.clone()).collect();
- let rules: Vec<(u64, String, bool)> = core.cfg.ad_rules.iter().map(|r| (r.id, r.name.clone(), r.adverts)).collect();
- let first_jingle = rules.iter().find(|r| !r.2).map(|r| format!("rule:{}", r.0));
- let mut gen: Option<(String, bool)> = None;
- egui::ScrollArea::vertical().show(ui, |ui| {
- ui.strong(tr("Auto-playlist"));
- ui.horizontal(|ui| {
- ui.checkbox(&mut core.cfg.auto_enabled, tr("Keep the playlist filled automatically"));
- ui.label(tr("using"));
- name_combo(ui, "autotpl", &mut core.cfg.auto_template, &tpl_names);
- ui.label(tr("when under"));
- ui.add(egui::DragValue::new(&mut core.cfg.auto_min_mins).range(1..=600).suffix(" min left"));
- });
- ui.separator();
- ui.strong(tr("Categories (what each pool of tracks is made from)"));
- let mut del = None;
- for i in 0..core.cfg.categories.len() {
- let c = &mut core.cfg.categories[i];
- ui.horizontal(|ui| {
- ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(100.0));
- ui.label(tr("folder"));
- ui.add(egui::TextEdit::singleline(&mut c.folder).desired_width(260.0).hint_text(tr("any")));
- if ui.button("…").clicked() {
- self.dlg(move || rfd::FileDialog::new().pick_folder().map(|p| Dlg::CatFolder(i, p)));
- }
- ui.label(tr("genre"));
- ui.add(egui::TextEdit::singleline(&mut c.genre).desired_width(90.0).hint_text(tr("any")));
- ui.label(tr("length"));
- ui.add(egui::DragValue::new(&mut c.min_secs).range(0.0..=3600.0).suffix("s"));
- ui.label("–");
- ui.add(egui::DragValue::new(&mut c.max_secs).range(0.0..=7200.0).suffix("s"));
- if delete_button(ui, ("category", i), tr("this category")) {
- del = Some(i);
- }
- });
- }
- if let Some(i) = del {
- core.cfg.categories.remove(i);
- }
- if ui.button(tr("➕ Add category")).clicked() {
- core.cfg.categories.push(Category { name: format!("Category {}", core.cfg.categories.len() + 1), ..Default::default() });
- }
- ui.separator();
- ui.strong(tr("Templates"));
- ui.horizontal(|ui| {
- for (i, n) in tpl_names.iter().enumerate() {
- ui.selectable_value(&mut self.tpl_sel, i, n);
- }
- if ui.button(tr("➕ New")).clicked() {
- core.cfg.templates.push(Template { name: format!("Template {}", core.cfg.templates.len() + 1), ..Default::default() });
- self.tpl_sel = core.cfg.templates.len() - 1;
- }
- });
- if self.tpl_sel >= core.cfg.templates.len() {
- self.tpl_sel = core.cfg.templates.len().saturating_sub(1);
- }
- let mut del_tpl = false;
- if let Some(t) = core.cfg.templates.get_mut(self.tpl_sel) {
- ui.horizontal(|ui| {
- ui.label(tr("Name"));
- ui.add(egui::TextEdit::singleline(&mut t.name).desired_width(140.0));
- ui.label(tr("Length"));
- ui.add(egui::DragValue::new(&mut t.minutes).range(5..=1440).suffix(" min"));
- ui.label(tr("Artist separation"));
- ui.add(egui::DragValue::new(&mut t.artist_gap).range(0..=50).suffix(" tracks"));
- });
- ui.label(RichText::new(tr("Pattern (repeats until the length is reached):")).color(GRAY));
- let mut pdel = None;
- for (k, p) in t.pattern.iter_mut().enumerate() {
- ui.horizontal(|ui| {
- ui.label(format!("{}.", k + 1));
- ui.add(egui::DragValue::new(&mut p.count).range(1..=20).suffix(" ×"));
- source_combo(ui, ("pat", k), &mut p.category, &cat_names, &rules);
- if ui.small_button("✖").clicked() {
- pdel = Some(k);
- }
- });
- }
- if let Some(k) = pdel {
- t.pattern.remove(k);
- }
- if ui.button(tr("➕ Add pattern step")).clicked() {
- t.pattern.push(PatternItem::default());
- }
- ui.label(
- RichText::new(tr("Jingles, adverts and other inserts by time (counted along the generated running order):"))
- .color(GRAY),
- );
- let mut mdel = None;
- for (k, m) in t.markers.iter_mut().enumerate() {
- ui.horizontal(|ui| {
- ui.label(tr("every"));
- ui.add(egui::DragValue::new(&mut m.every_mins).range(1..=600).suffix(" min"));
- ui.label(tr("insert"));
- ui.add(egui::DragValue::new(&mut m.count).range(1..=10).suffix(" ×"));
- ui.label(tr("from"));
- source_combo(ui, ("mk", k), &mut m.category, &cat_names, &rules);
- if ui.small_button("✖").clicked() {
- mdel = Some(k);
- }
- });
- }
- if let Some(k) = mdel {
- t.markers.remove(k);
- }
- ui.horizontal(|ui| {
- if ui
- .add_enabled(first_jingle.is_some(), egui::Button::new(tr("➕ Add jingles")))
- .on_hover_text(tr("Uses the files of a jingle rule (Advertisement scheduler) every 15 minutes"))
- .on_disabled_hover_text(tr("Make a jingle rule in the Advertisement scheduler first, or add a Jingles category"))
- .clicked()
- {
- t.markers.push(Marker { category: first_jingle.clone().unwrap_or_default(), every_mins: 15, count: 1 });
- }
- if ui.button(tr("➕ Add insert")).clicked() {
- t.markers.push(Marker::default());
- }
- ui.checkbox(&mut t.hour_markers, tr("Hour marker at the top of every hour"));
- });
- ui.horizontal(|ui| {
- if ui.button(RichText::new(tr("▶ Generate (replace playlist)")).color(GREEN)).clicked() {
- gen = Some((t.name.clone(), false));
- }
- if ui.button(tr("Append to playlist")).clicked() {
- gen = Some((t.name.clone(), true));
- }
- if delete_button(ui, ("template", self.tpl_sel), tr("this template")) {
- del_tpl = true;
- }
- });
- }
- if del_tpl {
- core.cfg.templates.remove(self.tpl_sel);
- }
- });
- if let Some((n, append)) = gen {
- let c = core.generate_playlist(&n, append);
- if c > 0 {
- core.say(format!("Generated {c} tracks from '{n}'"));
- }
- }
- }
-
fn tab_plugins(&mut self, ui: &mut egui::Ui, core: &mut Playout) {
ui.label(tr("Plugins are small programs, in any language, that RadioMaster runs when something happens on air — a song starts, the hour changes, an encoder drops. They can also send station commands back."));
ui.horizontal_wrapped(|ui| {
diff --git a/src/config.rs b/src/config.rs
index 70aed6c..f030be6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -376,14 +376,7 @@ impl AdRule {
pub fn in_force(&self, now: &DateTime) -> bool {
use chrono::Timelike;
let today = now.format("%Y-%m-%d").to_string();
- let h = now.hour();
- let hours_ok = if self.from_hour == self.to_hour {
- true
- } else if self.from_hour < self.to_hour {
- h >= self.from_hour && h < self.to_hour
- } else {
- h >= self.from_hour || h < self.to_hour
- };
+ let hours_ok = hours_ok(self.from_hour, self.to_hour, now.hour());
self.enabled
&& (self.from_date.trim().is_empty() || today.as_str() >= self.from_date.trim())
&& (self.to_date.trim().is_empty() || today.as_str() <= self.to_date.trim())
@@ -461,14 +454,40 @@ impl AdRule {
pub struct Category {
pub name: String,
pub folder: String,
+ /// one genre, or several separated by commas ("Rock, Pop"): a track needs one of them
pub genre: String,
pub min_secs: f32,
/// 0 = no maximum
pub max_secs: f32,
+ /// release years (0 = no limit)
+ pub min_year: u32,
+ pub max_year: u32,
+ /// leave out tracks whose artist, title, genre or path has one of these words (comma separated)
+ pub exclude: String,
}
impl Default for Category {
fn default() -> Self {
- Self { name: "Music".into(), folder: String::new(), genre: String::new(), min_secs: 0.0, max_secs: 0.0 }
+ Self {
+ name: "Music".into(),
+ folder: String::new(),
+ genre: String::new(),
+ min_secs: 0.0,
+ max_secs: 0.0,
+ min_year: 0,
+ max_year: 0,
+ exclude: String::new(),
+ }
+ }
+}
+
+/// `h` is between the hours `from` and `to` (from == to: all day; 22 → 6 runs over midnight).
+pub fn hours_ok(from: u32, to: u32, h: u32) -> bool {
+ if from == to {
+ true
+ } else if from < to {
+ h >= from && h < to
+ } else {
+ h >= from || h < to
}
}
@@ -487,26 +506,88 @@ impl Default for PatternItem {
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Marker {
- /// a category name, or "rule:" for the files of an advert / jingle rule
+ /// a category name, "rule:" for the files of an advert / jingle rule, or
+ /// `generator::TIME_SOURCE` for a time announcement
pub category: String,
+ /// after every N songs, every N minutes, or at set times (like an advert / jingle rule)
+ pub trigger: Trigger,
+ pub every_songs: u32,
pub every_mins: u32,
+ /// every N minutes counted from the top of the hour (:00, :15…) instead of from the start
+ pub on_clock: bool,
+ /// (hour, minute) for `Trigger::Times`
+ pub times: Vec<(u32, u32)>,
/// how many to insert each time
pub count: u32,
+ /// only between these hours (from == to: all day)
+ pub from_hour: u32,
+ pub to_hour: u32,
}
impl Default for Marker {
fn default() -> Self {
- Self { category: "Music".into(), every_mins: 60, count: 1 }
+ Self {
+ category: "Music".into(),
+ // inserts made before 0.2.9 only had "every N minutes"
+ trigger: Trigger::Minutes,
+ every_songs: 4,
+ every_mins: 60,
+ on_clock: false,
+ times: vec![],
+ count: 1,
+ from_hour: 0,
+ to_hour: 0,
+ }
+ }
+}
+
+/// A different pattern for part of the day, e.g. talk and fewer songs on weekday mornings.
+#[derive(Serialize, Deserialize, Clone)]
+#[serde(default)]
+pub struct Daypart {
+ pub name: String,
+ pub from_hour: u32,
+ pub to_hour: u32,
+ /// Monday … Sunday
+ pub days: [bool; 7],
+ pub pattern: Vec,
+}
+impl Default for Daypart {
+ fn default() -> Self {
+ Self { name: "Mornings".into(), from_hour: 6, to_hour: 10, days: [true; 7], pattern: vec![PatternItem::default()] }
+ }
+}
+impl Daypart {
+ pub fn covers(&self, t: &chrono::NaiveDateTime) -> bool {
+ use chrono::{Datelike, Timelike};
+ self.days[t.weekday().num_days_from_monday() as usize] && hours_ok(self.from_hour, self.to_hour, t.hour())
}
}
+/// How long a generated playlist runs.
+#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Default)]
+pub enum GenLength {
+ /// `Template::minutes`
+ #[default]
+ Minutes,
+ /// until the clock reaches `Template::until` (the next time it comes round)
+ Until,
+}
+
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Template {
pub name: String,
+ pub length: GenLength,
pub minutes: u32,
+ /// (hour, minute) for `GenLength::Until`
+ pub until: (u32, u32),
pub pattern: Vec,
+ /// patterns used instead of `pattern` at some times of day (the first that covers the time wins)
+ pub dayparts: Vec,
pub markers: Vec,
pub artist_gap: u32,
+ /// the same song doesn't come back within this many minutes (0 = whenever its pool runs out)
+ pub song_gap_mins: u32,
/// put an hour marker at the top of every hour
pub hour_markers: bool,
}
@@ -514,10 +595,14 @@ impl Default for Template {
fn default() -> Self {
Self {
name: "Default".into(),
+ length: GenLength::Minutes,
minutes: 120,
+ until: (0, 0),
pattern: vec![PatternItem::default()],
+ dayparts: vec![],
markers: vec![],
artist_gap: 5,
+ song_gap_mins: 0,
hour_markers: false,
}
}
diff --git a/src/generator.rs b/src/generator.rs
index 3558d88..da99330 100644
--- a/src/generator.rs
+++ b/src/generator.rs
@@ -1,18 +1,38 @@
//! Playlist generator: builds multi-hour programming from templates
-//! (category pattern + rotations + markers + artist separation).
+//! (category pattern + time-of-day patterns + inserts by songs / minutes / set times + artist and
+//! song separation), laid out along the clock from the time the new rows will start.
-use crate::config::{Category, Template};
+use crate::config::{hours_ok, Category, GenLength, Marker, Template, Trigger};
use crate::library::Track;
+use chrono::{Duration, NaiveDateTime, NaiveTime, Timelike};
use rand::seq::SliceRandom;
use std::collections::{HashMap, VecDeque};
+/// Marker source that inserts a time announcement instead of tracks.
+pub const TIME_SOURCE: &str = "@time";
+/// Rough air time of a time announcement.
+pub const TIME_SECS: f32 = 4.0;
+
+/// One generated row.
+#[derive(Clone, Debug)]
+pub enum Gen {
+ /// a track from a pattern step (`insert` = false) or an insert
+ Track {
+ track: Track,
+ insert: bool,
+ },
+ TimeAnnounce,
+}
+
struct Bag {
idx: Vec,
pos: usize,
}
impl Bag {
- fn next(&mut self, lib: &[Track], avoid: &VecDeque) -> Option {
+ /// The next track: not by an artist in `avoid`, nor one of `recent` (falls back to the
+ /// first candidate when everything is blocked).
+ fn next(&mut self, lib: &[Track], avoid: &VecDeque, recent: &VecDeque<(usize, f64)>) -> Option {
if self.idx.is_empty() {
return None;
}
@@ -25,7 +45,7 @@ impl Bag {
let i = self.idx[self.pos];
self.pos += 1;
let a = lib[i].artist.to_lowercase();
- if a.is_empty() || !avoid.contains(&a) {
+ if (a.is_empty() || !avoid.contains(&a)) && !recent.iter().any(|r| r.0 == i) {
return Some(i);
}
fallback.get_or_insert(i);
@@ -34,17 +54,30 @@ impl Bag {
}
}
+fn words(s: &str) -> Vec {
+ s.split(',').map(|w| w.trim().to_lowercase()).filter(|w| !w.is_empty()).collect()
+}
+
fn pool(cat: &Category, lib: &[Track]) -> Vec {
let folder = cat.folder.trim();
- let genre = cat.genre.trim().to_lowercase();
+ let genres = words(&cat.genre);
+ let exclude = words(&cat.exclude);
let mut v: Vec = lib
.iter()
.enumerate()
.filter(|(_, t)| {
+ let year: u32 = t.year.trim().get(..4).and_then(|y| y.parse().ok()).unwrap_or(0);
+ let genre = t.genre.to_lowercase();
(folder.is_empty() || t.path.starts_with(folder))
- && (genre.is_empty() || t.genre.to_lowercase().contains(&genre))
+ && (genres.is_empty() || genres.iter().any(|g| genre.contains(g.as_str())))
&& t.duration >= cat.min_secs
&& (cat.max_secs <= 0.0 || t.duration <= cat.max_secs)
+ && (cat.min_year == 0 || year >= cat.min_year)
+ && (cat.max_year == 0 || (year > 0 && year <= cat.max_year))
+ && (exclude.is_empty() || {
+ let hay = format!("{} {} {} {}", t.artist, t.title, t.genre, t.path.display()).to_lowercase();
+ !exclude.iter().any(|w| hay.contains(w.as_str()))
+ })
})
.map(|(i, _)| i)
.collect();
@@ -52,14 +85,57 @@ fn pool(cat: &Category, lib: &[Track]) -> Vec {
v
}
-/// Build the tracks for template `t`. `preset` gives ready-made pools (indexes into `lib`) for
-/// sources that aren't categories, e.g. "rule:3" = the files of advert / jingle rule 3.
-pub fn generate(t: &Template, cats: &[Category], lib: &[Track], preset: &HashMap>) -> Vec {
- let mut out: Vec = vec![];
- if t.pattern.is_empty() {
+/// The time of day `(h, m)` next after `t` (today or tomorrow).
+fn next_clock(t: NaiveDateTime, (h, m): (u32, u32)) -> NaiveDateTime {
+ let at = t.date().and_time(NaiveTime::from_hms_opt(h.min(23), m.min(59), 0).unwrap_or_default());
+ if at > t {
+ at
+ } else {
+ at + Duration::days(1)
+ }
+}
+
+/// When an insert by time goes off next after `t` (None: never, or it counts songs).
+fn next_due(m: &Marker, t: NaiveDateTime) -> Option {
+ match m.trigger {
+ Trigger::Songs => None,
+ Trigger::Times => m.times.iter().map(|&hm| next_clock(t, hm)).min(),
+ Trigger::Minutes => {
+ let n = m.every_mins.max(1) as i64;
+ if !m.on_clock {
+ return Some(t + Duration::minutes(n));
+ }
+ let midnight = t.date().and_time(NaiveTime::MIN);
+ let mins = (t - midnight).num_seconds() / 60;
+ Some(midnight + Duration::minutes((mins / n + 1) * n))
+ }
+ }
+}
+
+/// When a generated playlist starting at `start` should end.
+pub fn end_of(t: &Template, start: NaiveDateTime) -> NaiveDateTime {
+ match t.length {
+ GenLength::Minutes => start + Duration::minutes(t.minutes.max(1) as i64),
+ GenLength::Until => next_clock(start, t.until),
+ }
+}
+
+/// Build the rows for template `t`, starting on air at `start`. `preset` gives ready-made pools
+/// (indexes into `lib`) for sources that aren't categories, e.g. "rule:3" = the files of advert /
+/// jingle rule 3. `span` = how long a track takes on air.
+pub fn generate(
+ t: &Template,
+ cats: &[Category],
+ lib: &[Track],
+ preset: &HashMap>,
+ start: NaiveDateTime,
+ span: &dyn Fn(&Track) -> f32,
+) -> Vec {
+ let mut out: Vec = vec![];
+ if t.pattern.is_empty() && t.dayparts.iter().all(|d| d.pattern.is_empty()) {
return out;
}
- let target = t.minutes as f32 * 60.0;
+ let end = end_of(t, start);
let mut bags: HashMap = HashMap::new();
let get_bag = |name: &str, bags: &mut HashMap| {
if !bags.contains_key(name) {
@@ -74,42 +150,112 @@ pub fn generate(t: &Template, cats: &[Category], lib: &[Track], preset: &HashMap
bags.insert(name.to_string(), Bag { idx, pos: 0 });
}
};
+ let secs = |s: f32| Duration::milliseconds((s.max(1.0) * 1000.0) as i64);
+ let mut clock = start;
let mut recent: VecDeque = VecDeque::new();
- let mut marker_next: Vec = t.markers.iter().map(|m| m.every_mins.max(1) as f32 * 60.0).collect();
- let mut total = 0.0f32;
- for _cycle in 0..5000 {
- let mut picked_any = false;
- for p in &t.pattern {
- for _ in 0..p.count.max(1) {
- get_bag(&p.category, &mut bags);
- let Some(i) = bags.get_mut(&p.category).and_then(|b| b.next(lib, &recent)) else { continue };
- let tr = lib[i].clone();
- total += tr.duration.max(1.0);
- recent.push_back(tr.artist.to_lowercase());
- while recent.len() > t.artist_gap as usize {
- recent.pop_front();
- }
- out.push(tr);
- picked_any = true;
- for (k, m) in t.markers.iter().enumerate() {
- if total >= marker_next[k] {
- get_bag(&m.category, &mut bags);
- for _ in 0..m.count.max(1) {
- if let Some(j) = bags.get_mut(&m.category).and_then(|b| b.next(lib, &VecDeque::new())) {
- total += lib[j].duration.max(1.0);
- out.push(lib[j].clone());
- }
+ // (track, seconds after start) of songs inside the song gap
+ let mut played: VecDeque<(usize, f64)> = VecDeque::new();
+ let gap = t.song_gap_mins as f64 * 60.0;
+ let mut due: Vec> = t.markers.iter().map(|m| next_due(m, start)).collect();
+ let mut songs: Vec = vec![0; t.markers.len()];
+
+ // which pattern is in use (None = the main one) and the step it's on
+ let mut part: Option> = None;
+ let mut step = 0usize;
+ let mut misses = 0;
+ for _ in 0..20000 {
+ if clock >= end {
+ break;
+ }
+ let now_part = t.dayparts.iter().position(|d| !d.pattern.is_empty() && d.covers(&clock));
+ if part != Some(now_part) {
+ part = Some(now_part);
+ step = 0;
+ }
+ let pat = match now_part {
+ Some(k) => &t.dayparts[k].pattern,
+ None => &t.pattern,
+ };
+ if pat.is_empty() {
+ // nothing for this time of day: move on by a minute and look again
+ clock += Duration::minutes(1);
+ continue;
+ }
+ let p = &pat[step % pat.len()];
+ step += 1;
+ let mut picked = false;
+ for _ in 0..p.count.max(1) {
+ get_bag(&p.category, &mut bags);
+ let since = (clock - start).num_milliseconds() as f64 / 1000.0;
+ while played.front().is_some_and(|f| since - f.1 >= gap) {
+ played.pop_front();
+ }
+ let Some(i) = bags.get_mut(&p.category).and_then(|b| b.next(lib, &recent, &played)) else { break };
+ picked = true;
+ let tr = lib[i].clone();
+ clock += secs(span(&tr));
+ recent.push_back(tr.artist.to_lowercase());
+ while recent.len() > t.artist_gap as usize {
+ recent.pop_front();
+ }
+ if gap > 0.0 {
+ played.push_back((i, since));
+ }
+ out.push(Gen::Track { track: tr, insert: false });
+
+ // inserts that go off now
+ for (k, m) in t.markers.iter().enumerate() {
+ let fire = match m.trigger {
+ Trigger::Songs => {
+ songs[k] += 1;
+ if songs[k] >= m.every_songs.max(1) {
+ songs[k] = 0;
+ Some(clock)
+ } else {
+ None
}
- marker_next[k] += m.every_mins.max(1) as f32 * 60.0;
}
+ _ => match due[k] {
+ Some(at) if clock >= at => {
+ // the next time after now (missed times aren't played twice)
+ let mut next = next_due(m, at);
+ while let Some(n) = next.filter(|n| *n <= clock) {
+ next = next_due(m, n);
+ }
+ due[k] = next;
+ Some(at)
+ }
+ _ => None,
+ },
+ };
+ let Some(at) = fire else { continue };
+ if !hours_ok(m.from_hour, m.to_hour, at.hour()) {
+ continue;
}
- if total >= target {
- return out;
+ if m.category == TIME_SOURCE {
+ clock += secs(TIME_SECS);
+ out.push(Gen::TimeAnnounce);
+ continue;
}
+ get_bag(&m.category, &mut bags);
+ for _ in 0..m.count.max(1) {
+ if let Some(j) = bags.get_mut(&m.category).and_then(|b| b.next(lib, &VecDeque::new(), &VecDeque::new())) {
+ clock += secs(span(&lib[j]));
+ out.push(Gen::Track { track: lib[j].clone(), insert: true });
+ }
+ }
+ }
+ if clock >= end {
+ return out;
}
}
- if !picked_any {
- break;
+ if picked {
+ misses = 0;
+ } else {
+ misses += 1;
+ if misses > 64 {
+ break; // every source is empty
+ }
}
}
out
@@ -118,7 +264,8 @@ pub fn generate(t: &Template, cats: &[Category], lib: &[Track], preset: &HashMap
#[cfg(test)]
mod tests {
use super::*;
- use crate::config::{Marker, PatternItem};
+ use crate::config::{Daypart, Marker, PatternItem};
+ use chrono::NaiveDate;
use std::path::PathBuf;
fn lib() -> Vec {
@@ -129,7 +276,8 @@ mod tests {
artist: format!("A{}", i % 10),
title: format!("T{i}"),
duration: 180.0,
- genre: "Rock".into(),
+ genre: if i < 20 { "Rock".into() } else { "Pop".into() },
+ year: format!("{}", 1980 + i),
..Default::default()
});
}
@@ -141,14 +289,54 @@ mod tests {
..Default::default()
});
}
+ for i in 0..6 {
+ v.push(Track {
+ path: PathBuf::from(format!("/talk/k{i}.mp3")),
+ artist: format!("Host{i}"),
+ title: format!("K{i}"),
+ duration: 300.0,
+ ..Default::default()
+ });
+ }
v
}
fn cats() -> Vec {
vec![
Category { name: "Music".into(), folder: "/music".into(), ..Default::default() },
Category { name: "Jingles".into(), folder: "/jingles".into(), ..Default::default() },
+ Category { name: "Talk".into(), folder: "/talk".into(), ..Default::default() },
]
}
+ fn at(h: u32, m: u32) -> NaiveDateTime {
+ // a Monday
+ NaiveDate::from_ymd_opt(2026, 10, 26).unwrap().and_hms_opt(h, m, 0).unwrap()
+ }
+ fn run(t: &Template, start: NaiveDateTime) -> Vec {
+ generate(t, &cats(), &lib(), &HashMap::new(), start, &|t| t.duration)
+ }
+ fn tracks(out: &[Gen]) -> Vec<&Track> {
+ out.iter()
+ .filter_map(|g| match g {
+ Gen::Track { track, .. } => Some(track),
+ Gen::TimeAnnounce => None,
+ })
+ .collect()
+ }
+ /// (clock time, row) for every generated row
+ fn timeline(out: &[Gen], start: NaiveDateTime) -> Vec<(NaiveDateTime, &Gen)> {
+ let mut t = start;
+ out.iter()
+ .map(|g| {
+ let here = t;
+ let s = match g {
+ Gen::Track { track, .. } => track.duration,
+ Gen::TimeAnnounce => TIME_SECS,
+ };
+ t += Duration::milliseconds((s * 1000.0) as i64);
+ (here, g)
+ })
+ .collect()
+ }
#[test]
fn reaches_target_length_with_markers_and_artist_gap() {
@@ -156,16 +344,17 @@ mod tests {
name: "t".into(),
minutes: 60,
pattern: vec![PatternItem { category: "Music".into(), count: 3 }],
- markers: vec![Marker { category: "Jingles".into(), every_mins: 15, count: 1 }],
+ markers: vec![Marker { category: "Jingles".into(), every_mins: 15, count: 1, ..Default::default() }],
artist_gap: 4,
- hour_markers: false,
+ ..Default::default()
};
- let out = generate(&t, &cats(), &lib(), &HashMap::new());
- let total: f32 = out.iter().map(|t| t.duration).sum();
+ let out = run(&t, at(12, 0));
+ let all = tracks(&out);
+ let total: f32 = all.iter().map(|t| t.duration).sum();
assert!((3600.0..3600.0 + 400.0).contains(&total), "total {total}");
- assert!(out.iter().any(|t| t.path.starts_with("/jingles")));
+ assert!(all.iter().any(|t| t.path.starts_with("/jingles")));
// no artist repeats within the gap among music tracks
- let music: Vec<&Track> = out.iter().filter(|t| t.path.starts_with("/music")).collect();
+ let music: Vec<&&Track> = all.iter().filter(|t| t.path.starts_with("/music")).collect();
for w in music.windows(5) {
let mut s = std::collections::HashSet::new();
assert!(w.iter().all(|t| s.insert(t.artist.clone())), "artist repeated inside gap");
@@ -175,7 +364,7 @@ mod tests {
#[test]
fn empty_pool_terminates() {
let t = Template { pattern: vec![PatternItem { category: "Nope".into(), count: 2 }], ..Default::default() };
- assert!(generate(&t, &cats(), &lib(), &HashMap::new()).is_empty());
+ assert!(run(&t, at(12, 0)).is_empty());
}
#[test]
@@ -186,12 +375,145 @@ mod tests {
let t = Template {
minutes: 60,
pattern: vec![PatternItem { category: "Music".into(), count: 2 }, PatternItem { category: "rule:1".into(), count: 1 }],
- markers: vec![Marker { category: "rule:1".into(), every_mins: 30, count: 2 }],
+ markers: vec![Marker { category: "rule:1".into(), every_mins: 30, count: 2, ..Default::default() }],
..Default::default()
};
- let out = generate(&t, &cats(), &lib, &preset);
- let jingles = out.iter().filter(|t| t.path.starts_with("/jingles")).count();
- let songs = out.len() - jingles;
+ let out = generate(&t, &cats(), &lib, &preset, at(12, 0), &|t| t.duration);
+ let all = tracks(&out);
+ let jingles = all.iter().filter(|t| t.path.starts_with("/jingles")).count();
+ let songs = all.len() - jingles;
assert!(jingles >= songs / 2 + 2, "a jingle after every 2 songs plus 2 every half hour ({jingles} for {songs} songs)");
}
+
+ #[test]
+ fn time_announcement_at_the_top_of_every_hour() {
+ let t = Template {
+ minutes: 180,
+ pattern: vec![PatternItem { category: "Music".into(), count: 1 }],
+ markers: vec![Marker { category: TIME_SOURCE.into(), every_mins: 60, on_clock: true, ..Default::default() }],
+ ..Default::default()
+ };
+ let start = at(12, 10);
+ let out = run(&t, start);
+ let times: Vec =
+ timeline(&out, start).into_iter().filter(|(_, g)| matches!(g, Gen::TimeAnnounce)).map(|(t, _)| t).collect();
+ assert_eq!(times.len(), 3, "13:00, 14:00 and 15:00");
+ for (k, t) in times.iter().enumerate() {
+ let top = at(13 + k as u32, 0);
+ // after the song that runs over the hour, so within one song length
+ assert!(*t >= top && *t < top + Duration::minutes(3) + Duration::seconds(1), "announcement at {t}");
+ }
+ }
+
+ #[test]
+ fn inserts_at_set_times_and_after_songs_with_hours() {
+ let t = Template {
+ minutes: 240,
+ pattern: vec![PatternItem { category: "Music".into(), count: 1 }],
+ markers: vec![
+ Marker { category: "Talk".into(), trigger: Trigger::Times, times: vec![(13, 30), (15, 0)], ..Default::default() },
+ // a jingle after every 3 songs, but only from 14:00 to 15:00
+ Marker {
+ category: "Jingles".into(),
+ trigger: Trigger::Songs,
+ every_songs: 3,
+ from_hour: 14,
+ to_hour: 15,
+ ..Default::default()
+ },
+ ],
+ ..Default::default()
+ };
+ let start = at(12, 0);
+ let out = run(&t, start);
+ let line = timeline(&out, start);
+ let talk: Vec = line
+ .iter()
+ .filter(|(_, g)| matches!(g, Gen::Track { track, .. } if track.path.starts_with("/talk")))
+ .map(|(t, _)| *t)
+ .collect();
+ assert_eq!(talk.len(), 2);
+ assert!(talk[0] >= at(13, 30) && talk[0] < at(13, 34));
+ assert!(talk[1] >= at(15, 0) && talk[1] < at(15, 4));
+ let jingles: Vec = line
+ .iter()
+ .filter(|(_, g)| matches!(g, Gen::Track { track, .. } if track.path.starts_with("/jingles")))
+ .map(|(t, _)| *t)
+ .collect();
+ assert!(jingles.len() >= 5, "about 6 in the hour ({})", jingles.len());
+ // fired from 14:00 (the song before it counted) and before 15:00 (plus one song)
+ assert!(jingles.iter().all(|t| *t >= at(14, 0) && *t < at(15, 4)), "{jingles:?}");
+ }
+
+ #[test]
+ fn dayparts_switch_patterns_and_until_ends_on_time() {
+ let t = Template {
+ length: GenLength::Until,
+ until: (8, 0),
+ pattern: vec![PatternItem { category: "Music".into(), count: 1 }],
+ dayparts: vec![Daypart {
+ from_hour: 7,
+ to_hour: 9,
+ pattern: vec![PatternItem { category: "Talk".into(), count: 1 }],
+ ..Default::default()
+ }],
+ ..Default::default()
+ };
+ let start = at(6, 0);
+ let out = run(&t, start);
+ let line = timeline(&out, start);
+ let (last_t, last) = line.last().unwrap();
+ let end = *last_t
+ + Duration::seconds(match last {
+ Gen::Track { track, .. } => track.duration as i64,
+ _ => 4,
+ });
+ assert!(end >= at(8, 0) && end < at(8, 6), "ends at {end}");
+ for (t, g) in &line {
+ let Gen::Track { track, .. } = g else { continue };
+ if *t < at(7, 0) {
+ assert!(track.path.starts_with("/music"), "{t}: music before 7");
+ } else {
+ assert!(track.path.starts_with("/talk"), "{t}: talk from 7");
+ }
+ }
+ }
+
+ #[test]
+ fn song_gap_keeps_songs_apart() {
+ let t = Template {
+ minutes: 120,
+ artist_gap: 0,
+ song_gap_mins: 30,
+ pattern: vec![PatternItem { category: "Talk".into(), count: 1 }, PatternItem { category: "Music".into(), count: 1 }],
+ ..Default::default()
+ };
+ let start = at(12, 0);
+ let out = run(&t, start);
+ let line = timeline(&out, start);
+ let talk: Vec<(NaiveDateTime, &str)> = line
+ .iter()
+ .filter_map(|(t, g)| match g {
+ Gen::Track { track, .. } if track.path.starts_with("/talk") => Some((*t, track.title.as_str())),
+ _ => None,
+ })
+ .collect();
+ for (k, (t, title)) in talk.iter().enumerate() {
+ for (t2, title2) in &talk[k + 1..] {
+ if title == title2 {
+ assert!(*t2 - *t >= Duration::minutes(30), "{title} again after {}", *t2 - *t);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn category_filters_genres_years_and_words() {
+ let lib = lib();
+ let c = Category { genre: "rock, jazz".into(), min_year: 1990, exclude: "t15".into(), ..Default::default() };
+ let got: Vec<&Track> = pool(&c, &lib).into_iter().map(|i| &lib[i]).collect();
+ assert!(!got.is_empty());
+ assert!(got.iter().all(|t| t.genre == "Rock" && t.year.as_str() >= "1990" && t.title != "T15"));
+ assert_eq!(got.len(), 9, "1990…1999 without T15");
+ }
}
diff --git a/src/genui.rs b/src/genui.rs
new file mode 100644
index 0000000..2cea98d
--- /dev/null
+++ b/src/genui.rs
@@ -0,0 +1,452 @@
+//! Playlist Generator: categories, templates (pattern, time-of-day patterns, inserts by songs /
+//! minutes / set times, length, separation) and a preview of the running order before it goes in.
+use super::adsui::section;
+use super::*;
+use crate::config::{Category, Daypart, GenLength, Marker, PatternItem, Template, Trigger};
+
+#[derive(Default)]
+pub(super) struct GenUi {
+ /// time to add to an insert "at set times"
+ pub new_time: (u32, u32),
+ pub preview: Option,
+ /// bring the preview into view on the next frame
+ pub show_preview: bool,
+}
+
+/// A running order made by "Preview" that can go into the playlist as it is.
+pub(super) struct GenPreview {
+ pub template: String,
+ pub append: bool,
+ pub rows: Vec,
+ /// (start time, length) of each row
+ pub times: Vec<(chrono::DateTime, f32)>,
+}
+
+/// "all day" or from … to hours (from == to means all day).
+fn hours_edit(ui: &mut egui::Ui, from: &mut u32, to: &mut u32) {
+ let mut all_day = *from == *to;
+ if ui.checkbox(&mut all_day, tr("all day")).changed() {
+ (*from, *to) = if all_day { (0, 0) } else { (6, 22) };
+ }
+ if !all_day {
+ ui.label(tr("from"));
+ ui.add(egui::DragValue::new(from).range(0..=23).custom_formatter(|v, _| format!("{:02}:00", v as u32)));
+ ui.label(tr("to"));
+ ui.add(egui::DragValue::new(to).range(0..=23).custom_formatter(|v, _| format!("{:02}:00", v as u32)));
+ }
+}
+
+fn hhmm(ui: &mut egui::Ui, h: &mut u32, m: &mut u32) {
+ ui.add(egui::DragValue::new(h).range(0..=23).custom_formatter(|v, _| format!("{:02}", v as u32)));
+ ui.label(":");
+ ui.add(egui::DragValue::new(m).range(0..=59).custom_formatter(|v, _| format!("{:02}", v as u32)));
+}
+
+/// The steps of a pattern; returns nothing, edits in place.
+fn pattern_edit(
+ ui: &mut egui::Ui,
+ id: impl std::hash::Hash + Copy,
+ pat: &mut Vec,
+ cats: &[String],
+ rules: &[(u64, String, bool)],
+) {
+ let mut pdel = None;
+ for (k, p) in pat.iter_mut().enumerate() {
+ ui.horizontal(|ui| {
+ ui.label(format!("{}.", k + 1));
+ ui.add(egui::DragValue::new(&mut p.count).range(1..=20).suffix(" ×"));
+ source_combo(ui, ("pat", id, k), &mut p.category, cats, rules, false);
+ if ui.small_button("✖").on_hover_text(tr("Remove this step")).clicked() {
+ pdel = Some(k);
+ }
+ });
+ }
+ if let Some(k) = pdel {
+ pat.remove(k);
+ }
+ if ui.button(tr("➕ Add pattern step")).clicked() {
+ pat.push(PatternItem { category: cats.first().cloned().unwrap_or_else(|| "Music".into()), count: 1 });
+ }
+}
+
+impl App {
+ pub(super) fn tab_generator(&mut self, ui: &mut egui::Ui, core: &mut Playout) {
+ let cat_names: Vec = core.cfg.categories.iter().map(|c| c.name.clone()).collect();
+ let tpl_names: Vec = core.cfg.templates.iter().map(|t| t.name.clone()).collect();
+ let rules: Vec<(u64, String, bool)> = core.cfg.ad_rules.iter().map(|r| (r.id, r.name.clone(), r.adverts)).collect();
+ let first_jingle = rules.iter().find(|r| !r.2).map(|r| format!("rule:{}", r.0));
+ let mut gen: Option<(String, bool)> = None;
+ let mut preview: Option<(String, bool)> = None;
+ egui::ScrollArea::vertical().show(ui, |ui| {
+ ui.strong(tr("Auto-playlist"));
+ ui.horizontal_wrapped(|ui| {
+ ui.checkbox(&mut core.cfg.auto_enabled, tr("Keep the playlist filled automatically"));
+ ui.label(tr("using"));
+ name_combo(ui, "autotpl", &mut core.cfg.auto_template, &tpl_names);
+ ui.label(tr("when under"));
+ ui.add(egui::DragValue::new(&mut core.cfg.auto_min_mins).range(1..=600).suffix(" min left"));
+ });
+ ui.label(
+ RichText::new(tr("The template's times of day and timed inserts follow the clock from where the new tracks start."))
+ .small()
+ .color(GRAY),
+ );
+ ui.separator();
+
+ // ---- categories
+ egui::CollapsingHeader::new(RichText::new(tr("Categories (what each pool of tracks is made from)")).strong())
+ .id_salt("gen_cats")
+ .default_open(true)
+ .show(ui, |ui| {
+ let mut del = None;
+ for i in 0..core.cfg.categories.len() {
+ let c = &mut core.cfg.categories[i];
+ egui::Frame::group(ui.style()).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.horizontal_wrapped(|ui| {
+ ui.add(egui::TextEdit::singleline(&mut c.name).desired_width(110.0));
+ ui.label(tr("folder"));
+ ui.add(egui::TextEdit::singleline(&mut c.folder).desired_width(240.0).hint_text(tr("any")));
+ if ui.button("…").on_hover_text(tr("Browse")).clicked() {
+ self.dlg(move || rfd::FileDialog::new().pick_folder().map(|p| Dlg::CatFolder(i, p)));
+ }
+ if delete_button(ui, ("category", i), tr("this category")) {
+ del = Some(i);
+ }
+ });
+ ui.horizontal_wrapped(|ui| {
+ ui.label(tr("genre"));
+ ui.add(egui::TextEdit::singleline(&mut c.genre).desired_width(120.0).hint_text(tr("any")))
+ .on_hover_text(tr("Several genres: separate them with commas (Rock, Pop)"));
+ ui.label(tr("length"));
+ ui.add(egui::DragValue::new(&mut c.min_secs).range(0.0..=3600.0).suffix("s"));
+ ui.label("–");
+ ui.add(egui::DragValue::new(&mut c.max_secs).range(0.0..=7200.0).suffix("s"));
+ ui.label(tr("years"));
+ ui.add(egui::DragValue::new(&mut c.min_year).range(0..=2100).custom_formatter(|v, _| {
+ if v == 0.0 {
+ "any".into()
+ } else {
+ format!("{v}")
+ }
+ }));
+ ui.label("–");
+ ui.add(egui::DragValue::new(&mut c.max_year).range(0..=2100).custom_formatter(|v, _| {
+ if v == 0.0 {
+ "any".into()
+ } else {
+ format!("{v}")
+ }
+ }));
+ ui.label(tr("leave out"));
+ ui.add(egui::TextEdit::singleline(&mut c.exclude).desired_width(120.0).hint_text(tr("words, e.g. live, remix")))
+ .on_hover_text(tr("Tracks whose artist, title, genre or file name has one of these words are left out"));
+ });
+ });
+ }
+ if let Some(i) = del {
+ core.cfg.categories.remove(i);
+ }
+ if ui.button(tr("➕ Add category")).clicked() {
+ core.cfg.categories.push(Category { name: format!("Category {}", core.cfg.categories.len() + 1), ..Default::default() });
+ }
+ });
+ ui.separator();
+
+ // ---- templates
+ ui.strong(tr("Templates"));
+ ui.horizontal_wrapped(|ui| {
+ for (i, n) in tpl_names.iter().enumerate() {
+ ui.selectable_value(&mut self.tpl_sel, i, n);
+ }
+ if ui.button(tr("➕ New")).clicked() {
+ core.cfg.templates.push(Template { name: format!("Template {}", core.cfg.templates.len() + 1), ..Default::default() });
+ self.tpl_sel = core.cfg.templates.len() - 1;
+ }
+ if let Some(t) = core.cfg.templates.get(self.tpl_sel) {
+ if ui.button(tr("Duplicate")).on_hover_text(tr("A copy of this template to change")).clicked() {
+ let mut c = t.clone();
+ c.name = format!("{} (copy)", t.name);
+ core.cfg.templates.push(c);
+ self.tpl_sel = core.cfg.templates.len() - 1;
+ }
+ }
+ });
+ if self.tpl_sel >= core.cfg.templates.len() {
+ self.tpl_sel = core.cfg.templates.len().saturating_sub(1);
+ }
+ let mut del_tpl = false;
+ let Some(t) = core.cfg.templates.get_mut(self.tpl_sel) else { return };
+ ui.add_space(4.0);
+
+ section(ui, "1", tr("Length and separation"));
+ egui::Grid::new("gen_basics").num_columns(2).spacing([12.0, 6.0]).show(ui, |ui| {
+ ui.label(tr("Name"));
+ ui.add(egui::TextEdit::singleline(&mut t.name).desired_width(180.0));
+ ui.end_row();
+ ui.label(tr("Length"));
+ ui.vertical(|ui| {
+ ui.horizontal(|ui| {
+ ui.radio_value(&mut t.length, GenLength::Minutes, "");
+ ui.add_enabled(t.length == GenLength::Minutes, egui::DragValue::new(&mut t.minutes).range(5..=1440).suffix(" min"));
+ });
+ ui.horizontal(|ui| {
+ ui.radio_value(&mut t.length, GenLength::Until, tr("until"));
+ ui.add_enabled_ui(t.length == GenLength::Until, |ui| hhmm(ui, &mut t.until.0, &mut t.until.1));
+ ui.label(RichText::new(tr("the next time the clock reaches it")).small().color(GRAY));
+ });
+ });
+ ui.end_row();
+ ui.label(tr("Artist separation"));
+ ui.add(egui::DragValue::new(&mut t.artist_gap).range(0..=50).suffix(" tracks"));
+ ui.end_row();
+ ui.label(tr("Song separation"));
+ ui.horizontal(|ui| {
+ ui.add(egui::DragValue::new(&mut t.song_gap_mins).range(0..=1440).custom_formatter(|v, _| {
+ if v == 0.0 {
+ "off".into()
+ } else {
+ format!("{v} min")
+ }
+ }));
+ ui.label(RichText::new(tr("the same song doesn't come back sooner")).small().color(GRAY));
+ });
+ ui.end_row();
+ ui.label("");
+ ui.checkbox(&mut t.hour_markers, tr("Hour marker at the top of every hour"));
+ ui.end_row();
+ });
+ ui.add_space(8.0);
+
+ section(ui, "2", tr("Pattern"));
+ ui.label(RichText::new(tr("Repeats until the length is reached:")).color(GRAY));
+ pattern_edit(ui, "main", &mut t.pattern, &cat_names, &rules);
+ ui.add_space(8.0);
+
+ section(ui, "3", tr("Times of day"));
+ ui.label(
+ RichText::new(tr("A different pattern for part of the day. The first one that covers the time is used; outside them the pattern above is."))
+ .color(GRAY),
+ );
+ let mut ddel = None;
+ for (k, d) in t.dayparts.iter_mut().enumerate() {
+ egui::Frame::group(ui.style()).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.horizontal_wrapped(|ui| {
+ ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(120.0));
+ ui.label(tr("from"));
+ ui.add(egui::DragValue::new(&mut d.from_hour).range(0..=23).custom_formatter(|v, _| format!("{:02}:00", v as u32)));
+ ui.label(tr("to"));
+ ui.add(egui::DragValue::new(&mut d.to_hour).range(0..=23).custom_formatter(|v, _| format!("{:02}:00", v as u32)));
+ if d.from_hour == d.to_hour {
+ ui.label(RichText::new(tr("(all day)")).small().color(GRAY));
+ }
+ if delete_button(ui, ("daypart", self.tpl_sel, k), tr("this time of day")) {
+ ddel = Some(k);
+ }
+ });
+ adsui::days_edit(ui, &mut d.days);
+ pattern_edit(ui, ("dp", k), &mut d.pattern, &cat_names, &rules);
+ });
+ }
+ if let Some(k) = ddel {
+ t.dayparts.remove(k);
+ }
+ if ui.button(tr("➕ Add time of day")).clicked() {
+ t.dayparts.push(Daypart { pattern: t.pattern.clone(), ..Default::default() });
+ }
+ ui.add_space(8.0);
+
+ section(ui, "4", tr("Inserts"));
+ ui.label(
+ RichText::new(tr("Jingles, adverts, talk or a time announcement, like the advert / jingle rules: after a number of songs, every so often, or at set times. Timed ones go in after the song that's playing when the time comes."))
+ .color(GRAY),
+ );
+ let mut mdel = None;
+ for (k, m) in t.markers.iter_mut().enumerate() {
+ egui::Frame::group(ui.style()).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ ui.horizontal_wrapped(|ui| {
+ ui.label(tr("Insert"));
+ if m.category != crate::generator::TIME_SOURCE {
+ ui.add(egui::DragValue::new(&mut m.count).range(1..=10).suffix(" ×"));
+ }
+ source_combo(ui, ("mk", k), &mut m.category, &cat_names, &rules, true);
+ if delete_button(ui, ("insert", self.tpl_sel, k), tr("this insert")) {
+ mdel = Some(k);
+ }
+ });
+ ui.horizontal_wrapped(|ui| {
+ ui.radio_value(&mut m.trigger, Trigger::Songs, tr("after every"));
+ ui.add_enabled(m.trigger == Trigger::Songs, egui::DragValue::new(&mut m.every_songs).range(1..=100).suffix(" songs"));
+ ui.radio_value(&mut m.trigger, Trigger::Minutes, tr("every"));
+ ui.add_enabled_ui(m.trigger == Trigger::Minutes, |ui| {
+ ui.add(egui::DragValue::new(&mut m.every_mins).range(1..=1440).suffix(" min"));
+ ui.checkbox(&mut m.on_clock, tr("on the clock"))
+ .on_hover_text(tr("Every 60 minutes on the clock = at the top of each hour. Off = counted from the start of the playlist."));
+ });
+ });
+ ui.horizontal_wrapped(|ui| {
+ ui.radio_value(&mut m.trigger, Trigger::Times, tr("at"));
+ ui.add_enabled_ui(m.trigger == Trigger::Times, |ui| {
+ let mut tdel = None;
+ for (j, (h, mi)) in m.times.iter().enumerate() {
+ if ui.button(format!("{h:02}:{mi:02} ✖")).on_hover_text(tr("Remove this time")).clicked() {
+ tdel = Some(j);
+ }
+ }
+ if let Some(j) = tdel {
+ m.times.remove(j);
+ }
+ let (h, mi) = &mut self.gen.new_time;
+ hhmm(ui, h, mi);
+ if ui.button(tr("➕ Add time")).clicked() && !m.times.contains(&(*h, *mi)) {
+ m.times.push((*h, *mi));
+ m.times.sort();
+ }
+ });
+ });
+ ui.horizontal_wrapped(|ui| {
+ ui.label(tr("Hours"));
+ hours_edit(ui, &mut m.from_hour, &mut m.to_hour);
+ });
+ });
+ }
+ if let Some(k) = mdel {
+ t.markers.remove(k);
+ }
+ ui.horizontal_wrapped(|ui| {
+ if ui
+ .add_enabled(first_jingle.is_some(), egui::Button::new(tr("➕ Add jingles")))
+ .on_hover_text(tr("Uses the files of a jingle rule (Advertisement scheduler) every 15 minutes"))
+ .on_disabled_hover_text(tr("Make a jingle rule in the Advertisement scheduler first, or add a Jingles category"))
+ .clicked()
+ {
+ t.markers.push(Marker { category: first_jingle.clone().unwrap_or_default(), every_mins: 15, ..Default::default() });
+ }
+ if ui.button(tr("➕ Add time announcement")).on_hover_text(tr("At the top of every hour")).clicked() {
+ t.markers.push(Marker { category: crate::generator::TIME_SOURCE.into(), on_clock: true, ..Default::default() });
+ }
+ if ui.button(tr("➕ Add insert")).clicked() {
+ t.markers.push(Marker { category: cat_names.first().cloned().unwrap_or_default(), ..Default::default() });
+ }
+ });
+ ui.add_space(8.0);
+
+ ui.separator();
+ ui.horizontal_wrapped(|ui| {
+ if ui.button(tr("👁 Preview")).on_hover_text(tr("See the running order first; nothing changes until you use it")).clicked() {
+ preview = Some((t.name.clone(), false));
+ }
+ if ui.button(RichText::new(tr("▶ Generate (replace playlist)")).color(GREEN)).clicked() {
+ gen = Some((t.name.clone(), false));
+ }
+ if ui.button(tr("Append to playlist")).clicked() {
+ gen = Some((t.name.clone(), true));
+ }
+ if delete_button(ui, ("template", self.tpl_sel), tr("this template")) {
+ del_tpl = true;
+ }
+ });
+ if del_tpl {
+ core.cfg.templates.remove(self.tpl_sel);
+ }
+ self.gen_preview_ui(ui, core);
+ });
+ if let Some((n, append)) = preview {
+ self.make_preview(core, &n, append);
+ }
+ if let Some((n, append)) = gen {
+ self.gen.preview = None;
+ let c = core.generate_playlist(&n, append);
+ if c > 0 {
+ core.say(format!("Generated {c} tracks from '{n}'"));
+ }
+ }
+ }
+
+ fn make_preview(&mut self, core: &mut Playout, name: &str, append: bool) {
+ let Some(rows) = core.generated_rows(name, append) else {
+ self.gen.preview = None;
+ return;
+ };
+ let mut at = core.generate_start(append);
+ let mut times = vec![];
+ for r in &rows {
+ if let crate::items::ItemKind::HourMarker { hour, minute, .. } = r.kind {
+ at = crate::playout::marker_target(hour, minute, at).max(at);
+ }
+ let span = core.row_span(r);
+ times.push((at, span));
+ at += chrono::Duration::milliseconds((span * 1000.0) as i64);
+ }
+ self.gen.preview = Some(GenPreview { template: name.to_string(), append, rows, times });
+ self.gen.show_preview = true;
+ }
+
+ fn gen_preview_ui(&mut self, ui: &mut egui::Ui, core: &mut Playout) {
+ let Some(p) = &self.gen.preview else { return };
+ let mut close = false;
+ let mut use_it = false;
+ let mut again: Option = None;
+ ui.add_space(6.0);
+ egui::Frame::group(ui.style()).show(ui, |ui| {
+ ui.set_width(ui.available_width());
+ let songs = p.rows.iter().filter(|r| r.is_file()).count();
+ let total: f32 = p.times.iter().map(|t| t.1).sum();
+ ui.horizontal_wrapped(|ui| {
+ ui.strong(format!("{} — {}", tr("Preview"), p.template));
+ ui.label(RichText::new(format!("{songs} {} · {}", tr("tracks"), crate::items::mmss(total))).color(GRAY));
+ });
+ let row_h = ui.text_style_height(&egui::TextStyle::Body) + 2.0;
+ let out = egui::ScrollArea::vertical()
+ .id_salt("gen_preview")
+ .max_height(280.0)
+ .min_scrolled_height(240.0)
+ .auto_shrink([false, true])
+ .show_rows(ui, row_h, p.rows.len(), |ui, range| {
+ for k in range {
+ let (r, (at, _)) = (&p.rows[k], &p.times[k]);
+ ui.horizontal(|ui| {
+ ui.label(RichText::new(at.format("%H:%M:%S").to_string()).monospace());
+ ui.label(RichText::new(format!("{:>8}", r.length_text())).monospace().color(GRAY));
+ ui.add_sized([70.0, row_h], egui::Label::new(RichText::new(tr(r.type_label())).small().color(GRAY)));
+ ui.label(if r.is_file() { r.track.label() } else { r.title() });
+ });
+ }
+ });
+ if self.gen.show_preview {
+ ui.scroll_to_rect(out.inner_rect, Some(egui::Align::Center));
+ }
+ ui.horizontal_wrapped(|ui| {
+ if core.link.is_some() {
+ ui.label(RichText::new(tr("Connected to a server: Generate makes a new running order there.")).small().color(AMBER));
+ } else if ui.button(RichText::new(tr("✔ Put it in the playlist")).color(GREEN)).clicked() {
+ use_it = true;
+ }
+ let mut append = p.append;
+ if ui.checkbox(&mut append, tr("after the playlist (instead of replacing it)")).changed() {
+ again = Some(append);
+ }
+ if ui.button(tr("⟳ Again")).on_hover_text(tr("Another running order from the same template")).clicked() {
+ again = Some(p.append);
+ }
+ if ui.button(tr("Close")).clicked() {
+ close = true;
+ }
+ });
+ });
+ self.gen.show_preview = false;
+ if let Some(a) = again {
+ let n = p.template.clone();
+ self.make_preview(core, &n, a);
+ } else if use_it {
+ let Some(p) = self.gen.preview.take() else { return };
+ let n = p.rows.iter().filter(|r| r.is_file()).count();
+ core.use_generated(p.rows, p.append);
+ core.say(format!("Generated {n} tracks from '{}'", p.template));
+ } else if close {
+ self.gen.preview = None;
+ }
+ }
+}
diff --git a/src/playout.rs b/src/playout.rs
index 391fa54..4569fde 100644
--- a/src/playout.rs
+++ b/src/playout.rs
@@ -2524,14 +2524,39 @@ impl Playout {
if self.forward(|tab| sync::Cmd::Generate { tab, name: name.to_string(), append }) {
return 0;
}
+ let Some(rows) = self.generated_rows(name, append) else { return 0 };
+ let n = rows.iter().filter(|r| r.is_file()).count();
+ if !append {
+ self.clear_playlist();
+ }
+ self.playlist.extend(rows);
+ n
+ }
+
+ /// When rows added now would start on air: after the playlist (`append`), or now.
+ pub fn generate_start(&self, append: bool) -> DateTime {
+ if !append || self.playlist.is_empty() {
+ return self.now();
+ }
+ let k = self.playlist.len();
+ let starts = self.projected_starts(true);
+ let last = &self.playlist[k - 1];
+ starts[k - 1].map(|s| s.at).unwrap_or_else(Local::now) + CDur::milliseconds((self.row_span(last) * 1000.0) as i64)
+ }
+
+ /// The playlist rows template `name` makes, laid out from when they would start (None: it
+ /// made nothing; the reason is in the log).
+ pub fn generated_rows(&mut self, name: &str, append: bool) -> Option> {
let Some(tpl) = self.cfg.templates.iter().find(|t| t.name == name).cloned() else {
self.say(format!("Template '{name}' not found"));
- return 0;
+ return None;
};
// advert / jingle rules used as pattern steps or markers: their files join the pool
let mut lib = self.library.clone();
let mut preset: HashMap> = HashMap::new();
- for src in tpl.pattern.iter().map(|p| &p.category).chain(tpl.markers.iter().map(|m| &m.category)) {
+ let sources = tpl.pattern.iter().chain(tpl.dayparts.iter().flat_map(|d| d.pattern.iter())).map(|p| &p.category);
+ let sources: Vec = sources.chain(tpl.markers.iter().map(|m| &m.category)).cloned().collect();
+ for src in &sources {
let Some(id) = src.strip_prefix("rule:").and_then(|s| s.parse::().ok()) else { continue };
if preset.contains_key(src) {
continue;
@@ -2543,42 +2568,48 @@ impl Playout {
}
preset.insert(src.clone(), (start..lib.len()).collect());
}
- let tracks = crate::generator::generate(&tpl, &self.cfg.categories, &lib, &preset);
- let n = tracks.len();
- if n == 0 {
+ let mut t = self.generate_start(append);
+ let span = |tr: &Track| self.span_of(tr);
+ let made = crate::generator::generate(&tpl, &self.cfg.categories, &lib, &preset, t.naive_local(), &span);
+ if !made.iter().any(|g| matches!(g, crate::generator::Gen::Track { insert: false, .. })) {
self.say("Generator produced nothing — scan the library and check category filters");
- return 0;
- }
- if !append {
- self.clear_playlist();
+ return None;
}
- // when the new rows would start, so hour markers land on the right hours
- let starts = self.projected_starts(true);
- let mut t = match self.playlist.len() {
- 0 => Local::now(),
- k => {
- let last = &self.playlist[k - 1];
- starts[k - 1].map(|s| s.at).unwrap_or_else(Local::now) + CDur::milliseconds((self.row_span(last) * 1000.0) as i64)
- }
- };
- for tr in tracks {
- let span = self.span_of(&tr);
+ let mut rows = vec![];
+ for g in made {
+ let (row, span) = match g {
+ crate::generator::Gen::Track { track, .. } => {
+ let span = self.span_of(&track);
+ (PlEntry::file(track), span)
+ }
+ crate::generator::Gen::TimeAnnounce => {
+ (PlEntry::special(ItemKind::TimeAnnounce, "", crate::generator::TIME_SECS), crate::generator::TIME_SECS)
+ }
+ };
if tpl.hour_markers {
use chrono::Timelike;
let after = t + CDur::milliseconds((span * 1000.0) as i64);
if after.hour() != t.hour() || after.date_naive() != t.date_naive() {
- // this track would run over the top of the hour: the next hour starts with a marker
- self.playlist.push(PlEntry::file(tr));
+ // this row would run over the top of the hour: the next hour starts with a marker
+ rows.push(row);
let h = after.hour();
- self.playlist.push(PlEntry::special(ItemKind::HourMarker { hour: h, minute: 0, hard: false, wait: true }, "", 0.0));
+ rows.push(PlEntry::special(ItemKind::HourMarker { hour: h, minute: 0, hard: false, wait: true }, "", 0.0));
t = marker_target(h, 0, after);
continue;
}
}
t += CDur::milliseconds((span * 1000.0) as i64);
- self.playlist.push(PlEntry::file(tr));
+ rows.push(row);
}
- n
+ Some(rows)
+ }
+
+ /// Put rows made by `generated_rows` (e.g. a preview the user liked) into the playlist.
+ pub fn use_generated(&mut self, rows: Vec, append: bool) {
+ if !append {
+ self.clear_playlist();
+ }
+ self.playlist.extend(rows);
}
fn export_now(&self, t: &Track) {
@@ -2711,6 +2742,8 @@ impl Playout {
self.playlist.iter_mut().for_each(|e| e.played = false);
self.pending_jump = Some(0);
}
+ // playlist event rows don't carry an item (the scheduler's events do)
+ ActionKind::InsertItem => {}
}
// another playlist goes on air next tick: don't start this list's next row in between
self.pending_tab.is_some() || self.state == PlayState::Stopped
@@ -2756,6 +2789,26 @@ impl Playout {
self.playlist.iter_mut().for_each(|r| r.played = false);
self.play_index(0, xf);
}
+ ActionKind::InsertItem => {
+ let Some(mut row) = e.item.clone() else {
+ self.say(format!("Scheduler: '{}' has nothing to insert", e.name));
+ return;
+ };
+ row.played = false;
+ let at = self.current.map(|c| c + 1).unwrap_or(0);
+ // the last one hasn't played yet (e.g. playback is stopped): don't pile them up
+ let waiting = self.playlist.get(at).is_some_and(|r| !r.played && r.kind == row.kind && r.track.title == row.track.title);
+ if !waiting {
+ self.insert_rows(at, vec![row]);
+ }
+ if e.play_now {
+ if self.state == PlayState::Stopped {
+ self.play_index(at, xf);
+ } else {
+ self.next();
+ }
+ }
+ }
}
}
}
@@ -3044,6 +3097,56 @@ mod tests {
assert_eq!(p.library.last().unwrap().title, "Old");
}
+ #[test]
+ fn scheduler_inserts_a_playlist_item_next() {
+ let mut p = playout();
+ p.playlist = vec![file("a", 180.0), file("b", 180.0)];
+ p.current = Some(0);
+ let e =
+ Event { action: ActionKind::InsertItem, item: Some(PlEntry::special(ItemKind::TimeAnnounce, "", 4.0)), ..Default::default() };
+ p.run_event(&e);
+ assert_eq!(p.playlist.len(), 3);
+ assert!(matches!(p.playlist[1].kind, ItemKind::TimeAnnounce), "goes in after the track on air");
+ // it hasn't played yet when the next one comes round: no second copy
+ p.run_event(&e);
+ assert_eq!(p.playlist.len(), 3);
+ // an event without an item does nothing
+ p.run_event(&Event { action: ActionKind::InsertItem, ..Default::default() });
+ assert_eq!(p.playlist.len(), 3);
+ }
+
+ #[test]
+ fn generated_rows_follow_the_clock_and_go_in() {
+ use crate::config::{Category, Marker, PatternItem, Template};
+ let mut p = playout();
+ p.library = (0..30)
+ .map(|i| Track {
+ path: PathBuf::from(format!("/m/t{i}.mp3")),
+ artist: format!("A{i}"),
+ title: format!("T{i}"),
+ duration: 200.0,
+ ..Default::default()
+ })
+ .collect();
+ p.cfg.categories = vec![Category { name: "Music".into(), ..Default::default() }];
+ p.cfg.templates = vec![Template {
+ name: "Clock".into(),
+ minutes: 130,
+ pattern: vec![PatternItem { category: "Music".into(), count: 1 }],
+ markers: vec![Marker { category: crate::generator::TIME_SOURCE.into(), on_clock: true, every_mins: 60, ..Default::default() }],
+ ..Default::default()
+ }];
+ let rows = p.generated_rows("Clock", false).unwrap();
+ let ann = rows.iter().filter(|r| matches!(r.kind, ItemKind::TimeAnnounce)).count();
+ assert!((2..=3).contains(&ann), "one per top of the hour in 130 minutes ({ann})");
+ assert!(p.playlist.is_empty(), "a preview changes nothing");
+ p.use_generated(rows.clone(), false);
+ assert_eq!(p.playlist.len(), rows.len());
+ let before = p.playlist.len();
+ let n = p.generate_playlist("Clock", true);
+ assert!(n > 0 && p.playlist.len() > before + n - 1, "appended after the list");
+ }
+
#[test]
fn playlist_events_return_to_main_and_loop() {
let mut p = playout();
diff --git a/src/pltools.rs b/src/pltools.rs
index 952c858..e360cca 100644
--- a/src/pltools.rs
+++ b/src/pltools.rs
@@ -22,6 +22,19 @@ pub(super) enum DlgKind {
Event,
}
+/// The Playlist-menu items a scheduler event can insert.
+pub(super) const EVENT_ITEMS: [(DlgKind, &str); 9] = [
+ (DlgKind::TimeAnnounce, "Time announcement"),
+ (DlgKind::Tts, "Text to speech…"),
+ (DlgKind::TrackList, "Track List (a random track from a folder)…"),
+ (DlgKind::Container, "Container…"),
+ (DlgKind::Url, "URL (internet stream)…"),
+ (DlgKind::LineIn, "Line Input…"),
+ (DlgKind::Teaser, "Teaser…"),
+ (DlgKind::Pause, "Timed pause…"),
+ (DlgKind::Command, "Command…"),
+];
+
impl DlgKind {
fn title(self) -> &'static str {
match self {
@@ -47,6 +60,8 @@ pub(super) struct ItemDlg {
/// row being edited (in `tab`), or None to insert a new one
pub edit: Option,
pub tab: usize,
+ /// the row is for scheduler event `event` (`ActionKind::InsertItem`), not the playlist
+ pub event: Option,
pub title: String,
pub text: String,
pub mins: u32,
@@ -103,6 +118,7 @@ impl App {
kind,
edit: None,
tab: self.view_pl,
+ event: None,
title: String::new(),
text: String::new(),
mins: if kind == DlgKind::Pause { 0 } else { 5 },
@@ -137,6 +153,39 @@ impl App {
/// Open the dialog for an existing special row so it can be changed.
pub(super) fn edit_item(&mut self, row: usize, e: &PlEntry) {
+ if let Some(mut d) = self.dlg_for(e) {
+ d.edit = Some(row);
+ self.item_dlg = Some(d);
+ }
+ }
+
+ /// Open the dialog for the row scheduler event `i` inserts: a new one of `kind`, or (None)
+ /// the one it has. A time announcement needs no settings and is set straight away.
+ pub(super) fn edit_event_item(&mut self, core: &mut Playout, i: usize, kind: Option) {
+ if !self.perm.tool(crate::auth::tool::SCHEDULER) {
+ return;
+ }
+ let d = match kind {
+ Some(DlgKind::TimeAnnounce) => {
+ if let (Some(ev), Ok(row)) = (core.cfg.events.get_mut(i), Self::item_from_dlg(&self.new_item_dlg(DlgKind::TimeAnnounce))) {
+ ev.item = Some(row);
+ }
+ return;
+ }
+ Some(k) => {
+ self.open_item_dlg(k);
+ self.item_dlg.take()
+ }
+ None => core.cfg.events.get(i).and_then(|e| e.item.as_ref()).and_then(|e| self.dlg_for(e)),
+ };
+ if let Some(mut d) = d {
+ d.event = Some(i);
+ self.item_dlg = Some(d);
+ }
+ }
+
+ /// A dialog filled in from an existing special row (None for audio files).
+ fn dlg_for(&self, e: &PlEntry) -> Option {
let split = |s: f32| ((s.max(0.0) as u32) / 60, (s.max(0.0) as u32) % 60);
let mut d = match &e.kind {
ItemKind::Url { url, secs } => {
@@ -203,11 +252,10 @@ impl App {
d
}
ItemKind::TimeAnnounce => self.new_item_dlg(DlgKind::TimeAnnounce),
- ItemKind::Track | ItemKind::VoiceTrack => return,
+ ItemKind::Track | ItemKind::VoiceTrack => return None,
};
d.title = e.track.title.clone();
- d.edit = Some(row);
- self.item_dlg = Some(d);
+ Some(d)
}
/// Build the playlist row a dialog describes.
@@ -324,12 +372,20 @@ impl App {
pub(super) fn item_dialog_window(&mut self, ctx: &egui::Context, core: &mut Playout) {
let Some(mut d) = self.item_dlg.take() else { return };
- if !self.perm.edit_playlists {
- return; // the dialog closes: this account can't change playlists
+ // a scheduler event's item is the scheduler's to change; any other row is the playlist's
+ let allowed = if d.event.is_some() { self.perm.tool(crate::auth::tool::SCHEDULER) } else { self.perm.edit_playlists };
+ if !allowed {
+ return; // the dialog closes: this account can't change it
}
let mut open = true;
let (mut ok, mut cancel) = (false, false);
- let title = if d.edit.is_some() { d.kind.title().replace("Add", "Edit") } else { d.kind.title().to_string() };
+ let title = if d.event.is_some() {
+ d.kind.title().replacen("Add ", "Scheduled ", 1)
+ } else if d.edit.is_some() {
+ d.kind.title().replace("Add", "Edit")
+ } else {
+ d.kind.title().to_string()
+ };
Self::window_for(ctx, &title, [460.0, 300.0]).id(egui::Id::new("item_dlg")).collapsible(false).open(&mut open).show(ctx, |ui| {
// the fields scroll; OK / Cancel always stay on screen
let max_h = (ui.ctx().screen_rect().height() - 220.0).max(160.0);
@@ -620,6 +676,12 @@ impl App {
});
if ok {
match Self::item_from_dlg(&d) {
+ Ok(e) if d.event.is_some() => {
+ if let Some(ev) = d.event.and_then(|i| core.cfg.events.get_mut(i)) {
+ ev.item = Some(e);
+ }
+ return;
+ }
Ok(e) => {
match d.edit {
Some(row) => {
diff --git a/src/scheduler.rs b/src/scheduler.rs
index 83c42d2..8e724ca 100644
--- a/src/scheduler.rs
+++ b/src/scheduler.rs
@@ -23,10 +23,14 @@ pub enum ActionKind {
Command,
/// play the on-air playlist again from its first row
RestartPlaylist,
+ /// put a playlist item (time announcement, speech, stream, track list…) in as the next row
+ /// (`Event::item`; `Event::play_now` = start it straight away)
+ InsertItem,
}
impl ActionKind {
- pub const ALL: [ActionKind; 13] = [
+ pub const ALL: [ActionKind; 14] = [
+ ActionKind::InsertItem,
ActionKind::GenerateReplace,
ActionKind::GenerateAppend,
ActionKind::RunRule,
@@ -56,6 +60,7 @@ impl ActionKind {
ActionKind::SwitchPlaylist => "Put a playlist tab on air",
ActionKind::Command => "Run a station command",
ActionKind::RestartPlaylist => "Play the on-air playlist again from the top",
+ ActionKind::InsertItem => "Insert a playlist item (time announcement, speech, stream…)",
}
}
/// the action takes a generator template name
@@ -77,11 +82,19 @@ pub struct Event {
pub enabled: bool,
pub hour: u32,
pub minute: u32,
+ /// 0 = once at hour:minute; otherwise again every this many minutes until `until`
+ pub repeat_mins: u32,
+ /// (hour, minute) the repeats stop at (inclusive; before the start = runs over midnight)
+ pub until: (u32, u32),
/// Monday..Sunday
pub days: [bool; 7],
pub action: ActionKind,
pub path: String,
pub arg: f32,
+ /// the row `ActionKind::InsertItem` puts in
+ pub item: Option,
+ /// `ActionKind::InsertItem`: start it now instead of after the track on air
+ pub play_now: bool,
/// runs only from / to these dates (YYYY-MM-DD, empty = no limit)
pub from_date: String,
pub to_date: String,
@@ -94,10 +107,14 @@ impl Default for Event {
enabled: true,
hour: 12,
minute: 0,
+ repeat_mins: 0,
+ until: (23, 59),
days: [true; 7],
action: ActionKind::GenerateReplace,
path: String::new(),
arg: 0.0,
+ item: None,
+ play_now: false,
from_date: String::new(),
to_date: String::new(),
}
@@ -106,12 +123,72 @@ impl Default for Event {
impl Event {
pub fn due(&self, now: &DateTime) -> bool {
+ self.on_day(now) && self.at_time(now.hour(), now.minute())
+ }
+
+ /// Enabled, and today is one of its days inside its dates.
+ pub fn on_day(&self, now: &DateTime) -> bool {
let today = now.format("%Y-%m-%d").to_string();
self.enabled
- && self.hour == now.hour()
- && self.minute == now.minute()
&& self.days[now.weekday().num_days_from_monday() as usize]
&& (self.from_date.trim().is_empty() || today.as_str() >= self.from_date.trim())
&& (self.to_date.trim().is_empty() || today.as_str() <= self.to_date.trim())
}
+
+ /// It goes off at `h:m` (ignoring days and dates).
+ pub fn at_time(&self, h: u32, m: u32) -> bool {
+ let start = self.hour * 60 + self.minute;
+ let now = h * 60 + m;
+ if self.repeat_mins == 0 {
+ return now == start;
+ }
+ let end = self.until.0.min(23) * 60 + self.until.1.min(59);
+ let off = (now + 1440 - start) % 1440;
+ let span = if end == start { 1439 } else { (end + 1440 - start) % 1440 };
+ off <= span && off.is_multiple_of(self.repeat_mins)
+ }
+
+ /// "at 12:00", or "every 60 min from 06:00 to 22:00".
+ pub fn when_text(&self) -> String {
+ if self.repeat_mins == 0 {
+ return format!("at {:02}:{:02}", self.hour, self.minute);
+ }
+ let m = self.repeat_mins;
+ let every = if m.is_multiple_of(60) { format!("{} h", m / 60) } else { format!("{m} min") };
+ format!("every {every} from {:02}:{:02} to {:02}:{:02}", self.hour, self.minute, self.until.0, self.until.1)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn once_and_repeating_times() {
+ let once = Event { hour: 12, minute: 30, ..Default::default() };
+ assert!(once.at_time(12, 30));
+ assert!(!once.at_time(13, 30));
+
+ // every hour on the hour, all day
+ let hourly = Event { hour: 0, minute: 0, repeat_mins: 60, until: (23, 59), ..Default::default() };
+ assert!((0..24).all(|h| hourly.at_time(h, 0)));
+ assert!(!hourly.at_time(10, 30));
+
+ // every 15 minutes from 06:00 to 09:00
+ let morning = Event { hour: 6, minute: 0, repeat_mins: 15, until: (9, 0), ..Default::default() };
+ assert!(morning.at_time(6, 0) && morning.at_time(6, 45) && morning.at_time(9, 0));
+ assert!(!morning.at_time(5, 45) && !morning.at_time(9, 15) && !morning.at_time(7, 10));
+
+ // every 30 minutes from 22:00 over midnight to 02:00
+ let night = Event { hour: 22, minute: 0, repeat_mins: 30, until: (2, 0), ..Default::default() };
+ assert!(night.at_time(22, 0) && night.at_time(23, 30) && night.at_time(0, 0) && night.at_time(2, 0));
+ assert!(!night.at_time(2, 30) && !night.at_time(12, 0));
+ }
+
+ #[test]
+ fn old_events_load_as_once() {
+ let e: Event = serde_json::from_str(r#"{"name":"News","hour":9,"minute":0,"action":"Stop"}"#).unwrap();
+ assert_eq!(e.repeat_mins, 0);
+ assert!(e.at_time(9, 0) && !e.at_time(10, 0));
+ }
}