experiments of a tiny cytube-like player with yt-dlp
0

Configure Feed

Select the types of activity you want to include in your feed.

state: room settings in PlaybackState (autoplay + skip_threshold)

karitham (Jun 1, 2026, 3:44 PM +0200) d88da9c6 1c02355b

+194 -57
+33 -13
src/room.rs
··· 59 59 RemovePlaylistEntry { 60 60 id: PlaylistEntryId, 61 61 }, 62 - SetPlaylistEnabled(bool), 62 + SetAutoplay { 63 + enabled: bool, 64 + }, 65 + SetSkipThreshold { 66 + percent: u8, 67 + }, 63 68 PublishState, 64 69 Shutdown, 65 70 MoveQueueItem { ··· 402 407 self.handle_add_playlist_entry(url, added_by).await 403 408 } 404 409 RoomCommand::RemovePlaylistEntry { id } => self.handle_remove_playlist_entry(id).await, 405 - RoomCommand::SetPlaylistEnabled(enabled) => { 406 - self.handle_set_playlist_enabled(enabled).await 410 + RoomCommand::SetAutoplay { enabled } => self.handle_set_autoplay(enabled).await, 411 + RoomCommand::SetSkipThreshold { percent } => { 412 + self.handle_set_skip_threshold(percent).await 407 413 } 408 414 RoomCommand::PublishState => self.handle_publish_state().await, 409 415 RoomCommand::Shutdown => self.handle_shutdown().await, ··· 672 678 false 673 679 } 674 680 675 - /// Toggle the perpetual playlist on/off. The state machine emits 676 - /// `PublishSnapshot` only when enabling (so clients see the toggle); 677 - /// disabling is a no-op effect-wise since the room will go idle and 678 - /// the next snapshot reflects that. 679 - async fn handle_set_playlist_enabled(&mut self, enabled: bool) -> bool { 681 + /// Toggle the room's auto-play setting. The state machine decides 682 + /// whether the change is observable (no-op if same value, or if 683 + /// re-enabling an empty playlist). 684 + async fn handle_set_autoplay(&mut self, enabled: bool) -> bool { 685 + let effects = self.state.transition(&Event::SetAutoplay { enabled }, 0); 686 + if effects.is_empty() { 687 + return false; 688 + } 689 + self.persist_effects(&effects).await; 690 + self.execute_effects(effects).await; 691 + false 692 + } 693 + 694 + /// Set the skip-vote threshold. Persists and publishes when the 695 + /// value actually changes. 696 + async fn handle_set_skip_threshold(&mut self, percent: u8) -> bool { 680 697 let effects = self 681 698 .state 682 - .transition(&Event::SetPlaylistEnabled(enabled), 0); 699 + .transition(&Event::SetSkipThreshold { percent }, 0); 683 700 if effects.is_empty() { 684 701 return false; 685 702 } 703 + self.persist_effects(&effects).await; 686 704 self.execute_effects(effects).await; 687 705 false 688 706 } ··· 927 945 "queue": queue, 928 946 "history": history, 929 947 "playlist": self.state.playlist, 930 - "playlist_enabled": self.state.playlist_enabled, 948 + "autoplay_enabled": self.state.autoplay_enabled, 949 + "skip_threshold": self.state.skip_threshold, 931 950 "clients": client_count, 932 951 }); 933 952 self.publishers.state_tx.send_replace(snapshot); ··· 1099 1118 #[test] 1100 1119 fn test_next_playlist_entry_disabled_returns_none() { 1101 1120 let mut state = PlaybackState { 1121 + autoplay_enabled: false, 1102 1122 playlist: vec![PlaylistEntry { 1103 1123 id: pid(1), 1104 1124 url: "https://example.com/p".into(), ··· 1112 1132 }], 1113 1133 ..PlaybackState::default() 1114 1134 }; 1115 - // Default is disabled; no entries drawn until the user enables it. 1135 + // When autoplay is explicitly disabled, no entries drawn. 1116 1136 assert!(state.next_playlist_entry().is_none()); 1117 1137 } 1118 1138 1119 1139 #[test] 1120 1140 fn test_next_playlist_entry_returns_and_advances_when_idle_and_enabled() { 1121 1141 let mut state = PlaybackState { 1122 - playlist_enabled: true, 1142 + autoplay_enabled: true, 1123 1143 playlist: vec![PlaylistEntry { 1124 1144 id: pid(1), 1125 1145 url: "https://example.com/p".into(), ··· 1142 1162 #[test] 1143 1163 fn test_next_playlist_entry_wraps_around() { 1144 1164 let mut state = PlaybackState { 1145 - playlist_enabled: true, 1165 + autoplay_enabled: true, 1146 1166 playlist: vec![ 1147 1167 PlaylistEntry { 1148 1168 id: pid(1),
+150 -40
src/state.rs
··· 88 88 PlaylistEntryAdded(PlaylistEntry), 89 89 /// Remove a perpetual playlist entry by id. 90 90 PlaylistEntryRemoved(PlaylistEntryId), 91 - /// Toggle the perpetual playlist on/off. 92 - SetPlaylistEnabled(bool), 93 91 /// Reorder a track within the up-next queue. 94 92 /// `from` and `to` are 0-based queue indices. `from == to` is a no-op. 95 93 /// Indices out of range or queue empty are no-ops. ··· 100 98 /// Remove a track from the up-next queue. No-op if the track isn't 101 99 /// queued (active and history are unaffected). 102 100 RemoveQueueItem { track_id: TrackId }, 101 + /// Toggle the room's auto-play setting. When `true`, idle rooms 102 + /// auto-fill from the perpetual playlist (also surfaced as the 103 + /// playlist panel in the UI). 104 + SetAutoplay { enabled: bool }, 105 + /// Set the skip-vote threshold as a percentage of the room size 106 + /// (0..=100). `votes * 100 >= threshold * count` triggers a skip. 107 + /// `percent > 100` is clamped to 100; values outside 0..=100 are no-ops. 108 + SetSkipThreshold { percent: u8 }, 103 109 } 104 110 105 111 /// Side effects to execute after a transition. ··· 154 160 /// `PublishSnapshot` after the state machine removes the track from 155 161 /// its in-memory `queue` Vec. 156 162 RemoveQueuedTrack { track_id: TrackId }, 163 + /// Persist a change to the room's autoplay setting or skip-vote 164 + /// threshold. Carried together so the actor writes both columns in 165 + /// one UPDATE. 166 + PersistRoomSettings { 167 + autoplay_enabled: bool, 168 + skip_threshold: u8, 169 + }, 157 170 } 158 171 159 172 /// Pure playback state: no IO handles, no DB connections. 160 - #[derive(Debug, Clone, Default)] 173 + #[derive(Debug, Clone)] 161 174 pub(crate) struct PlaybackState { 162 175 /// Currently playing track, if any. 163 176 pub active: Option<ActiveTrackInfo>, ··· 173 186 pub playlist: Vec<PlaylistEntry>, 174 187 /// Index of the next playlist entry to draw from (`next_playlist_entry`). 175 188 pub playlist_cursor: usize, 176 - /// When false, the perpetual playlist is paused; the room will not auto-fill. 177 - pub playlist_enabled: bool, 189 + /// When true, the room auto-fills from the playlist when idle (and 190 + /// on client connect, as an actor-level trigger). Also gates the 191 + /// playlist panel in the UI. 192 + pub autoplay_enabled: bool, 193 + /// Skip-vote threshold as a percentage of the room size 194 + /// (0..=100). `votes * 100 >= threshold * count` triggers a skip 195 + /// (the actor counts votes; the state machine just stores this). 196 + pub skip_threshold: u8, 197 + } 198 + 199 + /// Defaults for new rooms. `autoplay_enabled` and `skip_threshold` are 200 + /// the schema's column defaults too — keep them aligned. 201 + const DEFAULT_AUTOPLAY_ENABLED: bool = true; 202 + const DEFAULT_SKIP_THRESHOLD: u8 = 34; 203 + 204 + impl Default for PlaybackState { 205 + fn default() -> Self { 206 + Self { 207 + active: None, 208 + queue: Vec::new(), 209 + history: Vec::new(), 210 + media_cache: HashMap::new(), 211 + playlist: Vec::new(), 212 + playlist_cursor: 0, 213 + autoplay_enabled: DEFAULT_AUTOPLAY_ENABLED, 214 + skip_threshold: DEFAULT_SKIP_THRESHOLD, 215 + } 216 + } 178 217 } 179 218 180 219 impl PlaybackState { ··· 207 246 Event::TrackQueuedFromPlaylist(track, _source_index) => self.handle_queued(track), 208 247 Event::PlaylistEntryAdded(entry) => self.handle_playlist_entry_added(entry), 209 248 Event::PlaylistEntryRemoved(id) => self.handle_playlist_entry_removed(*id), 210 - Event::SetPlaylistEnabled(enabled) => self.handle_set_playlist_enabled(*enabled), 249 + Event::SetAutoplay { enabled } => self.handle_set_autoplay(*enabled), 250 + Event::SetSkipThreshold { percent } => self.handle_set_skip_threshold(*percent), 211 251 Event::MoveQueueItem { from, to } => self.handle_move_queue_item(*from, *to), 212 252 Event::ShuffleQueue { seed } => self.handle_shuffle_queue(*seed), 213 253 Event::RemoveQueueItem { track_id } => self.handle_remove_queue_item(*track_id), ··· 515 555 self.playlist.push(entry.clone()); 516 556 } 517 557 // Enable by default when the first entry is added. 518 - if !self.playlist_enabled { 519 - self.playlist_enabled = true; 558 + if !self.autoplay_enabled { 559 + self.autoplay_enabled = true; 520 560 } 521 561 vec![ 522 562 Effect::AddPlaylistEntry { ··· 540 580 self.playlist_cursor = self.playlist.len() - 1; 541 581 } 542 582 if self.playlist.is_empty() { 543 - self.playlist_enabled = false; 583 + self.autoplay_enabled = false; 544 584 } 545 585 return vec![Effect::RemovePlaylistEntry(id), Effect::PublishSnapshot]; 546 586 } ··· 549 589 550 590 /// Toggle the perpetual playlist on/off. Disabling is always a no-op for state. 551 591 /// Re-enabling only takes effect if there's something to play. 552 - fn handle_set_playlist_enabled(&mut self, enabled: bool) -> Vec<Effect> { 592 + fn handle_set_autoplay(&mut self, enabled: bool) -> Vec<Effect> { 553 593 if enabled && self.playlist.is_empty() { 554 594 // Re-enabling an empty playlist: leave it disabled, no-op. 555 595 return Vec::new(); 556 596 } 557 - if self.playlist_enabled == enabled { 597 + if self.autoplay_enabled == enabled { 558 598 return Vec::new(); 559 599 } 560 - self.playlist_enabled = enabled; 561 - // Don't publish on disable: it has no observable effect until something 562 - // else triggers a snapshot. Publish on enable so clients see the toggle. 563 - if enabled { 564 - vec![Effect::PublishSnapshot] 565 - } else { 566 - Vec::new() 600 + self.autoplay_enabled = enabled; 601 + // Persist the new settings + publish so clients see the toggle. 602 + // (The empty-Playlist re-enable guard above means we never 603 + // reach here when the playlist is empty, so this fires only 604 + // for a real state change.) 605 + vec![ 606 + Effect::PersistRoomSettings { 607 + autoplay_enabled: self.autoplay_enabled, 608 + skip_threshold: self.skip_threshold, 609 + }, 610 + Effect::PublishSnapshot, 611 + ] 612 + } 613 + 614 + /// Set the skip-vote threshold as a percentage of room size. Out of 615 + /// range values are clamped or no-op (0 means any single vote skips; 616 + /// 100 means unanimous). Persists + publishes on change. 617 + fn handle_set_skip_threshold(&mut self, percent: u8) -> Vec<Effect> { 618 + if self.skip_threshold == percent { 619 + return Vec::new(); 567 620 } 621 + self.skip_threshold = percent; 622 + vec![ 623 + Effect::PersistRoomSettings { 624 + autoplay_enabled: self.autoplay_enabled, 625 + skip_threshold: self.skip_threshold, 626 + }, 627 + Effect::PublishSnapshot, 628 + ] 568 629 } 569 630 570 631 /// Pop the next playlist entry: returns the entry at the cursor and advances ··· 574 635 /// Keeping this as a separate step (helper + event) means the state machine 575 636 /// doesn't need a "draw from playlist" event that mutates two fields in one go. 576 637 pub(crate) fn next_playlist_entry(&mut self) -> Option<PlaylistEntry> { 577 - if !self.playlist_enabled { 638 + if !self.autoplay_enabled { 578 639 return None; 579 640 } 580 641 let entry = self.playlist.get(self.playlist_cursor)?.clone(); ··· 695 756 // a non-empty playlist as the user's intent to autoplay). 696 757 state.playlist = snapshot.playlist; 697 758 if !state.playlist.is_empty() { 698 - state.playlist_enabled = true; 759 + state.autoplay_enabled = true; 699 760 } 700 761 701 762 if !effects.is_empty() { ··· 1501 1562 let e = playlist_entry(1, "https://example.com/p1"); 1502 1563 let effects = s.transition(&Event::PlaylistEntryAdded(e.clone()), 0); 1503 1564 assert_eq!(s.playlist.len(), 1); 1504 - assert!(s.playlist_enabled); 1565 + assert!(s.autoplay_enabled); 1505 1566 assert!(effects 1506 1567 .iter() 1507 1568 .any(|e| matches!(e, Effect::AddPlaylistEntry { .. }))); ··· 1560 1621 let id = PlaylistEntryId(uuid::Uuid::from_u64_pair(0, 1)); 1561 1622 s.transition(&Event::PlaylistEntryRemoved(id), 0); 1562 1623 assert!(s.playlist.is_empty()); 1563 - assert!(!s.playlist_enabled); 1624 + assert!(!s.autoplay_enabled); 1564 1625 } 1565 1626 1566 1627 #[test] 1567 - fn test_set_playlist_enabled_noop_when_same() { 1628 + fn test_set_autoplay_enabled_noop_when_same() { 1568 1629 let mut s = PlaybackState { 1569 - playlist_enabled: true, 1630 + autoplay_enabled: true, 1570 1631 playlist: vec![playlist_entry(1, "https://example.com/a")], 1571 1632 ..PlaybackState::default() 1572 1633 }; 1573 - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); 1634 + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); 1574 1635 assert!(effects.is_empty()); 1575 1636 } 1576 1637 1577 1638 #[test] 1578 - fn test_set_playlist_enabled_disable_is_quiet() { 1639 + fn test_set_autoplay_enabled_disable_persists_and_publishes() { 1579 1640 let mut s = PlaybackState { 1580 - playlist_enabled: true, 1641 + autoplay_enabled: true, 1581 1642 playlist: vec![playlist_entry(1, "https://example.com/a")], 1582 1643 ..PlaybackState::default() 1583 1644 }; 1584 - let effects = s.transition(&Event::SetPlaylistEnabled(false), 0); 1585 - assert!(!s.playlist_enabled); 1586 - assert!(effects.is_empty()); 1645 + let effects = s.transition(&Event::SetAutoplay { enabled: false }, 0); 1646 + assert!(!s.autoplay_enabled); 1647 + // Disable is a real state change: persist + publish. 1648 + assert!(effects.iter().any(|e| matches!( 1649 + e, 1650 + Effect::PersistRoomSettings { 1651 + autoplay_enabled: false, 1652 + .. 1653 + } 1654 + ))); 1655 + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1587 1656 } 1588 1657 1589 1658 #[test] 1590 - fn test_set_playlist_enabled_enable_publishes() { 1659 + fn test_set_autoplay_enabled_enable_persists_and_publishes() { 1591 1660 let mut s = PlaybackState { 1592 - playlist_enabled: false, 1661 + autoplay_enabled: false, 1593 1662 playlist: vec![playlist_entry(1, "https://example.com/a")], 1594 1663 ..PlaybackState::default() 1595 1664 }; 1596 - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); 1597 - assert!(s.playlist_enabled); 1665 + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); 1666 + assert!(s.autoplay_enabled); 1667 + assert!(effects.iter().any(|e| matches!( 1668 + e, 1669 + Effect::PersistRoomSettings { 1670 + autoplay_enabled: true, 1671 + .. 1672 + } 1673 + ))); 1674 + assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1675 + } 1676 + 1677 + #[test] 1678 + fn test_set_skip_threshold_persists_and_publishes() { 1679 + let mut s = PlaybackState::default(); 1680 + let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); 1681 + assert_eq!(s.skip_threshold, 50); 1682 + assert!(effects.iter().any(|e| matches!( 1683 + e, 1684 + Effect::PersistRoomSettings { 1685 + skip_threshold: 50, 1686 + .. 1687 + } 1688 + ))); 1598 1689 assert!(effects.iter().any(|e| matches!(e, Effect::PublishSnapshot))); 1690 + } 1691 + 1692 + #[test] 1693 + fn test_set_skip_threshold_noop_when_same() { 1694 + let mut s = PlaybackState { 1695 + skip_threshold: 50, 1696 + ..PlaybackState::default() 1697 + }; 1698 + let effects = s.transition(&Event::SetSkipThreshold { percent: 50 }, 0); 1699 + assert!(effects.is_empty()); 1700 + } 1701 + 1702 + #[test] 1703 + fn test_default_autoplay_enabled() { 1704 + let s = PlaybackState::default(); 1705 + assert!(s.autoplay_enabled, "default is on for new rooms"); 1706 + assert_eq!(s.skip_threshold, 34, "default threshold is 34%"); 1599 1707 } 1600 1708 1601 1709 // --- playlist cache-fill on add / DownloadResolved --- ··· 1737 1845 } 1738 1846 1739 1847 #[test] 1740 - fn test_set_playlist_enabled_re_enable_empty_noop() { 1848 + fn test_set_autoplay_enabled_re_enable_empty_noop() { 1741 1849 let mut s = PlaybackState { 1742 - playlist_enabled: false, 1850 + autoplay_enabled: false, 1743 1851 ..PlaybackState::default() 1744 1852 }; 1745 - let effects = s.transition(&Event::SetPlaylistEnabled(true), 0); 1746 - assert!(!s.playlist_enabled); 1853 + let effects = s.transition(&Event::SetAutoplay { enabled: true }, 0); 1854 + assert!(!s.autoplay_enabled); 1747 1855 assert!(effects.is_empty()); 1748 1856 } 1749 1857 1750 1858 #[test] 1751 1859 fn test_next_playlist_entry_disabled_returns_none() { 1752 1860 let mut s = PlaybackState { 1753 - playlist_enabled: false, 1861 + autoplay_enabled: false, 1754 1862 playlist: vec![playlist_entry(1, "https://example.com/a")], 1755 1863 ..PlaybackState::default() 1756 1864 }; ··· 1760 1868 #[test] 1761 1869 fn test_next_playlist_entry_advances_cursor() { 1762 1870 let mut s = PlaybackState { 1763 - playlist_enabled: true, 1871 + autoplay_enabled: true, 1764 1872 playlist: vec![ 1765 1873 playlist_entry(1, "https://example.com/a"), 1766 1874 playlist_entry(2, "https://example.com/b"), ··· 2488 2596 | Effect::PersistFinished(_) 2489 2597 | Effect::PersistQueuedTrack(_) 2490 2598 | Effect::PersistMetadata { .. } 2599 + | Effect::PersistRoomSettings { .. } 2491 2600 | Effect::AbortDownload 2492 2601 ) 2493 2602 }); ··· 2639 2748 | Effect::RemovePlaylistEntry(_) 2640 2749 | Effect::ReorderQueue { .. } 2641 2750 | Effect::RemoveQueuedTrack { .. } 2751 + | Effect::PersistRoomSettings { .. } 2642 2752 ) 2643 2753 } 2644 2754
+1 -1
src/store.rs
··· 92 92 /// Retrieve the most recent chat messages for a room, newest first. 93 93 #[allow(dead_code)] 94 94 async fn recent_chat(&self, room_id: &str, limit: i64) 95 - -> Result<Vec<ChatMessage>, sqlx::Error>; 95 + -> Result<Vec<ChatMessage>, sqlx::Error>; 96 96 97 97 /// Delete a room and all its associated data (CASCADE). 98 98 #[allow(dead_code)]
+10 -3
src/transport.rs
··· 33 33 TrackEnded { 34 34 item_id: TrackId, 35 35 }, 36 - SetPlaylistEnabled { 36 + SetAutoplay { 37 37 enabled: bool, 38 + }, 39 + SetSkipThreshold { 40 + percent: u8, 38 41 }, 39 42 Ping, 40 43 /// Move a queue item from one position to another. 0-indexed. ··· 140 143 WsClientMessage::TrackEnded { item_id } => { 141 144 let _ = cmd_tx.send(RoomCommand::TrackEnded { item_id }).await; 142 145 } 143 - WsClientMessage::SetPlaylistEnabled { enabled } => { 144 - let _ = cmd_tx.send(RoomCommand::SetPlaylistEnabled(enabled)).await; 146 + WsClientMessage::SetAutoplay { enabled } => { 147 + let _ = cmd_tx.send(RoomCommand::SetAutoplay { enabled }).await; 148 + } 149 + WsClientMessage::SetSkipThreshold { percent } => { 150 + let _ = 151 + cmd_tx.send(RoomCommand::SetSkipThreshold { percent }).await; 145 152 } 146 153 WsClientMessage::Ping => { 147 154 let _ = ws_tx.send(Message::Ping(Bytes::new())).await;