···5959 RemovePlaylistEntry {
6060 id: PlaylistEntryId,
6161 },
6262- SetPlaylistEnabled(bool),
6262+ SetAutoplay {
6363+ enabled: bool,
6464+ },
6565+ SetSkipThreshold {
6666+ percent: u8,
6767+ },
6368 PublishState,
6469 Shutdown,
6570 MoveQueueItem {
···402407 self.handle_add_playlist_entry(url, added_by).await
403408 }
404409 RoomCommand::RemovePlaylistEntry { id } => self.handle_remove_playlist_entry(id).await,
405405- RoomCommand::SetPlaylistEnabled(enabled) => {
406406- self.handle_set_playlist_enabled(enabled).await
410410+ RoomCommand::SetAutoplay { enabled } => self.handle_set_autoplay(enabled).await,
411411+ RoomCommand::SetSkipThreshold { percent } => {
412412+ self.handle_set_skip_threshold(percent).await
407413 }
408414 RoomCommand::PublishState => self.handle_publish_state().await,
409415 RoomCommand::Shutdown => self.handle_shutdown().await,
···672678 false
673679 }
674680675675- /// Toggle the perpetual playlist on/off. The state machine emits
676676- /// `PublishSnapshot` only when enabling (so clients see the toggle);
677677- /// disabling is a no-op effect-wise since the room will go idle and
678678- /// the next snapshot reflects that.
679679- async fn handle_set_playlist_enabled(&mut self, enabled: bool) -> bool {
681681+ /// Toggle the room's auto-play setting. The state machine decides
682682+ /// whether the change is observable (no-op if same value, or if
683683+ /// re-enabling an empty playlist).
684684+ async fn handle_set_autoplay(&mut self, enabled: bool) -> bool {
685685+ let effects = self.state.transition(&Event::SetAutoplay { enabled }, 0);
686686+ if effects.is_empty() {
687687+ return false;
688688+ }
689689+ self.persist_effects(&effects).await;
690690+ self.execute_effects(effects).await;
691691+ false
692692+ }
693693+694694+ /// Set the skip-vote threshold. Persists and publishes when the
695695+ /// value actually changes.
696696+ async fn handle_set_skip_threshold(&mut self, percent: u8) -> bool {
680697 let effects = self
681698 .state
682682- .transition(&Event::SetPlaylistEnabled(enabled), 0);
699699+ .transition(&Event::SetSkipThreshold { percent }, 0);
683700 if effects.is_empty() {
684701 return false;
685702 }
703703+ self.persist_effects(&effects).await;
686704 self.execute_effects(effects).await;
687705 false
688706 }
···927945 "queue": queue,
928946 "history": history,
929947 "playlist": self.state.playlist,
930930- "playlist_enabled": self.state.playlist_enabled,
948948+ "autoplay_enabled": self.state.autoplay_enabled,
949949+ "skip_threshold": self.state.skip_threshold,
931950 "clients": client_count,
932951 });
933952 self.publishers.state_tx.send_replace(snapshot);
···10991118 #[test]
11001119 fn test_next_playlist_entry_disabled_returns_none() {
11011120 let mut state = PlaybackState {
11211121+ autoplay_enabled: false,
11021122 playlist: vec![PlaylistEntry {
11031123 id: pid(1),
11041124 url: "https://example.com/p".into(),
···11121132 }],
11131133 ..PlaybackState::default()
11141134 };
11151115- // Default is disabled; no entries drawn until the user enables it.
11351135+ // When autoplay is explicitly disabled, no entries drawn.
11161136 assert!(state.next_playlist_entry().is_none());
11171137 }
1118113811191139 #[test]
11201140 fn test_next_playlist_entry_returns_and_advances_when_idle_and_enabled() {
11211141 let mut state = PlaybackState {
11221122- playlist_enabled: true,
11421142+ autoplay_enabled: true,
11231143 playlist: vec![PlaylistEntry {
11241144 id: pid(1),
11251145 url: "https://example.com/p".into(),
···11421162 #[test]
11431163 fn test_next_playlist_entry_wraps_around() {
11441164 let mut state = PlaybackState {
11451145- playlist_enabled: true,
11651165+ autoplay_enabled: true,
11461166 playlist: vec![
11471167 PlaylistEntry {
11481168 id: pid(1),
+150-40
src/state.rs
···8888 PlaylistEntryAdded(PlaylistEntry),
8989 /// Remove a perpetual playlist entry by id.
9090 PlaylistEntryRemoved(PlaylistEntryId),
9191- /// Toggle the perpetual playlist on/off.
9292- SetPlaylistEnabled(bool),
9391 /// Reorder a track within the up-next queue.
9492 /// `from` and `to` are 0-based queue indices. `from == to` is a no-op.
9593 /// Indices out of range or queue empty are no-ops.
···10098 /// Remove a track from the up-next queue. No-op if the track isn't
10199 /// queued (active and history are unaffected).
102100 RemoveQueueItem { track_id: TrackId },
101101+ /// Toggle the room's auto-play setting. When `true`, idle rooms
102102+ /// auto-fill from the perpetual playlist (also surfaced as the
103103+ /// playlist panel in the UI).
104104+ SetAutoplay { enabled: bool },
105105+ /// Set the skip-vote threshold as a percentage of the room size
106106+ /// (0..=100). `votes * 100 >= threshold * count` triggers a skip.
107107+ /// `percent > 100` is clamped to 100; values outside 0..=100 are no-ops.
108108+ SetSkipThreshold { percent: u8 },
103109}
104110105111/// Side effects to execute after a transition.
···154160 /// `PublishSnapshot` after the state machine removes the track from
155161 /// its in-memory `queue` Vec.
156162 RemoveQueuedTrack { track_id: TrackId },
163163+ /// Persist a change to the room's autoplay setting or skip-vote
164164+ /// threshold. Carried together so the actor writes both columns in
165165+ /// one UPDATE.
166166+ PersistRoomSettings {
167167+ autoplay_enabled: bool,
168168+ skip_threshold: u8,
169169+ },
157170}
158171159172/// Pure playback state: no IO handles, no DB connections.
160160-#[derive(Debug, Clone, Default)]
173173+#[derive(Debug, Clone)]
161174pub(crate) struct PlaybackState {
162175 /// Currently playing track, if any.
163176 pub active: Option<ActiveTrackInfo>,
···173186 pub playlist: Vec<PlaylistEntry>,
174187 /// Index of the next playlist entry to draw from (`next_playlist_entry`).
175188 pub playlist_cursor: usize,
176176- /// When false, the perpetual playlist is paused; the room will not auto-fill.
177177- pub playlist_enabled: bool,
189189+ /// When true, the room auto-fills from the playlist when idle (and
190190+ /// on client connect, as an actor-level trigger). Also gates the
191191+ /// playlist panel in the UI.
192192+ pub autoplay_enabled: bool,
193193+ /// Skip-vote threshold as a percentage of the room size
194194+ /// (0..=100). `votes * 100 >= threshold * count` triggers a skip
195195+ /// (the actor counts votes; the state machine just stores this).
196196+ pub skip_threshold: u8,
197197+}
198198+199199+/// Defaults for new rooms. `autoplay_enabled` and `skip_threshold` are
200200+/// the schema's column defaults too — keep them aligned.
201201+const DEFAULT_AUTOPLAY_ENABLED: bool = true;
202202+const DEFAULT_SKIP_THRESHOLD: u8 = 34;
203203+204204+impl Default for PlaybackState {
205205+ fn default() -> Self {
206206+ Self {
207207+ active: None,
208208+ queue: Vec::new(),
209209+ history: Vec::new(),
210210+ media_cache: HashMap::new(),
211211+ playlist: Vec::new(),
212212+ playlist_cursor: 0,
213213+ autoplay_enabled: DEFAULT_AUTOPLAY_ENABLED,
214214+ skip_threshold: DEFAULT_SKIP_THRESHOLD,
215215+ }
216216+ }
178217}
179218180219impl PlaybackState {
···207246 Event::TrackQueuedFromPlaylist(track, _source_index) => self.handle_queued(track),
208247 Event::PlaylistEntryAdded(entry) => self.handle_playlist_entry_added(entry),
209248 Event::PlaylistEntryRemoved(id) => self.handle_playlist_entry_removed(*id),
210210- Event::SetPlaylistEnabled(enabled) => self.handle_set_playlist_enabled(*enabled),
249249+ Event::SetAutoplay { enabled } => self.handle_set_autoplay(*enabled),
250250+ Event::SetSkipThreshold { percent } => self.handle_set_skip_threshold(*percent),
211251 Event::MoveQueueItem { from, to } => self.handle_move_queue_item(*from, *to),
212252 Event::ShuffleQueue { seed } => self.handle_shuffle_queue(*seed),
213253 Event::RemoveQueueItem { track_id } => self.handle_remove_queue_item(*track_id),
···515555 self.playlist.push(entry.clone());
516556 }
517557 // Enable by default when the first entry is added.
518518- if !self.playlist_enabled {
519519- self.playlist_enabled = true;
558558+ if !self.autoplay_enabled {
559559+ self.autoplay_enabled = true;
520560 }
521561 vec![
522562 Effect::AddPlaylistEntry {
···540580 self.playlist_cursor = self.playlist.len() - 1;
541581 }
542582 if self.playlist.is_empty() {
543543- self.playlist_enabled = false;
583583+ self.autoplay_enabled = false;
544584 }
545585 return vec![Effect::RemovePlaylistEntry(id), Effect::PublishSnapshot];
546586 }
···549589550590 /// Toggle the perpetual playlist on/off. Disabling is always a no-op for state.
551591 /// Re-enabling only takes effect if there's something to play.
552552- fn handle_set_playlist_enabled(&mut self, enabled: bool) -> Vec<Effect> {
592592+ fn handle_set_autoplay(&mut self, enabled: bool) -> Vec<Effect> {
553593 if enabled && self.playlist.is_empty() {
554594 // Re-enabling an empty playlist: leave it disabled, no-op.
555595 return Vec::new();
556596 }
557557- if self.playlist_enabled == enabled {
597597+ if self.autoplay_enabled == enabled {
558598 return Vec::new();
559599 }
560560- self.playlist_enabled = enabled;
561561- // Don't publish on disable: it has no observable effect until something
562562- // else triggers a snapshot. Publish on enable so clients see the toggle.
563563- if enabled {
564564- vec![Effect::PublishSnapshot]
565565- } else {
566566- Vec::new()
600600+ self.autoplay_enabled = enabled;
601601+ // Persist the new settings + publish so clients see the toggle.
602602+ // (The empty-Playlist re-enable guard above means we never
603603+ // reach here when the playlist is empty, so this fires only
604604+ // for a real state change.)
605605+ vec![
606606+ Effect::PersistRoomSettings {
607607+ autoplay_enabled: self.autoplay_enabled,
608608+ skip_threshold: self.skip_threshold,
609609+ },
610610+ Effect::PublishSnapshot,
611611+ ]
612612+ }
613613+614614+ /// Set the skip-vote threshold as a percentage of room size. Out of
615615+ /// range values are clamped or no-op (0 means any single vote skips;
616616+ /// 100 means unanimous). Persists + publishes on change.
617617+ fn handle_set_skip_threshold(&mut self, percent: u8) -> Vec<Effect> {
618618+ if self.skip_threshold == percent {
619619+ return Vec::new();
567620 }
621621+ self.skip_threshold = percent;
622622+ vec![
623623+ Effect::PersistRoomSettings {
624624+ autoplay_enabled: self.autoplay_enabled,
625625+ skip_threshold: self.skip_threshold,
626626+ },
627627+ Effect::PublishSnapshot,
628628+ ]
568629 }
569630570631 /// Pop the next playlist entry: returns the entry at the cursor and advances
···574635 /// Keeping this as a separate step (helper + event) means the state machine
575636 /// doesn't need a "draw from playlist" event that mutates two fields in one go.
576637 pub(crate) fn next_playlist_entry(&mut self) -> Option<PlaylistEntry> {
577577- if !self.playlist_enabled {
638638+ if !self.autoplay_enabled {
578639 return None;
579640 }
580641 let entry = self.playlist.get(self.playlist_cursor)?.clone();
···695756 // a non-empty playlist as the user's intent to autoplay).
696757 state.playlist = snapshot.playlist;
697758 if !state.playlist.is_empty() {
698698- state.playlist_enabled = true;
759759+ state.autoplay_enabled = true;
699760 }
700761701762 if !effects.is_empty() {
···15011562 let e = playlist_entry(1, "https://example.com/p1");
15021563 let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0);
15031564 assert_eq!(s.playlist.len(), 1);
15041504- assert!(s.playlist_enabled);
15651565+ assert!(s.autoplay_enabled);
15051566 assert!(effects
15061567 .iter()
15071568 .any(|e| matches!(e, Effect::AddPlaylistEntry { .. })));
···15601621 let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 1));
15611622 s.transition(&Event::PlaylistEntryRemoved(id), 0);
15621623 assert!(s.playlist.is_empty());
15631563- assert!(!s.playlist_enabled);
16241624+ assert!(!s.autoplay_enabled);
15641625 }
1565162615661627 #[test]
15671567- fn test_set_playlist_enabled_noop_when_same() {
16281628+ fn test_set_autoplay_enabled_noop_when_same() {
15681629 let mut s = PlaybackState {
15691569- playlist_enabled: true,
16301630+ autoplay_enabled: true,
15701631 playlist: vec![playlist_entry(1, "https://example.com/a")],
15711632 ..PlaybackState::default()
15721633 };
15731573- let effects = s.transition(&Event::SetPlaylistEnabled(true), 0);
16341634+ let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0);
15741635 assert!(effects.is_empty());
15751636 }
1576163715771638 #[test]
15781578- fn test_set_playlist_enabled_disable_is_quiet() {
16391639+ fn test_set_autoplay_enabled_disable_persists_and_publishes() {
15791640 let mut s = PlaybackState {
15801580- playlist_enabled: true,
16411641+ autoplay_enabled: true,
15811642 playlist: vec![playlist_entry(1, "https://example.com/a")],
15821643 ..PlaybackState::default()
15831644 };
15841584- let effects = s.transition(&Event::SetPlaylistEnabled(false), 0);
15851585- assert!(!s.playlist_enabled);
15861586- assert!(effects.is_empty());
16451645+ let effects = s.transition(&Event::SetAutoplay { enabled: false }, 0);
16461646+ assert!(!s.autoplay_enabled);
16471647+ // Disable is a real state change: persist + publish.
16481648+ assert!(effects.iter().any(|e| matches!(
16491649+ e,
16501650+ Effect::PersistRoomSettings {
16511651+ autoplay_enabled: false,
16521652+ ..
16531653+ }
16541654+ )));
16551655+ assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
15871656 }
1588165715891658 #[test]
15901590- fn test_set_playlist_enabled_enable_publishes() {
16591659+ fn test_set_autoplay_enabled_enable_persists_and_publishes() {
15911660 let mut s = PlaybackState {
15921592- playlist_enabled: false,
16611661+ autoplay_enabled: false,
15931662 playlist: vec![playlist_entry(1, "https://example.com/a")],
15941663 ..PlaybackState::default()
15951664 };
15961596- let effects = s.transition(&Event::SetPlaylistEnabled(true), 0);
15971597- assert!(s.playlist_enabled);
16651665+ let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0);
16661666+ assert!(s.autoplay_enabled);
16671667+ assert!(effects.iter().any(|e| matches!(
16681668+ e,
16691669+ Effect::PersistRoomSettings {
16701670+ autoplay_enabled: true,
16711671+ ..
16721672+ }
16731673+ )));
16741674+ assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
16751675+ }
16761676+16771677+ #[test]
16781678+ fn test_set_skip_threshold_persists_and_publishes() {
16791679+ let mut s = PlaybackState::default();
16801680+ let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0);
16811681+ assert_eq!(s.skip_threshold, 50);
16821682+ assert!(effects.iter().any(|e| matches!(
16831683+ e,
16841684+ Effect::PersistRoomSettings {
16851685+ skip_threshold: 50,
16861686+ ..
16871687+ }
16881688+ )));
15981689 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot)));
16901690+ }
16911691+16921692+ #[test]
16931693+ fn test_set_skip_threshold_noop_when_same() {
16941694+ let mut s = PlaybackState {
16951695+ skip_threshold: 50,
16961696+ ..PlaybackState::default()
16971697+ };
16981698+ let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0);
16991699+ assert!(effects.is_empty());
17001700+ }
17011701+17021702+ #[test]
17031703+ fn test_default_autoplay_enabled() {
17041704+ let s = PlaybackState::default();
17051705+ assert!(s.autoplay_enabled, "default is on for new rooms");
17061706+ assert_eq!(s.skip_threshold, 34, "default threshold is 34%");
15991707 }
1600170816011709 // --- playlist cache-fill on add / DownloadResolved ---
···17371845 }
1738184617391847 #[test]
17401740- fn test_set_playlist_enabled_re_enable_empty_noop() {
18481848+ fn test_set_autoplay_enabled_re_enable_empty_noop() {
17411849 let mut s = PlaybackState {
17421742- playlist_enabled: false,
18501850+ autoplay_enabled: false,
17431851 ..PlaybackState::default()
17441852 };
17451745- let effects = s.transition(&Event::SetPlaylistEnabled(true), 0);
17461746- assert!(!s.playlist_enabled);
18531853+ let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0);
18541854+ assert!(!s.autoplay_enabled);
17471855 assert!(effects.is_empty());
17481856 }
1749185717501858 #[test]
17511859 fn test_next_playlist_entry_disabled_returns_none() {
17521860 let mut s = PlaybackState {
17531753- playlist_enabled: false,
18611861+ autoplay_enabled: false,
17541862 playlist: vec![playlist_entry(1, "https://example.com/a")],
17551863 ..PlaybackState::default()
17561864 };
···17601868 #[test]
17611869 fn test_next_playlist_entry_advances_cursor() {
17621870 let mut s = PlaybackState {
17631763- playlist_enabled: true,
18711871+ autoplay_enabled: true,
17641872 playlist: vec![
17651873 playlist_entry(1, "https://example.com/a"),
17661874 playlist_entry(2, "https://example.com/b"),
···24882596 | Effect::PersistFinished(_)
24892597 | Effect::PersistQueuedTrack(_)
24902598 | Effect::PersistMetadata { .. }
25992599+ | Effect::PersistRoomSettings { .. }
24912600 | Effect::AbortDownload
24922601 )
24932602 });
···26392748 | Effect::RemovePlaylistEntry(_)
26402749 | Effect::ReorderQueue { .. }
26412750 | Effect::RemoveQueuedTrack { .. }
27512751+ | Effect::PersistRoomSettings { .. }
26422752 )
26432753 }
26442754
+1-1
src/store.rs
···9292 /// Retrieve the most recent chat messages for a room, newest first.
9393 #[allow(dead_code)]
9494 async fn recent_chat(&self, room_id: &str, limit: i64)
9595- -> Result<Vec<ChatMessage>, sqlx::Error>;
9595+ -> Result<Vec<ChatMessage>, sqlx::Error>;
96969797 /// Delete a room and all its associated data (CASCADE).
9898 #[allow(dead_code)]