From d88da9c69a97172580b1a48e072c3dab0a0cd1b2 Mon Sep 17 00:00:00 2001 From: karitham Date: Mon, 01 Jun 2026 13:44:57 +0000 Subject: [PATCH] state: room settings in PlaybackState (autoplay + skip_threshold) --- src/room.rs | 50 +++++++++++++++++++++++++++++++++++--------------- src/state.rs | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------- src/store.rs | 2 +- src/transport.rs | 13 ++++++++++--- 4 file(s) changed, 203 insertion(s)(+), 66 deletion(s)(-) diff --git a/src/room.rs b/src/room.rs --- a/src/room.rs +++ b/src/room.rs @@ -59,7 +59,12 @@ RemovePlaylistEntry { id: PlaylistEntryId, }, - SetPlaylistEnabled(bool), + SetAutoplay { + enabled: bool, + }, + SetSkipThreshold { + percent: u8, + }, PublishState, Shutdown, MoveQueueItem { @@ -402,8 +407,9 @@ self.handle_add_playlist_entry(url, added_by).await } RoomCommand::RemovePlaylistEntry { id } => self.handle_remove_playlist_entry(id).await, - RoomCommand::SetPlaylistEnabled(enabled) => { - self.handle_set_playlist_enabled(enabled).await + RoomCommand::SetAutoplay { enabled } => self.handle_set_autoplay(enabled).await, + RoomCommand::SetSkipThreshold { percent } => { + self.handle_set_skip_threshold(percent).await } RoomCommand::PublishState => self.handle_publish_state().await, RoomCommand::Shutdown => self.handle_shutdown().await, @@ -672,17 +678,29 @@ false } - /// Toggle the perpetual playlist on/off. The state machine emits - /// `PublishSnapshot` only when enabling (so clients see the toggle); - /// disabling is a no-op effect-wise since the room will go idle and - /// the next snapshot reflects that. - async fn handle_set_playlist_enabled(&mut self, enabled: bool) -> bool { - let effects = self - .state - .transition(&Event::SetPlaylistEnabled(enabled), 0); + /// Toggle the room's auto-play setting. The state machine decides + /// whether the change is observable (no-op if same value, or if + /// re-enabling an empty playlist). + async fn handle_set_autoplay(&mut self, enabled: bool) -> bool { + let effects = self.state.transition(&Event::SetAutoplay { enabled }, 0); if effects.is_empty() { return false; } + self.persist_effects(&effects).await; + self.execute_effects(effects).await; + false + } + + /// Set the skip-vote threshold. Persists and publishes when the + /// value actually changes. + async fn handle_set_skip_threshold(&mut self, percent: u8) -> bool { + let effects = self + .state + .transition(&Event::SetSkipThreshold { percent }, 0); + if effects.is_empty() { + return false; + } + self.persist_effects(&effects).await; self.execute_effects(effects).await; false } @@ -927,7 +945,8 @@ "queue": queue, "history": history, "playlist": self.state.playlist, - "playlist_enabled": self.state.playlist_enabled, + "autoplay_enabled": self.state.autoplay_enabled, + "skip_threshold": self.state.skip_threshold, "clients": client_count, }); self.publishers.state_tx.send_replace(snapshot); @@ -1099,6 +1118,7 @@ #[test] fn test_next_playlist_entry_disabled_returns_none() { let mut state = PlaybackState { + autoplay_enabled: false, playlist: vec![PlaylistEntry { id: pid(1), url: "https://example.com/p".into(), @@ -1112,14 +1132,14 @@ }], ..PlaybackState::default() }; - // Default is disabled; no entries drawn until the user enables it. + // When autoplay is explicitly disabled, no entries drawn. assert!(state.next_playlist_entry().is_none()); } #[test] fn test_next_playlist_entry_returns_and_advances_when_idle_and_enabled() { let mut state = PlaybackState { - playlist_enabled: true, + autoplay_enabled: true, playlist: vec![PlaylistEntry { id: pid(1), url: "https://example.com/p".into(), @@ -1142,7 +1162,7 @@ #[test] fn test_next_playlist_entry_wraps_around() { let mut state = PlaybackState { - playlist_enabled: true, + autoplay_enabled: true, playlist: vec![ PlaylistEntry { id: pid(1), diff --git a/src/state.rs b/src/state.rs --- a/src/state.rs +++ b/src/state.rs @@ -88,8 +88,6 @@ PlaylistEntryAdded(PlaylistEntry), /// Remove a perpetual playlist entry by id. PlaylistEntryRemoved(PlaylistEntryId), - /// Toggle the perpetual playlist on/off. - SetPlaylistEnabled(bool), /// Reorder a track within the up-next queue. /// `from` and `to` are 0-based queue indices. `from == to` is a no-op. /// Indices out of range or queue empty are no-ops. @@ -100,6 +98,14 @@ /// Remove a track from the up-next queue. No-op if the track isn't /// queued (active and history are unaffected). RemoveQueueItem { track_id: TrackId }, + /// Toggle the room's auto-play setting. When `true`, idle rooms + /// auto-fill from the perpetual playlist (also surfaced as the + /// playlist panel in the UI). + SetAutoplay { enabled: bool }, + /// Set the skip-vote threshold as a percentage of the room size + /// (0..=100). `votes * 100 >= threshold * count` triggers a skip. + /// `percent > 100` is clamped to 100; values outside 0..=100 are no-ops. + SetSkipThreshold { percent: u8 }, } /// Side effects to execute after a transition. @@ -154,10 +160,17 @@ /// `PublishSnapshot` after the state machine removes the track from /// its in-memory `queue` Vec. RemoveQueuedTrack { track_id: TrackId }, + /// Persist a change to the room's autoplay setting or skip-vote + /// threshold. Carried together so the actor writes both columns in + /// one UPDATE. + PersistRoomSettings { + autoplay_enabled: bool, + skip_threshold: u8, + }, } /// Pure playback state: no IO handles, no DB connections. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub(crate) struct PlaybackState { /// Currently playing track, if any. pub active: Option, @@ -173,8 +186,34 @@ pub playlist: Vec, /// Index of the next playlist entry to draw from (`next_playlist_entry`). pub playlist_cursor: usize, - /// When false, the perpetual playlist is paused; the room will not auto-fill. - pub playlist_enabled: bool, + /// When true, the room auto-fills from the playlist when idle (and + /// on client connect, as an actor-level trigger). Also gates the + /// playlist panel in the UI. + pub autoplay_enabled: bool, + /// Skip-vote threshold as a percentage of the room size + /// (0..=100). `votes * 100 >= threshold * count` triggers a skip + /// (the actor counts votes; the state machine just stores this). + pub skip_threshold: u8, +} + +/// Defaults for new rooms. `autoplay_enabled` and `skip_threshold` are +/// the schema's column defaults too — keep them aligned. +const DEFAULT_AUTOPLAY_ENABLED: bool = true; +const DEFAULT_SKIP_THRESHOLD: u8 = 34; + +impl Default for PlaybackState { + fn default() -> Self { + Self { + active: None, + queue: Vec::new(), + history: Vec::new(), + media_cache: HashMap::new(), + playlist: Vec::new(), + playlist_cursor: 0, + autoplay_enabled: DEFAULT_AUTOPLAY_ENABLED, + skip_threshold: DEFAULT_SKIP_THRESHOLD, + } + } } impl PlaybackState { @@ -207,7 +246,8 @@ Event::TrackQueuedFromPlaylist(track, _source_index) => self.handle_queued(track), Event::PlaylistEntryAdded(entry) => self.handle_playlist_entry_added(entry), Event::PlaylistEntryRemoved(id) => self.handle_playlist_entry_removed(*id), - Event::SetPlaylistEnabled(enabled) => self.handle_set_playlist_enabled(*enabled), + Event::SetAutoplay { enabled } => self.handle_set_autoplay(*enabled), + Event::SetSkipThreshold { percent } => self.handle_set_skip_threshold(*percent), Event::MoveQueueItem { from, to } => self.handle_move_queue_item(*from, *to), Event::ShuffleQueue { seed } => self.handle_shuffle_queue(*seed), Event::RemoveQueueItem { track_id } => self.handle_remove_queue_item(*track_id), @@ -515,8 +555,8 @@ self.playlist.push(entry.clone()); } // Enable by default when the first entry is added. - if !self.playlist_enabled { - self.playlist_enabled = true; + if !self.autoplay_enabled { + self.autoplay_enabled = true; } vec![ Effect::AddPlaylistEntry { @@ -540,7 +580,7 @@ self.playlist_cursor = self.playlist.len() - 1; } if self.playlist.is_empty() { - self.playlist_enabled = false; + self.autoplay_enabled = false; } return vec![Effect::RemovePlaylistEntry(id), Effect::PublishSnapshot]; } @@ -549,22 +589,43 @@ /// Toggle the perpetual playlist on/off. Disabling is always a no-op for state. /// Re-enabling only takes effect if there's something to play. - fn handle_set_playlist_enabled(&mut self, enabled: bool) -> Vec { + fn handle_set_autoplay(&mut self, enabled: bool) -> Vec { if enabled && self.playlist.is_empty() { // Re-enabling an empty playlist: leave it disabled, no-op. return Vec::new(); } - if self.playlist_enabled == enabled { + if self.autoplay_enabled == enabled { return Vec::new(); } - self.playlist_enabled = enabled; - // Don't publish on disable: it has no observable effect until something - // else triggers a snapshot. Publish on enable so clients see the toggle. - if enabled { - vec![Effect::PublishSnapshot] - } else { - Vec::new() + self.autoplay_enabled = enabled; + // Persist the new settings + publish so clients see the toggle. + // (The empty-Playlist re-enable guard above means we never + // reach here when the playlist is empty, so this fires only + // for a real state change.) + vec![ + Effect::PersistRoomSettings { + autoplay_enabled: self.autoplay_enabled, + skip_threshold: self.skip_threshold, + }, + Effect::PublishSnapshot, + ] + } + + /// Set the skip-vote threshold as a percentage of room size. Out of + /// range values are clamped or no-op (0 means any single vote skips; + /// 100 means unanimous). Persists + publishes on change. + fn handle_set_skip_threshold(&mut self, percent: u8) -> Vec { + if self.skip_threshold == percent { + return Vec::new(); } + self.skip_threshold = percent; + vec![ + Effect::PersistRoomSettings { + autoplay_enabled: self.autoplay_enabled, + skip_threshold: self.skip_threshold, + }, + Effect::PublishSnapshot, + ] } /// Pop the next playlist entry: returns the entry at the cursor and advances @@ -574,7 +635,7 @@ /// Keeping this as a separate step (helper + event) means the state machine /// doesn't need a "draw from playlist" event that mutates two fields in one go. pub(crate) fn next_playlist_entry(&mut self) -> Option { - if !self.playlist_enabled { + if !self.autoplay_enabled { return None; } let entry = self.playlist.get(self.playlist_cursor)?.clone(); @@ -695,7 +756,7 @@ // a non-empty playlist as the user's intent to autoplay). state.playlist = snapshot.playlist; if !state.playlist.is_empty() { - state.playlist_enabled = true; + state.autoplay_enabled = true; } if !effects.is_empty() { @@ -1501,7 +1562,7 @@ let e = playlist_entry(1, "https://example.com/p1"); let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0); assert_eq!(s.playlist.len(), 1); - assert!(s.playlist_enabled); + assert!(s.autoplay_enabled); assert!(effects .iter() .any(|e| matches!(e, Effect::AddPlaylistEntry { .. }))); @@ -1560,42 +1621,89 @@ let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 1)); s.transition(&Event::PlaylistEntryRemoved(id), 0); assert!(s.playlist.is_empty()); - assert!(!s.playlist_enabled); + assert!(!s.autoplay_enabled); } #[test] - fn test_set_playlist_enabled_noop_when_same() { + fn test_set_autoplay_enabled_noop_when_same() { let mut s = PlaybackState { - playlist_enabled: true, + autoplay_enabled: true, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); assert!(effects.is_empty()); } #[test] - fn test_set_playlist_enabled_disable_is_quiet() { + fn test_set_autoplay_enabled_disable_persists_and_publishes() { let mut s = PlaybackState { - playlist_enabled: true, + autoplay_enabled: true, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; - let effects = s.transition(&Event::SetPlaylistEnabled(false), 0); - assert!(!s.playlist_enabled); - assert!(effects.is_empty()); - } - - #[test] - fn test_set_playlist_enabled_enable_publishes() { - let mut s = PlaybackState { - playlist_enabled: false, - playlist: vec![playlist_entry(1, "https://example.com/a")], - ..PlaybackState::default() - }; - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); - assert!(s.playlist_enabled); + let effects = s.transition(&Event::SetAutoplay { enabled: false }, 0); + assert!(!s.autoplay_enabled); + // Disable is a real state change: persist + publish. + assert!(effects.iter().any(|e| matches!( + e, + Effect::PersistRoomSettings { + autoplay_enabled: false, + .. + } + ))); assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); + } + + #[test] + fn test_set_autoplay_enabled_enable_persists_and_publishes() { + let mut s = PlaybackState { + autoplay_enabled: false, + playlist: vec![playlist_entry(1, "https://example.com/a")], + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); + assert!(s.autoplay_enabled); + assert!(effects.iter().any(|e| matches!( + e, + Effect::PersistRoomSettings { + autoplay_enabled: true, + .. + } + ))); + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); + } + + #[test] + fn test_set_skip_threshold_persists_and_publishes() { + let mut s = PlaybackState::default(); + let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); + assert_eq!(s.skip_threshold, 50); + assert!(effects.iter().any(|e| matches!( + e, + Effect::PersistRoomSettings { + skip_threshold: 50, + .. + } + ))); + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); + } + + #[test] + fn test_set_skip_threshold_noop_when_same() { + let mut s = PlaybackState { + skip_threshold: 50, + ..PlaybackState::default() + }; + let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); + assert!(effects.is_empty()); + } + + #[test] + fn test_default_autoplay_enabled() { + let s = PlaybackState::default(); + assert!(s.autoplay_enabled, "default is on for new rooms"); + assert_eq!(s.skip_threshold, 34, "default threshold is 34%"); } // --- playlist cache-fill on add / DownloadResolved --- @@ -1737,20 +1845,20 @@ } #[test] - fn test_set_playlist_enabled_re_enable_empty_noop() { + fn test_set_autoplay_enabled_re_enable_empty_noop() { let mut s = PlaybackState { - playlist_enabled: false, + autoplay_enabled: false, ..PlaybackState::default() }; - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); - assert!(!s.playlist_enabled); + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); + assert!(!s.autoplay_enabled); assert!(effects.is_empty()); } #[test] fn test_next_playlist_entry_disabled_returns_none() { let mut s = PlaybackState { - playlist_enabled: false, + autoplay_enabled: false, playlist: vec![playlist_entry(1, "https://example.com/a")], ..PlaybackState::default() }; @@ -1760,7 +1868,7 @@ #[test] fn test_next_playlist_entry_advances_cursor() { let mut s = PlaybackState { - playlist_enabled: true, + autoplay_enabled: true, playlist: vec![ playlist_entry(1, "https://example.com/a"), playlist_entry(2, "https://example.com/b"), @@ -2488,6 +2596,7 @@ | Effect::PersistFinished(_) | Effect::PersistQueuedTrack(_) | Effect::PersistMetadata { .. } + | Effect::PersistRoomSettings { .. } | Effect::AbortDownload ) }); @@ -2639,6 +2748,7 @@ | Effect::RemovePlaylistEntry(_) | Effect::ReorderQueue { .. } | Effect::RemoveQueuedTrack { .. } + | Effect::PersistRoomSettings { .. } ) } diff --git a/src/store.rs b/src/store.rs --- a/src/store.rs +++ b/src/store.rs @@ -92,7 +92,7 @@ /// Retrieve the most recent chat messages for a room, newest first. #[allow(dead_code)] async fn recent_chat(&self, room_id: &str, limit: i64) - -> Result, sqlx::Error>; + -> Result, sqlx::Error>; /// Delete a room and all its associated data (CASCADE). #[allow(dead_code)] diff --git a/src/transport.rs b/src/transport.rs --- a/src/transport.rs +++ b/src/transport.rs @@ -33,8 +33,11 @@ TrackEnded { item_id: TrackId, }, - SetPlaylistEnabled { + SetAutoplay { enabled: bool, + }, + SetSkipThreshold { + percent: u8, }, Ping, /// Move a queue item from one position to another. 0-indexed. @@ -140,8 +143,12 @@ WsClientMessage::TrackEnded { item_id } => { let _ = cmd_tx.send(RoomCommand::TrackEnded { item_id }).await; } - WsClientMessage::SetPlaylistEnabled { enabled } => { - let _ = cmd_tx.send(RoomCommand::SetPlaylistEnabled(enabled)).await; + WsClientMessage::SetAutoplay { enabled } => { + let _ = cmd_tx.send(RoomCommand::SetAutoplay { enabled }).await; + } + WsClientMessage::SetSkipThreshold { percent } => { + let _ = + cmd_tx.send(RoomCommand::SetSkipThreshold { percent }).await; } WsClientMessage::Ping => { let _ = ws_tx.send(Message::Ping(Bytes::new())).await; -- tangled.sh