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.

formatting & cleanup

karitham (May 17, 2026, 11:40 PM +0200) da4905a2 2dc59391

+2151 -2720
-23
Cargo.toml
··· 6 6 license = "MIT OR Apache-2.0" 7 7 8 8 [dependencies] 9 - # ── Async ────────────────────────────────────────────────────────── 10 9 tokio = { version = "1", features = ["full"] } 11 10 anyhow = "1" 12 11 thiserror = "2" 13 - 14 - # ── HTTP / SSR ───────────────────────────────────────────────────── 15 12 axum = { version = "0.8", features = ["ws"] } 16 - tower = "0.5" 17 - tower-http = { version = "0.6", features = ["cors", "trace", "fs", "compression-gzip"] } 18 13 tracing = "0.1" 19 14 tracing-subscriber = { version = "0.3", features = ["env-filter"] } 20 15 serde = { version = "1", features = ["derive"] } 21 16 serde_json = "1" 22 - 23 - # ── Templates ────────────────────────────────────────────────────── 24 17 askama = "0.13" 25 - 26 - # ── CLI ──────────────────────────────────────────────────────────── 27 18 clap = { version = "4", features = ["derive", "env"] } 28 - 29 - # ── WebSocket / MoQ Transport ─────────────────────────────────────── 30 - tokio-tungstenite = "0.24" 31 19 futures-util = "0.3" 32 - 33 - # ── TLS (reserved for future use) ───────────────────────────────────── 34 - rustls = { version = "0.23", features = ["ring"] } 35 - rcgen = "0.13" 36 - 37 - # ── Async ───────────────────────────────────────────────────────── 38 20 async-trait = "0.1" 39 - 40 - # ── Identifiers ──────────────────────────────────────────────────── 41 - uuid = { version = "1", features = ["v4", "serde"] } 42 - 43 - # ── Database ──────────────────────────────────────────────────────── 44 21 sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "chrono"] } 45 22 chrono = { version = "0.4", features = ["serde"] } 46 23 bytes = "1"
+44 -6
README.md
··· 7 7 8 8 One Axum binary serving SSR pages, REST API, and a custom media streaming 9 9 protocol over WebSocket on the same port. Per-room mpsc actor processes commands 10 - (queue, skip, chat, register) sequentially — no locks. Room state persisted to 11 - SQLite (WAL mode). 10 + (queue, skip, chat, register, metadata) sequentially -- no locks. Room state 11 + persisted to SQLite (WAL mode). 12 + 13 + ### Async metadata extraction 14 + 15 + The ingest HTTP handler returns immediately after queuing a URL. Metadata 16 + extraction (yt-dlp) runs in a spawned task within the actor. When extraction 17 + completes, the result is sent back to the actor which updates the DB and pushes 18 + the resolved metadata to clients via the state broadcast channel. 19 + 20 + ```mermaid 21 + sequenceDiagram 22 + participant Client 23 + participant HTTP as HTTP Handler 24 + participant Actor as Room Actor 25 + participant Extract as Extraction Task 26 + 27 + Client->>HTTP: POST /api/ingest 28 + HTTP->>Actor: QueueUrl(url) 29 + HTTP-->>Client: {ok: true} 30 + Actor->>Actor: insert placeholder 31 + Actor->>Extract: spawn extraction 32 + Extract->>Actor: MetadataReady 33 + Actor->>Client: PublishSnapshot via WS 34 + ``` 35 + 36 + ### Playback pipeline 12 37 13 38 yt-dlp resolves URLs to direct stream addresses. FFmpeg transcodes to fragmented 14 39 MP4 at real-time speed (`-re`). fMP4 boxes are read from stdout as moof+mdat 15 40 pairs and published over per-room broadcast channels. The video init segment 16 - (ftyp+moov) is cached separately. Forward tasks send cached init first, then 17 - catch up to the broadcast live edge before forwarding, so late joiners start at 18 - the live position. 41 + (ftyp+moov) is cached for late-joining clients. 42 + 43 + ### State machine 44 + 45 + `PlaybackState` is pure -- all transitions are deterministic. The actor follows 46 + the impure-pure-impure sandwich: 47 + 48 + 1. **Gather** -- DB queries, wall clock 49 + 2. **Transition** -- `PlaybackState::transition(event, now)` -- returns `Effect`s 50 + 3. **Commit** -- actor executes effects 51 + 52 + Events: `TrackQueued`, `Skip`, `TrackEnded`, `MetadataUpdated`. 53 + Effects: `AbortPipeline`, `StartPipeline`, `PersistStarted`, `PersistFinished`, `PublishSnapshot`. 54 + 55 + ### Client delivery 19 56 20 57 Clients use MSE with a single SourceBuffer in segments mode. A state track 21 58 (watch channel) delivers current playback position, queue, and history as JSON. 22 - Chat is persisted to SQLite, replayed on subscribe (last N), then live. 59 + Queue items carry a `pending` flag while metadata is unresolved. Chat is 60 + persisted to SQLite, replayed on subscribe, then live.
+3 -4
flake.nix
··· 55 55 cargo-edit 56 56 cargo-audit 57 57 cargo-nextest 58 - cargo-expand 59 - cargo-insta 60 - cargo-deny 58 + treefmt 59 + nixfmt 61 60 yt-dlp 62 61 ffmpeg 63 62 openssl ··· 65 64 ]; 66 65 }; 67 66 68 - formatter = pkgs.nixfmt; 67 + formatter = pkgs.treefmt; 69 68 }; 70 69 }; 71 70 }
+1
rustfmt.toml
··· 1 + edition = "2024"
+19 -19
src/db.rs
··· 9 9 pub(crate) async fn create_pool(path: &Path) -> Result<Pool<Sqlite>, sqlx::Error> { 10 10 let conn_str = format!("sqlite:{}?mode=rwc", path.to_string_lossy()); 11 11 let pool = SqlitePoolOptions::new() 12 - .max_connections(8) 12 + .max_connections(8) // sqlite supports unlimited connections but 8 avoids contention 13 13 .connect(&conn_str) 14 14 .await?; 15 15 ··· 26 26 Ok(pool) 27 27 } 28 28 29 - /// Run all migrations (idempotent — uses `CREATE TABLE IF NOT EXISTS`). 29 + /// Run all migrations. Idempotent: uses `CREATE TABLE IF NOT EXISTS`. 30 30 pub(crate) async fn run_migrations(pool: &Pool<Sqlite>) -> Result<(), sqlx::Error> { 31 31 sqlx::query( 32 32 "CREATE TABLE IF NOT EXISTS rooms ( ··· 118 118 } 119 119 120 120 // Migration: add name column to rooms. 121 - let has_name: bool = sqlx::query_scalar( 122 - "SELECT COUNT(*) FROM pragma_table_info('rooms') WHERE name = 'name'", 123 - ) 124 - .fetch_one(pool) 125 - .await 126 - .map(|c: i64| c > 0) 127 - .unwrap_or(false); 121 + let has_name: bool = 122 + sqlx::query_scalar("SELECT COUNT(*) FROM pragma_table_info('rooms') WHERE name = 'name'") 123 + .fetch_one(pool) 124 + .await 125 + .map(|c: i64| c > 0) 126 + .unwrap_or(false); 128 127 129 128 if !has_name { 130 129 sqlx::query("ALTER TABLE rooms ADD COLUMN name TEXT NOT NULL DEFAULT ''") ··· 135 134 Ok(()) 136 135 } 137 136 138 - fn now_iso() -> String { 139 - chrono::Utc::now() 140 - .format("%Y-%m-%dT%H:%M:%S%.3fZ") 141 - .to_string() 142 - } 143 - 144 137 pub(crate) struct NewChatMessage { 145 138 pub(crate) room_id: String, 146 139 pub(crate) user_name: String, ··· 164 157 name: &str, 165 158 persistent: bool, 166 159 ) -> Result<(), sqlx::Error> { 167 - let now = now_iso(); 160 + let now = crate::util::now_iso(); 168 161 sqlx::query( 169 162 "INSERT INTO rooms (id, name, created_at, updated_at, persistent) VALUES (?1, ?2, ?3, ?4, ?5)", 170 163 ) ··· 241 234 #[cfg(test)] 242 235 mod tests { 243 236 use super::*; 237 + use crate::util; 244 238 245 239 async fn setup() -> Pool<Sqlite> { 246 240 let pool = SqlitePoolOptions::new() ··· 267 261 #[tokio::test] 268 262 async fn test_room_crud() { 269 263 let pool = setup().await; 270 - room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 264 + room_insert(&pool, "test-room", "test-name", false) 265 + .await 266 + .unwrap(); 271 267 assert!(room_exists(&pool, "test-room").await.unwrap()); 268 + let name = get_room_name(&pool, "test-room").await; 269 + assert_eq!(name, "test-name"); 272 270 room_delete(&pool, "test-room").await.unwrap(); 273 271 assert!(!room_exists(&pool, "test-room").await.unwrap()); 274 272 } ··· 276 274 #[tokio::test] 277 275 async fn test_chat_crud() { 278 276 let pool = setup().await; 279 - room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 277 + room_insert(&pool, "test-room", "test-name", false) 278 + .await 279 + .unwrap(); 280 280 281 281 for i in 0..5 { 282 282 let msg = NewChatMessage { ··· 284 284 user_name: "alice".into(), 285 285 content: format!("message {i}"), 286 286 msg_type: "message".into(), 287 - created_at: now_iso(), 287 + created_at: util::now_iso(), 288 288 }; 289 289 chat_insert(&pool, &msg).await.unwrap(); 290 290 }
+2 -3
src/main.rs
··· 1 - //! moqbox — Ultra-low latency shared media rooms over MoQ. 1 + //! moqbox: Ultra-low latency shared media rooms over MoQ. 2 2 3 3 #![deny(rust_2018_idioms, unsafe_code)] 4 4 #![cfg_attr(not(test), deny(clippy::unwrap_used))] ··· 13 13 mod state; 14 14 mod transport; 15 15 mod types; 16 + mod util; 16 17 mod web; 17 18 18 19 use clap::Parser; ··· 46 47 47 48 let args = Args::parse(); 48 49 49 - // Ensure cache directory exists. 50 50 tokio::fs::create_dir_all(&args.cache_dir).await?; 51 51 52 52 // Ensure database parent directory exists. ··· 60 60 db::run_migrations(&pool).await?; 61 61 tracing::info!("database ready at {}", args.db_path.display()); 62 62 63 - // ── Source registry ────────────────────────────────────────────── 64 63 let mut sources = source::SourceRegistry::new(); 65 64 sources.register(Box::new(source::ytdlp::YtdlpSource::new())); 66 65 sources.register(Box::new(source::direct::DirectSource::new()));
+26 -78
src/media.rs
··· 4 4 //! real-time speed (`-re`), reads fMP4 boxes from stdout as moof+mdat pairs, 5 5 //! and publishes them over per-room broadcast channels. 6 6 //! 7 - //! This module does **not** resolve URLs or extract metadata — that's the 7 + //! This module does **not** resolve URLs or extract metadata: that is the 8 8 //! responsibility of [`crate::source`]. 9 9 10 10 use tokio::io::AsyncReadExt; 11 11 use tokio::process::Command; 12 12 13 13 use crate::transport::TrackPublishers; 14 - 15 - // --------------------------------------------------------------------------- 16 - // Error 17 - // --------------------------------------------------------------------------- 18 14 19 15 /// Errors that can occur during FFmpeg transcoding. 20 16 #[derive(Debug, thiserror::Error)] ··· 27 23 Aborted, 28 24 } 29 25 30 - // --------------------------------------------------------------------------- 31 - // Public API 32 - // --------------------------------------------------------------------------- 33 - 34 26 /// Transcode a playable stream URL to fMP4, publishing raw boxes as MoQ objects. 35 27 /// 36 28 /// Spawns `ffmpeg` with the stream URL as input, transcodes to fragmented MP4 ··· 39 31 /// can initialise their MediaSource. 40 32 /// 41 33 /// Returns `Ok(())` on normal EOF, `Err` on failure. 42 - /// 43 - /// ## Abort behaviour 44 34 /// 45 35 /// When `abort` is signalled, the pipeline exits early with 46 36 /// [`TranscodeError::Aborted`] and the ffmpeg process is killed. ··· 113 103 // Publishing complete boxes ensures MSE can consume them directly. 114 104 read_and_publish_boxes(&mut stdout, &publishers, &mut abort, group_id).await?; 115 105 116 - // 2. Wait for FFmpeg to exit 117 106 let status = ffmpeg 118 107 .wait() 119 108 .await ··· 128 117 129 118 if !status.success() { 130 119 tracing::warn!("ffmpeg exited with status {status}; stderr: {stderr_output}"); 131 - // Still return Ok — EOF is EOF even if ffmpeg reported an error at the end 120 + // Still return Ok: EOF is EOF even if ffmpeg reported an error at the end 132 121 } 133 122 134 123 tracing::info!(status = %status, stderr_len = %stderr_output.len(), "ffmpeg pipeline finished"); 135 124 136 125 Ok(()) 137 126 } 138 - 139 - // --------------------------------------------------------------------------- 140 - // fMP4 box parsing 141 - // --------------------------------------------------------------------------- 142 127 143 128 /// Read fMP4 boxes from FFmpeg stdout, buffer moof+mdat pairs, and publish 144 129 /// each complete segment as a single MoQ object. ··· 163 148 let mut pending_moof: Option<Vec<u8>> = None; 164 149 165 150 loop { 166 - // Read 8-byte box header 167 151 let mut header = [0u8; 8]; 168 152 tokio::select! { 169 153 _ = abort.changed() => return Err(TranscodeError::Aborted), ··· 229 213 let size = u32::from_be_bytes([header[0], header[1], header[2], header[3]]); 230 214 231 215 if size == 0 { 232 - // Last box — read all remaining data. 216 + // Last box: read all remaining data. 233 217 let mut remaining = Vec::new(); 234 218 reader.read_to_end(&mut remaining).await?; 235 219 let mut full = header.to_vec(); ··· 260 244 full.extend_from_slice(&payload); 261 245 Ok(full) 262 246 } 263 - 264 - // --------------------------------------------------------------------------- 265 - // Formatting 266 - // --------------------------------------------------------------------------- 267 247 268 248 /// Format a duration in seconds to a human-readable `M:SS` or `H:MM:SS` string. 269 249 pub(crate) fn format_duration(total_secs: f64) -> String { ··· 279 259 } 280 260 } 281 261 282 - // --------------------------------------------------------------------------- 283 - // Tests 284 - // --------------------------------------------------------------------------- 285 - 286 262 #[cfg(test)] 287 263 mod tests { 288 264 use super::*; ··· 323 299 let mut slice: &[u8] = &input; 324 300 read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 0) 325 301 .await 326 - .expect("read_and_publish_boxes should succeed on valid fMP4"); 302 + .unwrap(); 327 303 328 - // Init is cached (for late joiners) AND published to broadcast (for track changes). 329 304 let cached = publishers.get_init_segment(); 330 - assert!(cached.is_some(), "init cache should contain ftyp+moov"); 305 + assert!(cached.is_some()); 331 306 let (cached_group, cached_data) = cached.unwrap(); 332 307 assert_eq!(cached_group, 0); 333 308 let mut expected_init = ftyp.clone(); 334 309 expected_init.extend_from_slice(&moov); 335 310 assert_eq!(cached_data.to_vec(), expected_init); 336 311 337 - // Object 0: init (ftyp+moov) published to broadcast. 338 - let init_obj = rx.recv().await.expect("should receive init via broadcast"); 312 + let init_obj = rx.recv().await.unwrap(); 339 313 assert_eq!(init_obj.object_id, 0); 340 314 let mut expected_init_broadcast = ftyp.clone(); 341 315 expected_init_broadcast.extend_from_slice(&moov); 342 316 assert_eq!(init_obj.payload.to_vec(), expected_init_broadcast); 343 317 344 - // Object 1: moof+mdat combined. 345 - let obj1 = rx.recv().await.expect("should receive combined moof+mdat"); 318 + let obj1 = rx.recv().await.unwrap(); 346 319 assert_eq!(obj1.object_id, 1); 347 320 let mut expected_media = moof.clone(); 348 321 expected_media.extend_from_slice(&mdat); ··· 350 323 } 351 324 352 325 #[tokio::test] 353 - async fn test_init_broadcast_and_cache() { 326 + async fn test_init_cache_has_group_id() { 354 327 let ftyp = make_box(b"ftyp", b"iso5"); 355 328 let moov = make_box(b"moov", b"moovdata"); 356 - let moof = make_box(b"moof", b"moofdata"); 357 - let mdat = make_box(b"mdat", b"datadata"); 358 329 359 330 let mut input = Vec::new(); 360 331 input.extend_from_slice(&ftyp); 361 332 input.extend_from_slice(&moov); 362 - input.extend_from_slice(&moof); 363 - input.extend_from_slice(&mdat); 364 333 365 334 let publishers = TrackPublishers::new(); 366 - let mut rx = publishers.video.subscribe(); 367 335 let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 368 336 369 337 let mut slice: &[u8] = &input; 370 - read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 0) 338 + read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 42) 371 339 .await 372 - .expect("read_and_publish_boxes should succeed"); 340 + .unwrap(); 373 341 374 - // Init is both cached and broadcast. 375 342 let cached = publishers.get_init_segment(); 376 - assert!(cached.is_some(), "init cache should contain ftyp+moov"); 377 - 378 - // Object 0: init via broadcast. 379 - let init = rx.recv().await.expect("should receive init via broadcast"); 380 - assert_eq!(init.object_id, 0); 381 - assert_eq!(init.group_id, 0); 382 - let mut expected_init = ftyp.clone(); 383 - expected_init.extend_from_slice(&moov); 384 - assert_eq!(init.payload.to_vec(), expected_init); 385 - 386 - // Object 1: moof+mdat media segment. 387 - let media = rx.recv().await.expect("should receive media segment"); 388 - assert_eq!(media.object_id, 1); 389 - let mut expected_media = moof.clone(); 390 - expected_media.extend_from_slice(&mdat); 391 - assert_eq!(media.payload.to_vec(), expected_media); 343 + assert!(cached.is_some()); 344 + let (group_id, _data) = cached.unwrap(); 345 + assert_eq!(group_id, 42); 392 346 } 393 347 394 348 #[tokio::test] 395 - async fn test_init_cache_has_group_id() { 396 - let ftyp = make_box(b"ftyp", b"iso5"); 397 - let moov = make_box(b"moov", b"moovdata"); 398 - 399 - let mut input = Vec::new(); 400 - input.extend_from_slice(&ftyp); 401 - input.extend_from_slice(&moov); 402 - 349 + async fn test_empty_input_no_crash() { 403 350 let publishers = TrackPublishers::new(); 404 351 let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 405 - 406 - let mut slice: &[u8] = &input; 407 - read_and_publish_boxes(&mut slice, &publishers, &mut abort_rx, 42) 352 + let mut empty: &[u8] = &[]; 353 + read_and_publish_boxes(&mut empty, &publishers, &mut abort_rx, 0) 408 354 .await 409 - .expect("read_and_publish_boxes should succeed"); 355 + .unwrap(); 356 + } 410 357 411 - let cached = publishers.get_init_segment(); 412 - assert!(cached.is_some(), "init should be cached"); 413 - let (group_id, _data) = cached.unwrap(); 414 - assert_eq!( 415 - group_id, 42, 416 - "group_id in cache should match the input group_id" 417 - ); 358 + #[tokio::test] 359 + async fn test_partial_box_returns_error() { 360 + let publishers = TrackPublishers::new(); 361 + let (_tx, mut abort_rx) = tokio::sync::watch::channel(false); 362 + let mut partial: &[u8] = &[0, 0, 0, 100, b'f', b't', b'y', b'p']; 363 + read_and_publish_boxes(&mut partial, &publishers, &mut abort_rx, 0) 364 + .await 365 + .unwrap_err(); 418 366 } 419 367 }
+27
src/names.rs
··· 337 337 "yacht", 338 338 "zebra", 339 339 ]; 340 + 341 + #[cfg(test)] 342 + mod tests { 343 + use super::*; 344 + 345 + #[test] 346 + fn test_generate_room_name_is_deterministic() { 347 + let name1 = generate_room_name("12345"); 348 + let name2 = generate_room_name("12345"); 349 + assert_eq!(name1, name2); 350 + } 351 + 352 + #[test] 353 + fn test_generate_room_name_differs_for_diff_ids() { 354 + let name1 = generate_room_name("11111"); 355 + let name2 = generate_room_name("22222"); 356 + assert_ne!(name1, name2); 357 + } 358 + 359 + #[test] 360 + fn test_generate_room_name_has_hyphen() { 361 + let name = generate_room_name("99999"); 362 + assert!(name.contains('-'), "room name should be adjective-noun"); 363 + let parts: Vec<&str> = name.split('-').collect(); 364 + assert_eq!(parts.len(), 2); 365 + } 366 + }
+16 -61
src/playback.rs
··· 1 1 //! Playback lifecycle management. 2 2 //! 3 3 //! The [`Player`] struct owns the abort handle for the currently-running 4 - //! transcoding pipeline. It does **not** track what's playing — that's the 4 + //! transcoding pipeline. It does **not** track what is playing: that is the 5 5 //! [`PlaybackState`](crate::state::PlaybackState) machine's job. 6 - //! 7 - //! # Lifecycle 8 - //! 9 - //! 1. **Start**: [`Player::start`] receives a fully-formed [`ActiveTrackInfo`], 10 - //! resolves the URL via the source registry, and spawns ffmpeg transcoding. 11 - //! 2. **End**: The spawned task sends [`RoomCommand::TrackEnded`] when ffmpeg 12 - //! finishes or errors. The room actor calls [`Player::on_track_ended`] to 13 - //! acknowledge. 14 - //! 3. **Skip**: [`Player::abort`] signals the transcoding task to stop. 15 6 16 7 use std::path::PathBuf; 17 8 use std::sync::Arc; ··· 26 17 27 18 /// Manages the abort handle for the currently-running transcoding pipeline. 28 19 /// 29 - /// The player is **not** responsible for queue or state management — that 20 + /// The player is **not** responsible for queue or state management: that 30 21 /// belongs to the [`PlaybackState`](crate::state::PlaybackState) machine in the 31 22 /// room actor. 32 23 pub(crate) struct Player { ··· 69 60 let pipeline_tx = self.pipeline_tx.clone(); 70 61 71 62 tokio::spawn(async move { 72 - // 1. Resolve URL to a playable stream. 73 63 let stream_url = match source_registry.resolve(&url, &cache_dir).await { 74 64 Ok(url) => url, 75 65 Err(e) => { ··· 79 69 } 80 70 }; 81 71 82 - // 2. Transcode to fMP4 via ffmpeg. 83 72 let group_id = track_id as u64; 84 73 let result = 85 74 media::transcode_to_fmp4(&stream_url, publishers, abort_rx, group_id).await; ··· 109 98 /// 110 99 /// Returns `true` if `item_id` matched the currently-tracked pipeline, 111 100 /// `false` if it was a stale event from an older pipeline. 101 + #[must_use] 112 102 pub(crate) fn on_track_ended(&mut self, item_id: i64) -> bool { 113 103 if self.track_id == Some(item_id) { 114 104 self.track_id = None; ··· 125 115 tracing::warn!(item_id, error = %e, "player: failed to send TrackEnded"); 126 116 } 127 117 } 128 - 129 - // --------------------------------------------------------------------------- 130 - // Tests 131 - // --------------------------------------------------------------------------- 132 118 133 119 #[cfg(test)] 134 120 mod tests { 135 121 use super::*; 136 - use crate::db; 137 - use crate::playlist::Playlist; 138 - use crate::types::TrackMeta; 139 - use sqlx::sqlite::SqlitePoolOptions; 140 - use sqlx::Pool; 141 122 use std::path::Path; 142 123 use std::sync::Arc; 124 + use std::sync::atomic::{AtomicI64, Ordering}; 143 125 144 126 struct MockPlayerSource; 145 127 ··· 149 131 &self, 150 132 url: &str, 151 133 _cache_dir: &Path, 152 - ) -> Result<TrackMeta, crate::source::SourceError> { 134 + ) -> Result<crate::types::TrackMeta, crate::source::SourceError> { 153 135 Err(crate::source::SourceError::Unsupported(url.into())) 154 136 } 155 137 ··· 168 150 Arc::new(reg) 169 151 } 170 152 171 - async fn test_pool() -> Pool<sqlx::Sqlite> { 172 - let pool = SqlitePoolOptions::new() 173 - .max_connections(1) 174 - .connect(":memory:") 175 - .await 176 - .unwrap(); 177 - db::run_migrations(&pool).await.unwrap(); 178 - db::room_insert(&pool, "test-room", "test-name", false) 179 - .await 180 - .unwrap(); 181 - pool 182 - } 183 - 184 - async fn make_queue_item(pool: &Pool<sqlx::Sqlite>) -> i64 { 185 - let pl = Playlist::new(pool.clone(), "test-room"); 186 - pl.push(&TrackMeta { 187 - title: "Test".into(), 188 - duration: "3:45".into(), 189 - thumbnail: None, 190 - url: "https://example.com/track".into(), 191 - source: crate::types::SourceKind::Direct, 192 - }) 193 - .await 194 - .unwrap() 195 - .id 153 + fn make_item_id() -> i64 { 154 + static NEXT_ID: AtomicI64 = AtomicI64::new(1); 155 + NEXT_ID.fetch_add(1, Ordering::Relaxed) 196 156 } 197 157 198 158 fn test_info(id: i64) -> ActiveTrackInfo { ··· 208 168 209 169 #[tokio::test] 210 170 async fn test_player_start_then_abort() { 211 - let pool = test_pool().await; 212 - let item_id = make_queue_item(&pool).await; 171 + let item_id = make_item_id(); 213 172 let (tx, _rx) = mpsc::channel(256); 214 173 215 174 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); ··· 217 176 218 177 player.abort(); 219 178 220 - // on_track_ended with a stale id should return false. 221 179 assert!(!player.on_track_ended(99999)); 222 180 } 223 181 224 182 #[tokio::test] 225 183 async fn test_player_abort_idempotent() { 226 - let pool = test_pool().await; 227 - let item_id = make_queue_item(&pool).await; 184 + let item_id = make_item_id(); 228 185 let (tx, _rx) = mpsc::channel(256); 229 186 230 187 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); 231 188 player.start(&test_info(item_id), TrackPublishers::new()); 232 189 233 190 player.abort(); 234 - player.abort(); // second abort is a no-op 235 - // Did not panic = pass 191 + player.abort(); 192 + assert!(!player.on_track_ended(99999)); 236 193 } 237 194 238 195 #[tokio::test] 239 196 async fn test_player_abort_when_idle() { 240 197 let (tx, _rx) = mpsc::channel(256); 241 198 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); 242 - player.abort(); // no-op 199 + player.abort(); 200 + assert!(!player.on_track_ended(99999)); 243 201 } 244 202 245 203 #[tokio::test] 246 204 async fn test_player_on_track_ended_matches_current() { 247 - let pool = test_pool().await; 248 - let item_id = make_queue_item(&pool).await; 205 + let item_id = make_item_id(); 249 206 let (tx, _rx) = mpsc::channel(256); 250 207 251 208 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); ··· 256 213 257 214 #[tokio::test] 258 215 async fn test_player_on_track_ended_ignores_old_id() { 259 - let pool = test_pool().await; 260 - let item_id = make_queue_item(&pool).await; 216 + let item_id = make_item_id(); 261 217 let (tx, _rx) = mpsc::channel(256); 262 218 263 219 let mut player = Player::new(test_registry(), PathBuf::from("/tmp"), tx); 264 220 player.start(&test_info(item_id), TrackPublishers::new()); 265 221 266 - // Stale id should be ignored. 267 222 assert!(!player.on_track_ended(99999)); 268 223 } 269 224 }
+70 -166
src/playlist.rs
··· 1 - //! Ordered track list for a room — upcoming queue and played history. 2 - //! 3 - //! Wraps SQLite queue operations with room-scoped queries, providing 4 - //! push, pop-next, upcoming-list, and history-list operations. 1 + //! Ordered track list for a room: upcoming queue and played history. 5 2 6 3 use sqlx::{Pool, Sqlite}; 7 4 8 5 use crate::types::TrackMeta; 9 - 10 - fn now_iso() -> String { 11 - chrono::Utc::now() 12 - .format("%Y-%m-%dT%H:%M:%S%.3fZ") 13 - .to_string() 14 - } 15 6 16 7 /// A single queue item, returned from all Playlist operations. 17 8 #[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)] ··· 62 53 .bind(meta.source.to_string()) 63 54 .bind(None::<String>) // added_by 64 55 .bind(position) 65 - .bind(now_iso()) 56 + .bind(crate::util::now_iso()) 66 57 .execute(&self.pool) 67 58 .await?; 68 59 ··· 79 70 Ok(item) 80 71 } 81 72 82 - /// Atomically fetch and mark the first unplayed item as playing. 83 - /// 84 - /// Returns the item with `started_playing_at` set, or `None` when the 85 - /// queue is empty. Unlike the old `pop_next`, this does NOT set 86 - /// `played = 1` — that happens later via `mark_finished`. 87 - #[allow(dead_code)] 88 - pub(crate) async fn pop_next(&self) -> Result<Option<QueueItem>, sqlx::Error> { 89 - let mut tx = self.pool.begin().await?; 90 - 91 - let item = sqlx::query_as::<_, QueueItem>( 92 - "SELECT id, url, title, duration, thumbnail, source, position, played, played_at, started_playing_at 93 - FROM queue_items WHERE room_id = ? AND played = 0 AND started_playing_at IS NULL 94 - ORDER BY position ASC LIMIT 1", 95 - ) 96 - .bind(&self.room_id) 97 - .fetch_optional(&mut *tx) 98 - .await?; 99 - 100 - let result = if let Some(item) = item { 101 - let now = now_iso(); 102 - sqlx::query("UPDATE queue_items SET started_playing_at = ? WHERE id = ?") 103 - .bind(&now) 104 - .bind(item.id) 105 - .execute(&mut *tx) 106 - .await?; 107 - 108 - Some(QueueItem { 109 - started_playing_at: Some(now), 110 - ..item 111 - }) 112 - } else { 113 - None 114 - }; 115 - 116 - tx.commit().await?; 117 - Ok(result) 118 - } 119 - 120 73 /// List queued items (not playing, not finished) ordered by position ASC. 121 74 pub(crate) async fn upcoming(&self) -> Result<Vec<QueueItem>, sqlx::Error> { 122 75 let items = sqlx::query_as::<_, QueueItem>( ··· 155 108 156 109 /// Mark a currently-playing item as finished (moves it from "playing" to "played"). 157 110 pub(crate) async fn mark_finished(&self, id: i64) -> Result<(), sqlx::Error> { 158 - let now = now_iso(); 111 + let now = crate::util::now_iso(); 159 112 sqlx::query( 160 113 "UPDATE queue_items SET played = 1, played_at = ?, started_playing_at = NULL WHERE id = ?", 161 114 ) ··· 204 157 .await 205 158 .unwrap(); 206 159 db::run_migrations(&pool).await.unwrap(); 207 - // Create a room row so foreign-key constraints are satisfied. 208 - db::room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 160 + db::room_insert(&pool, "test-room", "test-name", false) 161 + .await 162 + .unwrap(); 209 163 Playlist::new(pool, "test-room") 210 164 } 211 165 ··· 241 195 } 242 196 243 197 #[tokio::test] 244 - async fn test_pop_next() { 245 - let pl = test_playlist().await; 246 - 247 - pl.push(&track("First")).await.unwrap(); 248 - pl.push(&track("Second")).await.unwrap(); 249 - 250 - let popped = pl.pop_next().await.unwrap().expect("should pop first"); 251 - assert_eq!(popped.title, "First"); 252 - assert!(!popped.played, "pop_next should NOT mark as played"); 253 - assert!( 254 - popped.played_at.is_none(), 255 - "pop_next should NOT set played_at" 256 - ); 257 - assert!( 258 - popped.started_playing_at.is_some(), 259 - "pop_next should set started_playing_at" 260 - ); 261 - 262 - // Upcoming should exclude items that have started playing. 263 - let upcoming = pl.upcoming().await.unwrap(); 264 - assert_eq!(upcoming.len(), 1); 265 - assert_eq!(upcoming[0].title, "Second"); 266 - } 267 - 268 - #[tokio::test] 269 - async fn test_history() { 270 - let pl = test_playlist().await; 271 - 272 - pl.push(&track("A")).await.unwrap(); 273 - pl.push(&track("B")).await.unwrap(); 274 - pl.push(&track("C")).await.unwrap(); 275 - 276 - let a = pl.pop_next().await.unwrap().unwrap(); 277 - let b = pl.pop_next().await.unwrap().unwrap(); 278 - 279 - // Items only appear in history after mark_finished. 280 - let history = pl.history(10).await.unwrap(); 281 - assert_eq!( 282 - history.len(), 283 - 0, 284 - "items should not appear in history until mark_finished" 285 - ); 286 - 287 - pl.mark_finished(a.id).await.unwrap(); 288 - pl.mark_finished(b.id).await.unwrap(); 289 - 290 - let history = pl.history(10).await.unwrap(); 291 - assert_eq!(history.len(), 2); 292 - // Most recently played first. 293 - assert_eq!(history[0].title, "B"); 294 - assert_eq!(history[1].title, "A"); 295 - assert!(history[0].played); 296 - assert!(history[0].played_at.is_some()); 297 - } 298 - 299 - #[tokio::test] 300 - async fn test_pop_empty() { 301 - let pl = test_playlist().await; 302 - let popped = pl.pop_next().await.unwrap(); 303 - assert!(popped.is_none()); 304 - } 305 - 306 - #[tokio::test] 307 198 async fn test_push_positions() { 308 199 let pl = test_playlist().await; 309 200 ··· 319 210 #[tokio::test] 320 211 async fn test_mark_finished_non_existent_id() { 321 212 let pl = test_playlist().await; 322 - // Should not panic or error. 323 - let result = pl.mark_finished(99999).await; 324 - assert!( 325 - result.is_ok(), 326 - "mark_finished on non-existent id should be Ok" 327 - ); 213 + pl.mark_finished(99999).await.unwrap(); 328 214 } 329 215 330 216 #[tokio::test] 331 - async fn test_pop_next_returns_none_when_all_played() { 217 + async fn test_upcoming_excludes_playing_items() { 332 218 let pl = test_playlist().await; 333 - pl.push(&track("A")).await.unwrap(); 334 - pl.pop_next().await.unwrap(); 335 - let next = pl.pop_next().await.unwrap(); 336 - assert!( 337 - next.is_none(), 338 - "pop_next should return None when queue is empty" 339 - ); 219 + let a = pl.push(&track("A")).await.unwrap(); 220 + pl.push(&track("B")).await.unwrap(); 221 + 222 + let now = chrono::Utc::now() 223 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 224 + .to_string(); 225 + pl.mark_started(a.id, &now).await.unwrap(); 226 + 227 + let upcoming = pl.upcoming().await.unwrap(); 228 + assert_eq!(upcoming.len(), 1); 229 + assert_eq!(upcoming[0].title, "B"); 340 230 } 341 231 342 232 #[tokio::test] 343 - async fn test_upcoming_excludes_playing_items() { 233 + async fn test_history() { 344 234 let pl = test_playlist().await; 345 - pl.push(&track("A")).await.unwrap(); 235 + 236 + let a = pl.push(&track("A")).await.unwrap(); 346 237 pl.push(&track("B")).await.unwrap(); 238 + let c = pl.push(&track("C")).await.unwrap(); 347 239 348 - // Pop A (marks as playing, not finished). 349 - let a = pl.pop_next().await.unwrap().unwrap(); 350 - assert_eq!(a.title, "A"); 351 - assert!(!a.played, "popped item should not be marked played"); 352 - assert!( 353 - a.started_playing_at.is_some(), 354 - "popped item should have started_playing_at" 355 - ); 240 + let now_a = chrono::Utc::now() 241 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 242 + .to_string(); 243 + pl.mark_started(a.id, &now_a).await.unwrap(); 244 + pl.mark_finished(a.id).await.unwrap(); 356 245 357 - // Upcoming should only have B. 358 - let upcoming = pl.upcoming().await.unwrap(); 359 - assert_eq!(upcoming.len(), 1, "upcoming should exclude playing item"); 360 - assert_eq!(upcoming[0].title, "B"); 246 + tokio::time::sleep(std::time::Duration::from_millis(5)).await; 247 + let now_c = chrono::Utc::now() 248 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 249 + .to_string(); 250 + pl.mark_started(c.id, &now_c).await.unwrap(); 251 + pl.mark_finished(c.id).await.unwrap(); 252 + 253 + let history = pl.history(10).await.unwrap(); 254 + assert_eq!(history.len(), 2); 255 + assert_eq!(history[0].title, "C"); 256 + assert_eq!(history[1].title, "A"); 257 + assert!(history[0].played); 258 + assert!(history[0].played_at.is_some()); 361 259 } 362 260 363 261 #[tokio::test] 364 262 async fn test_history_only_after_mark_finished() { 365 263 let pl = test_playlist().await; 366 - pl.push(&track("A")).await.unwrap(); 367 - let a = pl.pop_next().await.unwrap().unwrap(); 264 + let a = pl.push(&track("A")).await.unwrap(); 368 265 369 - // A is playing — should NOT be in history. 266 + let now = chrono::Utc::now() 267 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 268 + .to_string(); 269 + pl.mark_started(a.id, &now).await.unwrap(); 270 + 370 271 let history = pl.history(10).await.unwrap(); 371 - assert!( 372 - history.is_empty(), 373 - "playing items should not appear in history" 374 - ); 272 + assert!(history.is_empty()); 375 273 376 - // Mark as finished. 377 274 pl.mark_finished(a.id).await.unwrap(); 378 275 379 - // Now A should be in history. 380 276 let history = pl.history(10).await.unwrap(); 381 277 assert_eq!(history.len(), 1); 382 278 assert_eq!(history[0].title, "A"); 383 279 assert!(history[0].played); 384 - assert!( 385 - history[0].played_at.is_some(), 386 - "finished items should have played_at" 387 - ); 280 + assert!(history[0].played_at.is_some()); 388 281 } 389 282 390 283 #[tokio::test] 391 284 async fn test_push_after_mark_finished() { 392 285 let pl = test_playlist().await; 393 - pl.push(&track("A")).await.unwrap(); 394 - let a = pl.pop_next().await.unwrap().unwrap(); 286 + let a = pl.push(&track("A")).await.unwrap(); 287 + let now = chrono::Utc::now() 288 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 289 + .to_string(); 290 + pl.mark_started(a.id, &now).await.unwrap(); 395 291 pl.mark_finished(a.id).await.unwrap(); 396 292 397 - // Push another track 398 293 pl.push(&track("B")).await.unwrap(); 399 294 400 295 let upcoming = pl.upcoming().await.unwrap(); ··· 409 304 #[tokio::test] 410 305 async fn test_history_order_multiple_finished() { 411 306 let pl = test_playlist().await; 412 - pl.push(&track("A")).await.unwrap(); 413 - pl.push(&track("B")).await.unwrap(); 414 - pl.push(&track("C")).await.unwrap(); 307 + let a = pl.push(&track("A")).await.unwrap(); 308 + let b = pl.push(&track("B")).await.unwrap(); 309 + let c = pl.push(&track("C")).await.unwrap(); 415 310 416 - let a = pl.pop_next().await.unwrap().unwrap(); 417 - tokio::time::sleep(std::time::Duration::from_millis(5)).await; 311 + let now_a = chrono::Utc::now() 312 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 313 + .to_string(); 314 + pl.mark_started(a.id, &now_a).await.unwrap(); 418 315 pl.mark_finished(a.id).await.unwrap(); 419 316 420 - let b = pl.pop_next().await.unwrap().unwrap(); 421 317 tokio::time::sleep(std::time::Duration::from_millis(5)).await; 318 + let now_b = chrono::Utc::now() 319 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 320 + .to_string(); 321 + pl.mark_started(b.id, &now_b).await.unwrap(); 422 322 pl.mark_finished(b.id).await.unwrap(); 423 323 424 - let c = pl.pop_next().await.unwrap().unwrap(); 324 + tokio::time::sleep(std::time::Duration::from_millis(5)).await; 325 + let now_c = chrono::Utc::now() 326 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 327 + .to_string(); 328 + pl.mark_started(c.id, &now_c).await.unwrap(); 425 329 pl.mark_finished(c.id).await.unwrap(); 426 330 427 331 let history = pl.history(10).await.unwrap();
+283 -500
src/room.rs
··· 4 4 //! SQLite, and holds ephemeral playback state in memory via a pure 5 5 //! [`PlaybackState`] machine. 6 6 //! 7 - //! All mutations flow through a per-room mpsc event-loop actor, 8 - //! following the impure-pure-impure sandwich: 7 + //! All mutations flow through a per-room mpsc event-loop actor: 9 8 //! 10 - //! 1. **Gather** — fetch data (DB, wall clock) 11 - //! 2. **Transition** — `PlaybackState::transition()` (pure) 12 - //! 3. **Commit** — execute returned [`Effect`]s 9 + //! 1. Gather: fetch data (DB, wall clock) 10 + //! 2. Transition: `PlaybackState::transition()` (pure) 11 + //! 3. Commit: execute returned [`Effect`]s 13 12 14 13 use std::collections::HashMap; 15 14 use std::path::PathBuf; ··· 32 31 use crate::state::{self, Effect, Event, PlaybackState}; 33 32 use crate::transport::TrackPublishers; 34 33 use crate::types::{ 35 - ChatMessage, HistoryEntry, QueueSummary, RoomCommand, RoomHandle, RoomId, TrackMeta, 36 - TrackState, 34 + ChatMessage, HistoryEntry, QueueSummary, RoomCommand, RoomHandle, RoomId, TrackMeta, TrackState, 37 35 }; 38 36 39 37 /// Distributed unique ID generator (Discord-style snowflake). 40 38 /// 41 39 /// Bit layout: 42 - /// 1 bit — unused (always 0) 43 - /// 41 bits — timestamp in milliseconds since custom epoch 44 - /// 10 bits — node ID (0–1023) 45 - /// 12 bits — sequence number (0–4095) 40 + /// 1 bit unused (always 0) 41 + /// 41 bits timestamp in milliseconds since custom epoch 42 + /// 10 bits node ID (0-1023) 43 + /// 12 bits sequence number (0-4095) 46 44 /// 47 45 /// Thread-safety is provided by a single `AtomicU64` that packs 48 46 /// `(timestamp_ms << 12) | sequence`, allowing lock-free CAS updates. ··· 51 49 state: AtomicU64, // (timestamp_ms << 12) | sequence (12 bits) 52 50 } 53 51 54 - const CUSTOM_EPOCH_MS: u64 = 173_568_960_0000; // 2025-01-01T00:00:00Z 52 + const CUSTOM_EPOCH_MS: u64 = 1_735_689_600_000; // 2025-01-01T00:00:00Z 55 53 56 54 impl SnowflakeIdGen { 57 55 pub(crate) fn new(node_id: u16) -> Self { ··· 67 65 /// Thread-safe. Under extreme load (≥4096 IDs in the same 68 66 /// millisecond) the generator yields to the tokio runtime until 69 67 /// the next millisecond tick. 68 + #[must_use] 70 69 pub(crate) async fn next_id(&self) -> u64 { 71 70 loop { 72 71 let now = SystemTime::now() ··· 89 88 { 90 89 return (ts << 22) | ((self.node_id as u64) << 12); 91 90 } 92 - // CAS lost — another thread already advanced or incremented. 93 - // Retry with fresh state. 94 91 continue; 95 92 } 96 93 ··· 105 102 { 106 103 return (last_ts << 22) | ((self.node_id as u64) << 12) | seq as u64; 107 104 } 108 - // CAS lost — another thread claimed this seq first; retry. 109 105 continue; 110 106 } 111 107 ··· 124 120 pool: Pool<Sqlite>, 125 121 source_registry: Arc<SourceRegistry>, 126 122 cache_dir: PathBuf, 127 - /// Clone of the command sender — used by spawned extraction tasks to 123 + /// Clone of the command sender, used by spawned extraction tasks to 128 124 /// deliver MetadataReady / MetadataFailed back to the actor. 129 125 cmd_tx: mpsc::Sender<RoomCommand>, 130 126 ··· 168 164 } 169 165 170 166 /// Create a new room with a snowflake ID, persist to SQLite. 171 - pub(crate) async fn create(&self) -> RoomRef { 167 + pub(crate) async fn create(&self) -> RoomId { 172 168 let id = RoomId(self.id_gen.next_id().await.to_string()); 173 169 let room_name = generate_room_name(&id.0); 174 170 let now = Utc::now(); ··· 178 174 let (tx, rx) = mpsc::channel(256); 179 175 let (last_active_tx, last_active_rx) = watch::channel(now); 180 176 181 - let player = Player::new( 182 - self.sources.clone(), 183 - self.cache_dir.clone(), 184 - tx.clone(), 185 - ); 177 + let player = Player::new(self.sources.clone(), self.cache_dir.clone(), tx.clone()); 186 178 let actor = RoomActor { 187 179 rx, 188 180 room_id: id.clone(), ··· 209 201 }, 210 202 ); 211 203 212 - RoomRef { 213 - id, 214 - _registry: self.clone(), 215 - } 204 + id 216 205 } 217 206 218 207 /// Get the room handle if the room is active. ··· 221 210 } 222 211 223 212 /// Check whether a room exists in SQLite. 213 + #[must_use] 224 214 pub(crate) async fn exists(&self, id: &RoomId) -> bool { 225 215 db::room_exists(&self.pool, &id.0).await.unwrap_or(false) 226 216 } ··· 236 226 duration: i.duration, 237 227 thumbnail: i.thumbnail, 238 228 url: i.url, 239 - source: i 240 - .source 241 - .parse() 242 - .unwrap_or_else(|s: String| { 243 - tracing::warn!(source = %s, "unknown source kind in queue_items"); 244 - crate::types::SourceKind::Direct 245 - }), 229 + source: i.source.parse().unwrap_or_else(|s: String| { 230 + tracing::warn!(source = %s, "unknown source kind in queue_items"); 231 + crate::types::SourceKind::Direct 232 + }), 246 233 }) 247 234 .collect() 248 - } 249 - 250 - /// Push a track onto a room's queue (persisted to SQLite via Playlist). 251 - #[allow(dead_code)] // only used by tests 252 - pub(crate) async fn push(&self, id: &RoomId, meta: TrackMeta) { 253 - if let Some(handle) = self.handle(id).await { 254 - let _ = handle.cmd_tx.send(RoomCommand::QueueTrack(meta)).await; 255 - } 256 235 } 257 236 258 237 /// Push a raw URL onto a room's queue. Metadata extraction (yt-dlp) runs 259 - /// asynchronously in a spawned task — the caller returns immediately. 238 + /// asynchronously in a spawned task. The caller returns immediately. 260 239 pub(crate) async fn push_url(&self, id: &RoomId, url: String) { 261 - let t0 = std::time::Instant::now(); 262 240 let handle = match self.handle(id).await { 263 241 Some(h) => h, 264 242 None => return, 265 243 }; 266 - let t1 = t0.elapsed(); 267 244 let _ = handle.cmd_tx.send(RoomCommand::QueueUrl(url)).await; 268 - let t2 = t0.elapsed(); 269 - tracing::info!( 270 - room = %id, 271 - "push_url timing: handle={:.0?} send={:.0?} total={:.0?}", 272 - t1, t2 - t1, t2 273 - ); 274 245 } 275 246 276 247 /// Remove a room (from SQLite and in-memory state). ··· 278 249 pub(crate) async fn remove(&self, id: &RoomId) { 279 250 if let Some(handle) = self.handle(id).await { 280 251 let _ = tokio::time::timeout( 281 - Duration::from_secs(1), 252 + Duration::from_secs(1), // generous 1s window for graceful shutdown 282 253 handle.cmd_tx.send(RoomCommand::Shutdown), 283 254 ) 284 255 .await; ··· 397 368 for id in &idle_rooms { 398 369 if let Some(handle) = self.handle(id).await { 399 370 if tokio::time::timeout( 400 - Duration::from_secs(1), 371 + Duration::from_secs(1), // generous 1s window for graceful shutdown 401 372 handle.cmd_tx.send(RoomCommand::Shutdown), 402 373 ) 403 374 .await ··· 416 387 } 417 388 } 418 389 419 - /// An owned handle to a room, returned by [`Registry::create`]. 420 - pub(crate) struct RoomRef { 421 - pub id: RoomId, 422 - _registry: Registry, 423 - } 424 - 425 390 impl RoomActor { 426 391 /// Run the event loop. Returns when rx is closed or Shutdown received. 427 392 async fn run(mut self) { ··· 431 396 break; 432 397 } 433 398 } 434 - // On shutdown, abort any active pipeline. 435 399 self.player.abort(); 436 400 tracing::info!(room = %self.room_id, "room actor shut down"); 437 401 } ··· 439 403 /// Process a single command. 440 404 /// 441 405 /// Returns `true` if the actor should shut down. 406 + #[must_use] 442 407 async fn process(&mut self, cmd: RoomCommand) -> bool { 443 408 match cmd { 444 - RoomCommand::QueueTrack(meta) => { 445 - tracing::debug!(room = %self.room_id, title = %meta.title, "track queued"); 446 - 447 - // ── Gather ────────────────────────────────────────── 448 - let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 449 - let item = match playlist.push(&meta).await { 450 - Ok(item) => item, 451 - Err(e) => { 452 - tracing::warn!("failed to persist queue item: {e}"); 453 - return false; 454 - } 455 - }; 456 - let now = Utc::now().timestamp_millis(); 457 - 458 - // ── Transition (pure) ─────────────────────────────── 459 - let effects = self.state.transition( 460 - &Event::TrackQueued(state::QueuedTrack { 461 - id: item.id, 462 - title: item.title, 463 - url: item.url, 464 - duration: item.duration, 465 - thumbnail: item.thumbnail, 466 - pending: false, 467 - }), 468 - now, 469 - ); 470 - 471 - // ── Commit ────────────────────────────────────────── 472 - self.execute_effects(effects).await; 473 - } 474 - 475 409 RoomCommand::QueueUrl(url) => { 476 - let t0 = std::time::Instant::now(); 477 410 tracing::debug!(room = %self.room_id, %url, "track url queued"); 478 411 479 - // ── Gather ────────────────────────────────────────── 480 412 let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 481 413 let placeholder = TrackMeta { 482 - title: "Loading…".into(), 414 + title: "Loading...".into(), 483 415 duration: "--:--".into(), 484 416 thumbnail: None, 485 417 url: url.clone(), ··· 492 424 return false; 493 425 } 494 426 }; 495 - let t1 = t0.elapsed(); 496 427 let now = Utc::now().timestamp_millis(); 497 428 498 - // ── Transition (pure) ─────────────────────────────── 499 429 let effects = self.state.transition( 500 430 &Event::TrackQueued(state::QueuedTrack { 501 431 id: item.id, ··· 507 437 }), 508 438 now, 509 439 ); 510 - let t2 = t0.elapsed(); 511 440 512 - // ── Commit ────────────────────────────────────────── 513 441 self.execute_effects(effects).await; 514 - let t3 = t0.elapsed(); 515 442 516 - // ── Spawn async extraction (non-blocking) ─────────── 517 443 let item_id = item.id; 518 444 let cmd_tx = self.cmd_tx.clone(); 519 445 let sources = self.source_registry.clone(); ··· 535 461 } 536 462 } 537 463 }); 538 - 539 - tracing::info!( 540 - room = %self.room_id, 541 - "QueueUrl actor timing: db_push={:.0?} transition={:.0?} effects={:.0?} total={:.0?}", 542 - t1, t2 - t1, t3 - t2, t3 543 - ); 544 464 } 545 465 546 466 RoomCommand::MetadataReady { item_id, meta } => { 547 467 tracing::debug!(room = %self.room_id, item_id, title = %meta.title, "metadata ready"); 548 468 549 - // ── Gather ────────────────────────────────────────── 550 469 let now = Utc::now().timestamp_millis(); 551 470 552 - // ── DB update (synchronous commit, actor-safe) ────── 553 471 let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 554 472 if let Err(e) = playlist.update_metadata(item_id, &meta).await { 555 473 tracing::warn!(item_id, error = %e, "failed to persist metadata update"); 556 474 } 557 475 558 - // ── Transition (pure) ─────────────────────────────── 559 476 let effects = self.state.transition( 560 477 &Event::MetadataUpdated { 561 478 item_id, ··· 566 483 now, 567 484 ); 568 485 569 - // ── Commit ────────────────────────────────────────── 570 486 self.execute_effects(effects).await; 571 487 } 572 488 573 489 RoomCommand::MetadataFailed { item_id, error } => { 574 490 tracing::debug!(room = %self.room_id, item_id, %error, "metadata extraction failed"); 575 491 576 - // ── Transition (pure) — set error state in title ──── 577 492 let now = Utc::now().timestamp_millis(); 578 493 let effects = self.state.transition( 579 494 &Event::MetadataUpdated { 580 495 item_id, 581 - title: format!("Failed to load — {error}"), 496 + title: format!("Failed to load: {error}"), 582 497 duration: "--:--".into(), 583 498 thumbnail: None, 584 499 }, ··· 590 505 RoomCommand::Skip => { 591 506 tracing::info!(room = %self.room_id, "skip requested"); 592 507 593 - // ── Gather ────────────────────────────────────────── 594 508 let now = Utc::now().timestamp_millis(); 595 509 596 - // ── Transition (pure) ─────────────────────────────── 597 510 let effects = self.state.transition(&Event::Skip, now); 598 511 599 - // ── Commit ────────────────────────────────────────── 600 512 self.execute_effects(effects).await; 601 513 } 602 514 603 515 RoomCommand::TrackEnded { item_id } => { 604 516 tracing::debug!(room = %self.room_id, item_id, "track ended"); 605 517 606 - // Gate on player first — stale events are silently dropped. 607 518 if !self.player.on_track_ended(item_id) { 608 519 return false; 609 520 } 610 521 611 - // ── Gather ────────────────────────────────────────── 612 522 let now = Utc::now().timestamp_millis(); 613 523 614 - // ── Transition (pure) ─────────────────────────────── 615 524 let effects = self.state.transition(&Event::TrackEnded { item_id }, now); 616 525 617 - // ── Commit ────────────────────────────────────────── 618 526 self.execute_effects(effects).await; 619 527 } 620 528 ··· 677 585 } 678 586 679 587 Effect::PersistStarted(id) => { 680 - let t0 = std::time::Instant::now(); 681 588 let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); 682 589 let playlist = Playlist::new(self.pool.clone(), &self.room_id.0); 683 590 let _ = playlist.mark_started(id, &now).await; 684 - tracing::debug!(room = %self.room_id, id, "PersistStarted: {:.0?}", t0.elapsed()); 685 591 } 686 592 687 593 Effect::StartPipeline(track) => { ··· 712 618 713 619 /// Publish the current state snapshot to all connected clients. 714 620 /// 715 - /// Reads from in-memory [`PlaybackState`] — no SQLite queries. 621 + /// Reads from in-memory [`PlaybackState`], no SQLite queries. 716 622 async fn publish_state_snapshot(&self) { 717 623 let current = self.state.active.as_ref().map(|t| TrackState { 718 624 id: t.id, 719 625 title: t.title.clone(), 720 626 url: t.url.clone(), 721 627 duration: t.duration.clone(), 628 + thumbnail: t.thumbnail.clone(), 722 629 started_at: t.started_at_wall, 723 630 }); 724 631 ··· 774 681 use super::*; 775 682 use sqlx::sqlite::SqlitePoolOptions; 776 683 777 - async fn test_pool() -> Pool<Sqlite> { 684 + async fn poll_until<F, Fut>(check: F, timeout: Duration, label: &str) 685 + where 686 + F: Fn() -> Fut, 687 + Fut: std::future::Future<Output = bool>, 688 + { 689 + let start = std::time::Instant::now(); 690 + loop { 691 + if check().await { 692 + return; 693 + } 694 + if start.elapsed() > timeout { 695 + panic!("timed out: {label}"); 696 + } 697 + tokio::time::sleep(Duration::from_millis(10)).await; 698 + } 699 + } 700 + 701 + async fn test_registry_and_room() -> (Registry, RoomId) { 778 702 let pool = SqlitePoolOptions::new() 779 703 .max_connections(1) 780 704 .connect(":memory:") 781 705 .await 782 706 .unwrap(); 783 707 db::run_migrations(&pool).await.unwrap(); 784 - pool 708 + let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 709 + let room_id = reg.create().await; 710 + (reg, room_id) 785 711 } 786 712 787 - /// A mock source that handles all URLs. 788 713 struct PanaceaSource; 789 714 790 715 #[async_trait::async_trait] ··· 794 719 url: &str, 795 720 _cache_dir: &std::path::Path, 796 721 ) -> Result<TrackMeta, crate::source::SourceError> { 722 + let title = url.rsplit('/').next().unwrap_or("test"); 797 723 Ok(TrackMeta { 798 - title: "test".into(), 724 + title: title.into(), 799 725 duration: "3:45".into(), 800 726 thumbnail: None, 801 727 url: url.into(), ··· 844 770 845 771 #[tokio::test] 846 772 async fn test_create_and_exists() { 847 - let pool = test_pool().await; 848 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 849 - let room = reg.create().await; 850 - assert!(reg.exists(&room.id).await); 773 + let (reg, room_id) = test_registry_and_room().await; 774 + assert!(reg.exists(&room_id).await); 851 775 } 852 776 853 777 #[tokio::test] 854 778 async fn test_push_and_queue() { 855 - let pool = test_pool().await; 856 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 857 - let room = reg.create().await; 858 - 859 - let meta_a = TrackMeta { 860 - title: "Track A".into(), 861 - duration: "3:45".into(), 862 - thumbnail: None, 863 - url: "https://example.com/a".into(), 864 - source: crate::types::SourceKind::Direct, 865 - }; 866 - let meta_b = TrackMeta { 867 - title: "Track B".into(), 868 - duration: "4:20".into(), 869 - thumbnail: None, 870 - url: "https://example.com/b".into(), 871 - source: crate::types::SourceKind::Direct, 872 - }; 779 + let (reg, room_id) = test_registry_and_room().await; 873 780 874 - // Push first track — it gets popped and starts playing immediately. 875 - reg.push(&room.id, meta_a.clone()).await; 876 - // Push second track — stays in the upcoming queue. 877 - reg.push(&room.id, meta_b.clone()).await; 781 + reg.push_url(&room_id, "https://example.com/a".into()).await; 782 + reg.push_url(&room_id, "https://example.com/b".into()).await; 878 783 879 - // Give the actor time to process both commands. 880 - tokio::time::sleep(Duration::from_millis(100)).await; 881 - 882 - let queue = reg.queue(&room.id).await; 883 - assert_eq!(queue.len(), 1, "only unplayed track should appear in queue"); 884 - assert_eq!(queue[0].title, "Track B"); 784 + poll_until( 785 + || async { 786 + let q = reg.queue(&room_id).await; 787 + q.len() == 1 && q[0].title == "b" 788 + }, 789 + Duration::from_secs(2), 790 + "queue should have 1 item with title 'b'", 791 + ) 792 + .await; 885 793 } 886 794 887 795 #[tokio::test] 888 796 async fn test_remove() { 889 - let pool = test_pool().await; 890 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 891 - let room = reg.create().await; 797 + let (reg, room_id) = test_registry_and_room().await; 892 798 893 - reg.remove(&room.id).await; 894 - assert!(!reg.exists(&room.id).await); 799 + reg.remove(&room_id).await; 800 + assert!(!reg.exists(&room_id).await); 895 801 } 896 802 897 803 #[tokio::test] 898 804 async fn test_send_chat() { 899 - let pool = test_pool().await; 900 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 901 - let room = reg.create().await; 805 + let (reg, room_id) = test_registry_and_room().await; 902 806 903 807 let msg = reg 904 - .send_chat(&room.id, "alice", "hello world", "message") 808 + .send_chat(&room_id, "alice", "hello world", "message") 905 809 .await 906 810 .unwrap(); 907 811 assert_eq!(msg.user_name, "alice"); ··· 912 816 913 817 #[tokio::test] 914 818 async fn test_send_chat_persistence() { 915 - let pool = test_pool().await; 916 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 917 - let room = reg.create().await; 819 + let (reg, room_id) = test_registry_and_room().await; 918 820 919 821 let _ = reg 920 - .send_chat(&room.id, "alice", "msg1", "message") 822 + .send_chat(&room_id, "alice", "msg1", "message") 921 823 .await 922 824 .unwrap(); 923 825 let _ = reg 924 - .send_chat(&room.id, "bob", "msg2", "message") 826 + .send_chat(&room_id, "bob", "msg2", "message") 925 827 .await 926 828 .unwrap(); 927 829 928 - tokio::time::sleep(Duration::from_millis(100)).await; 830 + poll_until( 831 + || async { reg.recent_chat(&room_id, 10).await.unwrap().len() == 2 }, 832 + Duration::from_secs(2), 833 + "recent_chat should have 2 messages", 834 + ) 835 + .await; 929 836 930 - let recent = reg.recent_chat(&room.id, 10).await.unwrap(); 931 - assert_eq!(recent.len(), 2); 837 + let recent = reg.recent_chat(&room_id, 10).await.unwrap(); 932 838 assert_eq!(recent[0].user_name, "bob"); 933 839 assert_eq!(recent[0].content, "msg2"); 934 840 assert_eq!(recent[1].user_name, "alice"); ··· 937 843 938 844 #[tokio::test] 939 845 async fn test_send_chat_empty_content() { 940 - let pool = test_pool().await; 941 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 942 - let room = reg.create().await; 846 + let (reg, room_id) = test_registry_and_room().await; 943 847 944 - let result = reg.send_chat(&room.id, "alice", "", "message").await; 945 - assert!(result.is_err()); 848 + let result = reg.send_chat(&room_id, "alice", "", "message").await; 849 + result.unwrap_err(); 946 850 } 947 851 948 852 #[tokio::test] 949 853 async fn test_send_chat_long_content() { 950 - let pool = test_pool().await; 951 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 952 - let room = reg.create().await; 854 + let (reg, room_id) = test_registry_and_room().await; 953 855 954 856 let long = "a".repeat(2001); 955 - let result = reg.send_chat(&room.id, "alice", &long, "message").await; 956 - assert!(result.is_err()); 857 + let result = reg.send_chat(&room_id, "alice", &long, "message").await; 858 + result.unwrap_err(); 957 859 } 958 860 959 861 #[tokio::test] 960 862 async fn test_send_chat_long_username() { 961 - let pool = test_pool().await; 962 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 963 - let room = reg.create().await; 863 + let (reg, room_id) = test_registry_and_room().await; 964 864 965 865 let long = "a".repeat(33); 966 - let result = reg.send_chat(&room.id, &long, "hello", "message").await; 967 - assert!(result.is_err()); 866 + let result = reg.send_chat(&room_id, &long, "hello", "message").await; 867 + result.unwrap_err(); 968 868 } 969 869 970 870 #[tokio::test] 971 871 async fn test_register_client() { 972 - let pool = test_pool().await; 973 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 974 - let room = reg.create().await; 872 + let (reg, room_id) = test_registry_and_room().await; 975 873 976 - let id1 = reg.register_client(&room.id).await; 874 + let id1 = reg.register_client(&room_id).await; 977 875 assert_eq!(id1, Some(1)); 978 876 979 - let id2 = reg.register_client(&room.id).await; 877 + let id2 = reg.register_client(&room_id).await; 980 878 assert_eq!(id2, Some(2)); 981 879 } 982 880 983 881 #[tokio::test] 984 882 async fn test_register_client_nonexistent_room() { 985 - let pool = test_pool().await; 986 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 883 + let (reg, _) = test_registry_and_room().await; 987 884 let fake_id = RoomId("nonexistent".into()); 988 885 assert_eq!(reg.register_client(&fake_id).await, None); 989 886 } 990 887 991 888 #[tokio::test] 992 889 async fn test_unregister_client() { 993 - let pool = test_pool().await; 994 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 995 - let room = reg.create().await; 890 + let (reg, room_id) = test_registry_and_room().await; 996 891 997 - reg.register_client(&room.id).await; 998 - reg.unregister_client(&room.id).await; 892 + reg.register_client(&room_id).await; 893 + reg.unregister_client(&room_id).await; 999 894 1000 - let id = reg.register_client(&room.id).await; 895 + let id = reg.register_client(&room_id).await; 1001 896 assert_eq!(id, Some(2)); 1002 897 } 1003 898 1004 899 #[tokio::test] 1005 900 async fn test_skip_when_idle_does_not_panic() { 1006 - let pool = test_pool().await; 1007 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1008 - let room = reg.create().await; 1009 - reg.skip(&room.id).await; 901 + let (reg, room_id) = test_registry_and_room().await; 902 + reg.skip(&room_id).await; 1010 903 } 1011 904 1012 905 #[tokio::test] 1013 906 async fn test_double_skip_does_not_deadlock() { 1014 - let pool = test_pool().await; 1015 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1016 - let room = reg.create().await; 907 + let (reg, room_id) = test_registry_and_room().await; 1017 908 1018 - let meta = TrackMeta { 1019 - title: "A".into(), 1020 - duration: "3:45".into(), 1021 - thumbnail: None, 1022 - url: "https://example.com/a".into(), 1023 - source: crate::types::SourceKind::Direct, 1024 - }; 1025 - reg.push(&room.id, meta).await; 1026 - tokio::time::sleep(Duration::from_millis(100)).await; 909 + reg.push_url(&room_id, "https://example.com/a".into()).await; 910 + poll_until( 911 + || async { reg.queue(&room_id).await.is_empty() }, 912 + Duration::from_secs(2), 913 + "queue should be empty after push starts playing", 914 + ) 915 + .await; 1027 916 1028 917 let r1 = reg.clone(); 1029 - let rid1 = room.id.clone(); 918 + let rid1 = room_id.clone(); 1030 919 let h1 = tokio::spawn(async move { r1.skip(&rid1).await }); 1031 920 let r2 = reg.clone(); 1032 - let rid2 = room.id.clone(); 921 + let rid2 = room_id.clone(); 1033 922 let h2 = tokio::spawn(async move { r2.skip(&rid2).await }); 1034 923 let _ = tokio::join!(h1, h2); 1035 924 } 1036 925 1037 926 #[tokio::test] 1038 927 async fn test_send_chat_returns_id_zero() { 1039 - let pool = test_pool().await; 1040 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1041 - let room = reg.create().await; 928 + let (reg, room_id) = test_registry_and_room().await; 1042 929 let msg = reg 1043 - .send_chat(&room.id, "alice", "hello", "message") 930 + .send_chat(&room_id, "alice", "hello", "message") 1044 931 .await 1045 932 .unwrap(); 1046 - assert_eq!(msg.id, 0, "send_chat should return id 0 in actor mode"); 933 + assert_eq!(msg.id, 0); 1047 934 } 1048 935 1049 936 #[tokio::test] 1050 937 async fn test_sweep_idle_removes_inactive_rooms() { 1051 - let pool = test_pool().await; 1052 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1053 - let room = reg.create().await; 938 + let (reg, room_id) = test_registry_and_room().await; 1054 939 1055 940 tokio::time::sleep(Duration::from_millis(1)).await; 1056 941 1057 942 let swept = reg.sweep_idle(Duration::from_secs(0)).await; 1058 - assert!(swept.contains(&room.id), "room should be swept immediately"); 1059 - assert!( 1060 - !reg.exists(&room.id).await, 1061 - "room should no longer exist after sweep" 1062 - ); 943 + assert!(swept.contains(&room_id)); 944 + assert!(!reg.exists(&room_id).await); 1063 945 } 1064 946 1065 947 #[tokio::test] 1066 948 async fn test_sweep_idle_preserves_active_rooms() { 1067 - let pool = test_pool().await; 1068 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1069 - let room = reg.create().await; 949 + let (reg, room_id) = test_registry_and_room().await; 1070 950 1071 - reg.register_client(&room.id).await; 1072 - tokio::time::sleep(Duration::from_millis(50)).await; 951 + reg.register_client(&room_id).await; 1073 952 let swept = reg.sweep_idle(Duration::from_millis(100)).await; 1074 - assert!(!swept.contains(&room.id), "active room should not be swept"); 953 + assert!(!swept.contains(&room_id)); 1075 954 } 1076 955 1077 956 #[tokio::test] 1078 957 async fn test_push_starts_playback_when_idle() { 1079 - let pool = test_pool().await; 1080 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1081 - let room = reg.create().await; 958 + let (reg, room_id) = test_registry_and_room().await; 1082 959 1083 - let meta = TrackMeta { 1084 - title: "Test".into(), 1085 - duration: "3:45".into(), 1086 - thumbnail: None, 1087 - url: "https://example.com/t".into(), 1088 - source: crate::types::SourceKind::Direct, 1089 - }; 1090 - reg.push(&room.id, meta).await; 1091 - 1092 - tokio::time::sleep(Duration::from_millis(100)).await; 1093 - 1094 - let queue = reg.queue(&room.id).await; 1095 - assert!( 1096 - queue.is_empty(), 1097 - "track should be popped when playback starts" 1098 - ); 960 + reg.push_url(&room_id, "https://example.com/t".into()).await; 961 + poll_until( 962 + || async { reg.queue(&room_id).await.is_empty() }, 963 + Duration::from_secs(2), 964 + "queue should be empty (item started playing)", 965 + ) 966 + .await; 1099 967 } 1100 968 1101 969 #[tokio::test] 1102 970 async fn test_push_appends_when_already_playing() { 1103 - let pool = test_pool().await; 1104 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1105 - let room = reg.create().await; 971 + let (reg, room_id) = test_registry_and_room().await; 1106 972 1107 - reg.push( 1108 - &room.id, 1109 - TrackMeta { 1110 - title: "A".into(), 1111 - duration: "3:45".into(), 1112 - thumbnail: None, 1113 - url: "https://example.com/a".into(), 1114 - source: crate::types::SourceKind::Direct, 1115 - }, 973 + reg.push_url(&room_id, "https://example.com/a".into()).await; 974 + poll_until( 975 + || async { reg.queue(&room_id).await.is_empty() }, 976 + Duration::from_secs(2), 977 + "first push should have started playing", 1116 978 ) 1117 979 .await; 1118 - tokio::time::sleep(Duration::from_millis(100)).await; 1119 980 1120 - reg.push( 1121 - &room.id, 1122 - TrackMeta { 1123 - title: "B".into(), 1124 - duration: "3:45".into(), 1125 - thumbnail: None, 1126 - url: "https://example.com/b".into(), 1127 - source: crate::types::SourceKind::Direct, 1128 - }, 981 + reg.push_url(&room_id, "https://example.com/b".into()).await; 982 + poll_until( 983 + || async { reg.queue(&room_id).await.len() == 1 }, 984 + Duration::from_secs(2), 985 + "second push should be queued", 1129 986 ) 1130 987 .await; 1131 - tokio::time::sleep(Duration::from_millis(100)).await; 1132 988 1133 - let queue = reg.queue(&room.id).await; 1134 - assert_eq!(queue.len(), 1, "second track should be queued"); 1135 - assert_eq!(queue[0].title, "B"); 989 + let queue = reg.queue(&room_id).await; 990 + assert_eq!(queue[0].title, "b"); 1136 991 1137 - // History should be empty (first track is still "playing", not finished). 1138 - let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 992 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1139 993 let history = playlist.history(10).await.unwrap(); 1140 - assert!( 1141 - history.is_empty(), 1142 - "no history yet — first track is still playing" 1143 - ); 994 + assert!(history.is_empty()); 1144 995 } 1145 996 1146 997 #[tokio::test] 1147 998 async fn test_skip_when_queue_empty_after_track_ends() { 1148 - let pool = test_pool().await; 1149 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1150 - let room = reg.create().await; 999 + let (reg, room_id) = test_registry_and_room().await; 1151 1000 1152 - reg.push( 1153 - &room.id, 1154 - TrackMeta { 1155 - title: "A".into(), 1156 - duration: "3:45".into(), 1157 - thumbnail: None, 1158 - url: "https://example.com/a".into(), 1159 - source: crate::types::SourceKind::Direct, 1001 + reg.push_url(&room_id, "https://example.com/a".into()).await; 1002 + poll_until( 1003 + || async { reg.queue(&room_id).await.is_empty() }, 1004 + Duration::from_secs(2), 1005 + "first push should have started playing", 1006 + ) 1007 + .await; 1008 + 1009 + reg.skip(&room_id).await; 1010 + poll_until( 1011 + || async { 1012 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1013 + playlist.history(10).await.unwrap().len() == 1 1160 1014 }, 1015 + Duration::from_secs(2), 1016 + "skip should move track to history", 1161 1017 ) 1162 1018 .await; 1163 - tokio::time::sleep(Duration::from_millis(100)).await; 1164 1019 1165 - reg.skip(&room.id).await; 1166 - tokio::time::sleep(Duration::from_millis(100)).await; 1167 - 1168 - let queue = reg.queue(&room.id).await; 1169 - assert!( 1170 - queue.is_empty(), 1171 - "queue should be empty after skip with no next track" 1172 - ); 1020 + let queue = reg.queue(&room_id).await; 1021 + assert!(queue.is_empty()); 1173 1022 } 1174 1023 1175 1024 #[tokio::test] 1176 1025 async fn test_double_skip_does_not_corrupt_state() { 1177 - let pool = test_pool().await; 1178 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1179 - let room = reg.create().await; 1026 + let (reg, room_id) = test_registry_and_room().await; 1180 1027 1181 - reg.push( 1182 - &room.id, 1183 - TrackMeta { 1184 - title: "A".into(), 1185 - duration: "3:45".into(), 1186 - thumbnail: None, 1187 - url: "https://example.com/a".into(), 1188 - source: crate::types::SourceKind::Direct, 1189 - }, 1028 + reg.push_url(&room_id, "https://example.com/a".into()).await; 1029 + poll_until( 1030 + || async { reg.queue(&room_id).await.is_empty() }, 1031 + Duration::from_secs(2), 1032 + "first push started playing", 1190 1033 ) 1191 1034 .await; 1192 - tokio::time::sleep(Duration::from_millis(100)).await; 1193 1035 1194 - reg.push( 1195 - &room.id, 1196 - TrackMeta { 1197 - title: "B".into(), 1198 - duration: "3:45".into(), 1199 - thumbnail: None, 1200 - url: "https://example.com/b".into(), 1201 - source: crate::types::SourceKind::Direct, 1036 + reg.push_url(&room_id, "https://example.com/b".into()).await; 1037 + poll_until( 1038 + || async { reg.queue(&room_id).await.len() == 1 }, 1039 + Duration::from_secs(2), 1040 + "second push queued", 1041 + ) 1042 + .await; 1043 + 1044 + reg.skip(&room_id).await; 1045 + reg.skip(&room_id).await; 1046 + poll_until( 1047 + || async { 1048 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1049 + let h = playlist.history(10).await.unwrap(); 1050 + !h.is_empty() && h[0].title == "b" 1202 1051 }, 1052 + Duration::from_secs(2), 1053 + "history should have 'b' as most recent", 1203 1054 ) 1204 1055 .await; 1205 - tokio::time::sleep(Duration::from_millis(50)).await; 1206 1056 1207 - reg.skip(&room.id).await; 1208 - reg.skip(&room.id).await; 1209 - tokio::time::sleep(Duration::from_millis(100)).await; 1210 - 1211 - let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 1057 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1212 1058 let history = playlist.history(10).await.unwrap(); 1213 - assert!( 1214 - !history.is_empty(), 1215 - "at least one track should be in history after double skip" 1216 - ); 1217 - assert_eq!(history[0].title, "B", "last skipped track should be B"); 1059 + assert!(!history.is_empty()); 1060 + assert_eq!(history[0].title, "b"); 1218 1061 } 1219 1062 1220 1063 #[tokio::test] 1221 - async fn test_register_client_overflow() { 1222 - let pool = test_pool().await; 1223 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1224 - let room = reg.create().await; 1064 + async fn test_register_client_runs_three_times() { 1065 + let (reg, room_id) = test_registry_and_room().await; 1225 1066 1226 - for _ in 0..100 { 1227 - let id = reg.register_client(&room.id).await; 1228 - assert!(id.is_some(), "register_client should return Some(id)"); 1067 + for _ in 0..3 { 1068 + let id = reg.register_client(&room_id).await; 1069 + assert!(id.is_some()); 1229 1070 } 1230 - tokio::time::sleep(Duration::from_millis(50)).await; 1231 1071 1232 - let exists = reg.exists(&room.id).await; 1233 - assert!(exists, "room should still exist"); 1072 + assert!(reg.exists(&room_id).await); 1234 1073 } 1235 1074 1236 1075 #[tokio::test] 1237 1076 async fn test_remove_while_playing_does_not_panic() { 1238 - let pool = test_pool().await; 1239 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1240 - let room = reg.create().await; 1077 + let (reg, room_id) = test_registry_and_room().await; 1241 1078 1242 - reg.push( 1243 - &room.id, 1244 - TrackMeta { 1245 - title: "A".into(), 1246 - duration: "3:45".into(), 1247 - thumbnail: None, 1248 - url: "https://example.com/a".into(), 1249 - source: crate::types::SourceKind::Direct, 1250 - }, 1079 + reg.push_url(&room_id, "https://example.com/a".into()).await; 1080 + poll_until( 1081 + || async { reg.queue(&room_id).await.is_empty() }, 1082 + Duration::from_secs(2), 1083 + "push should have started playing", 1251 1084 ) 1252 1085 .await; 1253 - tokio::time::sleep(Duration::from_millis(100)).await; 1254 1086 1255 - reg.remove(&room.id).await; 1256 - tokio::time::sleep(Duration::from_millis(100)).await; 1257 - 1258 - assert!( 1259 - !reg.exists(&room.id).await, 1260 - "room should not exist after remove" 1261 - ); 1087 + reg.remove(&room_id).await; 1088 + assert!(!reg.exists(&room_id).await); 1262 1089 } 1263 1090 1264 1091 #[tokio::test] 1265 1092 async fn test_sweep_idle_keeps_active_room() { 1266 - let pool = test_pool().await; 1267 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1268 - let room = reg.create().await; 1093 + let (reg, room_id) = test_registry_and_room().await; 1269 1094 1270 - reg.register_client(&room.id).await; 1095 + reg.register_client(&room_id).await; 1271 1096 1272 1097 let swept = reg.sweep_idle(Duration::from_millis(10_000)).await; 1273 - assert!( 1274 - !swept.contains(&room.id), 1275 - "active room should not be swept while clients are connected" 1276 - ); 1098 + assert!(!swept.contains(&room_id)); 1277 1099 } 1278 1100 1279 1101 #[tokio::test] 1280 1102 async fn test_client_count_tracking() { 1281 - let pool = test_pool().await; 1282 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1283 - let room = reg.create().await; 1103 + let (reg, room_id) = test_registry_and_room().await; 1284 1104 1285 - let id1 = reg.register_client(&room.id).await; 1286 - let id2 = reg.register_client(&room.id).await; 1287 - let id3 = reg.register_client(&room.id).await; 1288 - assert!(id1.is_some() && id2.is_some() && id3.is_some()); 1105 + let id1 = reg.register_client(&room_id).await; 1106 + let id2 = reg.register_client(&room_id).await; 1107 + let id3 = reg.register_client(&room_id).await; 1108 + assert!(id1.is_some(), "id1 should be Some"); 1109 + assert!(id2.is_some(), "id2 should be Some"); 1110 + assert!(id3.is_some(), "id3 should be Some"); 1289 1111 1290 - reg.unregister_client(&room.id).await; 1291 - tokio::time::sleep(Duration::from_millis(50)).await; 1112 + reg.unregister_client(&room_id).await; 1292 1113 1293 - let id4 = reg.register_client(&room.id).await; 1294 - assert!(id4.is_some(), "new client should get an id"); 1295 - assert_ne!(id4, id1, "ids should be unique"); 1114 + let id4 = reg.register_client(&room_id).await; 1115 + assert!(id4.is_some()); 1116 + assert_ne!(id4, id1); 1296 1117 } 1297 1118 1298 1119 #[tokio::test] 1299 1120 async fn test_multiple_rooms_dont_interfere() { 1300 - let pool = test_pool().await; 1301 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1302 - let room_a = reg.create().await; 1121 + let (reg, room_a) = test_registry_and_room().await; 1303 1122 let room_b = reg.create().await; 1304 1123 1305 - assert_ne!(room_a.id, room_b.id, "room IDs should be unique"); 1306 - assert!(reg.exists(&room_a.id).await); 1307 - assert!(reg.exists(&room_b.id).await); 1124 + assert_ne!(room_a, room_b); 1125 + assert!(reg.exists(&room_a).await); 1126 + assert!(reg.exists(&room_b).await); 1127 + 1128 + reg.push_url(&room_a, "https://example.com/a".into()).await; 1129 + reg.push_url(&room_b, "https://example.com/b".into()).await; 1308 1130 1309 - reg.push( 1310 - &room_a.id, 1311 - TrackMeta { 1312 - title: "A".into(), 1313 - duration: "1:00".into(), 1314 - thumbnail: None, 1315 - url: "https://example.com/a".into(), 1316 - source: crate::types::SourceKind::Direct, 1317 - }, 1131 + poll_until( 1132 + || async { reg.queue(&room_a).await.is_empty() }, 1133 + Duration::from_secs(2), 1134 + "room_a queue empty", 1318 1135 ) 1319 1136 .await; 1320 - reg.push( 1321 - &room_b.id, 1322 - TrackMeta { 1323 - title: "B".into(), 1324 - duration: "2:00".into(), 1325 - thumbnail: None, 1326 - url: "https://example.com/b".into(), 1327 - source: crate::types::SourceKind::Direct, 1328 - }, 1137 + poll_until( 1138 + || async { reg.queue(&room_b).await.is_empty() }, 1139 + Duration::from_secs(2), 1140 + "room_b queue empty", 1329 1141 ) 1330 1142 .await; 1331 - tokio::time::sleep(Duration::from_millis(100)).await; 1332 - 1333 - let queue_a = reg.queue(&room_a.id).await; 1334 - let queue_b = reg.queue(&room_b.id).await; 1335 - assert!( 1336 - queue_a.is_empty(), 1337 - "room A should be empty after first track starts" 1338 - ); 1339 - assert!( 1340 - queue_b.is_empty(), 1341 - "room B should be empty after first track starts" 1342 - ); 1343 - } 1344 - 1345 - #[tokio::test] 1346 - async fn test_skip_on_empty_queue_no_crash() { 1347 - let pool = test_pool().await; 1348 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1349 - let room = reg.create().await; 1350 - reg.skip(&room.id).await; 1351 - tokio::time::sleep(Duration::from_millis(50)).await; 1352 1143 } 1353 1144 1354 1145 #[tokio::test] 1355 1146 async fn test_push_after_skip_works() { 1356 - let pool = test_pool().await; 1357 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1358 - let room = reg.create().await; 1147 + let (reg, room_id) = test_registry_and_room().await; 1359 1148 1360 - reg.push( 1361 - &room.id, 1362 - TrackMeta { 1363 - title: "A".into(), 1364 - duration: "3:45".into(), 1365 - thumbnail: None, 1366 - url: "https://a".into(), 1367 - source: crate::types::SourceKind::Direct, 1368 - }, 1149 + reg.push_url(&room_id, "https://a".into()).await; 1150 + poll_until( 1151 + || async { reg.queue(&room_id).await.is_empty() }, 1152 + Duration::from_secs(2), 1153 + "first push started playing", 1369 1154 ) 1370 1155 .await; 1371 - tokio::time::sleep(Duration::from_millis(100)).await; 1372 - reg.skip(&room.id).await; 1373 - tokio::time::sleep(Duration::from_millis(100)).await; 1374 1156 1375 - reg.push( 1376 - &room.id, 1377 - TrackMeta { 1378 - title: "B".into(), 1379 - duration: "3:45".into(), 1380 - thumbnail: None, 1381 - url: "https://b".into(), 1382 - source: crate::types::SourceKind::Direct, 1157 + reg.skip(&room_id).await; 1158 + poll_until( 1159 + || async { 1160 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1161 + !playlist.history(10).await.unwrap().is_empty() 1383 1162 }, 1163 + Duration::from_secs(2), 1164 + "skip should move track to history", 1384 1165 ) 1385 1166 .await; 1386 - tokio::time::sleep(Duration::from_millis(100)).await; 1387 1167 1388 - let queue = reg.queue(&room.id).await; 1389 - assert!(queue.is_empty(), "new track should be playing"); 1168 + reg.push_url(&room_id, "https://b".into()).await; 1169 + poll_until( 1170 + || async { reg.queue(&room_id).await.is_empty() }, 1171 + Duration::from_secs(2), 1172 + "second push should start playing immediately (idle after skip)", 1173 + ) 1174 + .await; 1390 1175 1391 - let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room.id.0); 1176 + let playlist = crate::playlist::Playlist::new(reg.pool.clone(), &room_id.0); 1392 1177 let history = playlist.history(10).await.unwrap(); 1393 - assert!(!history.is_empty(), "skipped track should be in history"); 1178 + assert!(!history.is_empty()); 1394 1179 } 1395 1180 1396 1181 #[tokio::test] 1397 1182 async fn test_client_count_does_not_underflow() { 1398 - let pool = test_pool().await; 1399 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1400 - let room = reg.create().await; 1183 + let (reg, room_id) = test_registry_and_room().await; 1401 1184 1402 - reg.unregister_client(&room.id).await; 1403 - tokio::time::sleep(Duration::from_millis(50)).await; 1185 + reg.unregister_client(&room_id).await; 1404 1186 1405 - assert!(reg.exists(&room.id).await); 1187 + let re_registered = reg.register_client(&room_id).await; 1188 + assert!( 1189 + re_registered.is_some(), 1190 + "room should accept new clients after underflow" 1191 + ); 1406 1192 } 1407 1193 1408 1194 #[tokio::test] 1409 1195 async fn test_multiple_skips_sequential() { 1410 - let pool = test_pool().await; 1411 - let reg = Registry::new(pool, PathBuf::from("/tmp/moqbox"), test_registry()); 1412 - let room = reg.create().await; 1196 + let (reg, room_id) = test_registry_and_room().await; 1413 1197 1414 - for title in ["A", "B", "C"] { 1415 - reg.push( 1416 - &room.id, 1417 - TrackMeta { 1418 - title: title.into(), 1419 - duration: "1:00".into(), 1420 - thumbnail: None, 1421 - url: "https://x".into(), 1422 - source: crate::types::SourceKind::Direct, 1423 - }, 1424 - ) 1425 - .await; 1198 + for url in ["https://x/a", "https://x/b", "https://x/c"] { 1199 + reg.push_url(&room_id, url.into()).await; 1426 1200 } 1427 - tokio::time::sleep(Duration::from_millis(100)).await; 1201 + poll_until( 1202 + || async { reg.queue(&room_id).await.len() == 2 }, 1203 + Duration::from_secs(2), 1204 + "3 pushes → 1 active, 2 queued", 1205 + ) 1206 + .await; 1428 1207 1429 - let mut queue = reg.queue(&room.id).await; 1430 - assert_eq!(queue.len(), 2, "B and C should be queued"); 1208 + reg.skip(&room_id).await; 1209 + poll_until( 1210 + || async { reg.queue(&room_id).await.len() == 1 }, 1211 + Duration::from_secs(2), 1212 + "skip → 1 item left in queue", 1213 + ) 1214 + .await; 1431 1215 1432 - reg.skip(&room.id).await; 1433 - tokio::time::sleep(Duration::from_millis(100)).await; 1434 - queue = reg.queue(&room.id).await; 1435 - assert_eq!(queue.len(), 1, "only C should remain after skipping A"); 1436 - assert_eq!(queue[0].title, "C"); 1216 + let queue = reg.queue(&room_id).await; 1217 + assert_eq!(queue[0].title, "c"); 1437 1218 1438 - reg.skip(&room.id).await; 1439 - tokio::time::sleep(Duration::from_millis(100)).await; 1440 - queue = reg.queue(&room.id).await; 1441 - assert!(queue.is_empty(), "nothing should remain after skipping B"); 1219 + reg.skip(&room_id).await; 1220 + poll_until( 1221 + || async { reg.queue(&room_id).await.is_empty() }, 1222 + Duration::from_secs(2), 1223 + "skip → queue empty", 1224 + ) 1225 + .await; 1442 1226 1443 - reg.skip(&room.id).await; 1444 - tokio::time::sleep(Duration::from_millis(100)).await; 1227 + reg.skip(&room_id).await; 1445 1228 } 1446 1229 }
+30 -35
src/source/direct.rs
··· 2 2 //! 3 3 //! Handles URLs that point directly to media files (`.mp4`, `.webm`, `.mp3`, 4 4 //! etc.) without going through yt-dlp. Metadata is extracted heuristically 5 - //! from the URL path — no network requests are made during extraction. 5 + //! from the URL path. No network requests are made during extraction. 6 6 7 7 use std::path::Path; 8 8 ··· 11 11 12 12 /// Media file extensions that DirectSource can handle. 13 13 const MEDIA_EXTENSIONS: &[&str] = &[ 14 - "mp4", "webm", "mkv", "avi", "mov", "m4v", 15 - "mp3", "ogg", "flac", "wav", "m4a", "aac", "opus", "wma", 14 + "mp4", "webm", "mkv", "avi", "mov", "m4v", "mp3", "ogg", "flac", "wav", "m4a", "aac", "opus", 15 + "wma", 16 16 ]; 17 17 18 - // --------------------------------------------------------------------------- 19 - // Source 20 - // --------------------------------------------------------------------------- 21 - 22 18 /// A media source that handles direct media file URLs. 23 19 /// 24 20 /// Detects media files by URL path extension. If the URL has a recognised ··· 29 25 /// replaces separators with spaces) 30 26 /// - **resolve** returns the URL unchanged 31 27 /// 32 - /// This source is intended as a fallback after [`YtdlpSource`](super::ytdlp::YtdlpSource) 33 - /// — it only matches URLs with media extensions whose filenames look like 28 + /// This source is intended as a fallback after [`YtdlpSource`](super::ytdlp::YtdlpSource). 29 + /// It only matches URLs with media extensions whose filenames look like 34 30 /// media files. 35 31 pub(crate) struct DirectSource; 36 32 ··· 44 40 impl MediaSource for DirectSource { 45 41 async fn extract(&self, url: &str, _cache_dir: &Path) -> Result<TrackMeta, SourceError> { 46 42 let Some(ext) = extension(url) else { 47 - return Err(SourceError::Unsupported(format!("no file extension in URL: {url}"))); 43 + return Err(SourceError::Unsupported(format!( 44 + "no file extension in URL: {url}" 45 + ))); 48 46 }; 49 47 50 48 if !MEDIA_EXTENSIONS.contains(&ext.as_str()) { ··· 66 64 67 65 async fn resolve(&self, url: &str, _cache_dir: &Path) -> Result<String, SourceError> { 68 66 if !url.starts_with("http://") && !url.starts_with("https://") { 69 - return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}"))); 67 + return Err(SourceError::Unsupported(format!( 68 + "not an HTTP(S) URL: {url}" 69 + ))); 70 70 } 71 71 72 - // Direct URLs are already playable as-is. 73 72 Ok(url.to_string()) 74 73 } 75 74 } 76 - 77 - // --------------------------------------------------------------------------- 78 - // Helpers 79 - // --------------------------------------------------------------------------- 80 75 81 76 /// Extract the file extension (without dot) from a URL path, if any. 82 77 fn extension(url: &str) -> Option<String> { ··· 123 118 .collect::<Vec<_>>() 124 119 .join(" "); 125 120 126 - if result.is_empty() { "Untitled".into() } else { result } 121 + if result.is_empty() { 122 + "Untitled".into() 123 + } else { 124 + result 125 + } 127 126 } 128 127 129 - // --------------------------------------------------------------------------- 130 - // Tests 131 - // --------------------------------------------------------------------------- 132 - 133 128 #[cfg(test)] 134 129 mod tests { 135 130 use super::*; 136 - 137 - // -- Extension detection ------------------------------------------------- 138 131 139 132 #[test] 140 133 fn test_extension_mp4() { 141 - assert_eq!(extension("https://example.com/video.mp4"), Some("mp4".into())); 134 + assert_eq!( 135 + extension("https://example.com/video.mp4"), 136 + Some("mp4".into()) 137 + ); 142 138 } 143 139 144 140 #[test] ··· 161 157 162 158 #[test] 163 159 fn test_extension_uppercase() { 164 - assert_eq!(extension("https://example.com/video.MP4"), Some("mp4".into())); 160 + assert_eq!( 161 + extension("https://example.com/video.MP4"), 162 + Some("mp4".into()) 163 + ); 165 164 } 166 165 167 - // -- Title extraction ---------------------------------------------------- 168 - 169 166 #[test] 170 167 fn test_title_basic() { 171 - assert_eq!(extract_title("https://example.com/my-video.mp4", "mp4"), "my video"); 168 + assert_eq!( 169 + extract_title("https://example.com/my-video.mp4", "mp4"), 170 + "my video" 171 + ); 172 172 } 173 173 174 174 #[test] ··· 212 212 213 213 #[test] 214 214 fn test_title_multi_space_collapse() { 215 - assert_eq!( 216 - extract_title("https://example.com/a---b.mp4", "mp4"), 217 - "a b" 218 - ); 215 + assert_eq!(extract_title("https://example.com/a---b.mp4", "mp4"), "a b"); 219 216 } 220 - 221 - // -- MediaSource implementation ------------------------------------------ 222 217 223 218 #[tokio::test] 224 219 async fn test_direct_source_accepts_mp4() { ··· 270 265 assert!(matches!(result, Err(SourceError::Unsupported(_)))); 271 266 } 272 267 273 - // Integration test with yt-dlp is in src/tests/ — this file focuses on 268 + // Integration test with yt-dlp is in src/tests/. This file focuses on 274 269 // DirectSource unit tests without network dependencies. 275 270 }
+5 -25
src/source/mod.rs
··· 1 1 //! Pluggable media source resolution. 2 2 //! 3 - //! Defines [`MediaSource`] — the seam between URL-based track selection and 3 + //! Defines [`MediaSource`]: the seam between URL-based track selection and 4 4 //! playable stream production. Different implementations handle different URL 5 5 //! patterns (yt-dlp, direct media URLs, etc.). 6 6 //! ··· 8 8 //! 9 9 //! 1. Implement [`MediaSource`] for your source type. 10 10 //! 2. Register it with [`SourceRegistry::register`]. 11 - //! 3. Sources are tried in registration order — first match wins. 11 + //! 3. Sources are tried in registration order: first match wins. 12 12 13 13 pub(crate) mod direct; 14 14 pub(crate) mod ytdlp; ··· 16 16 use std::path::Path; 17 17 18 18 use crate::types::TrackMeta; 19 - 20 - // --------------------------------------------------------------------------- 21 - // Error 22 - // --------------------------------------------------------------------------- 23 19 24 20 /// Errors from media source resolution. 25 21 #[derive(Debug, thiserror::Error)] ··· 41 37 Io(#[from] std::io::Error), 42 38 } 43 39 44 - // --------------------------------------------------------------------------- 45 - // Trait 46 - // --------------------------------------------------------------------------- 47 - 48 40 /// A media source knows how to extract metadata from a URL and resolve it to a 49 41 /// playable stream URL. 50 42 #[async_trait::async_trait] ··· 52 44 /// Extract metadata from `url`. 53 45 /// 54 46 /// Returns [`SourceError::Unsupported`] if this source cannot handle the 55 - /// URL — the registry will try the next source. Any other error is 47 + /// URL. The registry will try the next source. Any other error is 56 48 /// terminal for this URL. 57 49 async fn extract(&self, url: &str, cache_dir: &Path) -> Result<TrackMeta, SourceError>; 58 50 59 51 /// Resolve `url` to a playable stream URL. 60 52 /// 61 53 /// Returns [`SourceError::Unsupported`] if this source cannot handle the 62 - /// URL — the registry will try the next source. 54 + /// URL. The registry will try the next source. 63 55 async fn resolve(&self, url: &str, cache_dir: &Path) -> Result<String, SourceError>; 64 56 } 65 - 66 - // --------------------------------------------------------------------------- 67 - // Registry 68 - // --------------------------------------------------------------------------- 69 57 70 58 /// A registry of media sources tried in registration order. 71 59 /// ··· 127 115 } 128 116 129 117 /// Resolve a URL to a playable stream URL by trying each registered source. 130 - pub(crate) async fn resolve( 131 - &self, 132 - url: &str, 133 - cache_dir: &Path, 134 - ) -> Result<String, SourceError> { 118 + pub(crate) async fn resolve(&self, url: &str, cache_dir: &Path) -> Result<String, SourceError> { 135 119 let mut unsupported_hint = String::new(); 136 120 for source in &self.sources { 137 121 match source.resolve(url, cache_dir).await { ··· 148 132 ))) 149 133 } 150 134 } 151 - 152 - // --------------------------------------------------------------------------- 153 - // Tests 154 - // --------------------------------------------------------------------------- 155 135 156 136 #[cfg(test)] 157 137 mod tests {
+9 -17
src/source/ytdlp.rs
··· 12 12 use crate::source::{MediaSource, SourceError}; 13 13 use crate::types::TrackMeta; 14 14 15 - // --------------------------------------------------------------------------- 16 - // yt-dlp JSON output fields 17 - // --------------------------------------------------------------------------- 18 - 19 15 #[derive(Debug, Deserialize)] 20 16 #[serde(rename_all = "snake_case")] 21 17 struct YtdlpMetadata { ··· 25 21 webpage_url: String, 26 22 } 27 23 28 - // --------------------------------------------------------------------------- 29 - // Source 30 - // --------------------------------------------------------------------------- 31 - 32 24 /// A media source backed by yt-dlp. 33 25 /// 34 26 /// Handles URLs from YouTube, SoundCloud, Bandcamp, and hundreds of other ··· 45 37 impl MediaSource for YtdlpSource { 46 38 async fn extract(&self, url: &str, cache_dir: &Path) -> Result<TrackMeta, SourceError> { 47 39 if !url.starts_with("http://") && !url.starts_with("https://") { 48 - return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}"))); 40 + return Err(SourceError::Unsupported(format!( 41 + "not an HTTP(S) URL: {url}" 42 + ))); 49 43 } 50 44 51 45 let cache_dir = cache_dir.to_path_buf(); ··· 63 57 .output() 64 58 }) 65 59 .await 66 - .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; 60 + .map_err(|e| SourceError::Io(std::io::Error::other(e)))?; 67 61 68 62 let output = result.map_err(|e| { 69 63 if e.kind() == std::io::ErrorKind::NotFound { ··· 100 94 101 95 async fn resolve(&self, url: &str, cache_dir: &Path) -> Result<String, SourceError> { 102 96 if !url.starts_with("http://") && !url.starts_with("https://") { 103 - return Err(SourceError::Unsupported(format!("not an HTTP(S) URL: {url}"))); 97 + return Err(SourceError::Unsupported(format!( 98 + "not an HTTP(S) URL: {url}" 99 + ))); 104 100 } 105 101 106 102 let cache_dir = cache_dir.to_path_buf(); ··· 109 105 std::process::Command::new("yt-dlp") 110 106 .args([ 111 107 "-f", 112 - "b", 108 + "b", // best single format (not bestvideo+bestaudio which needs merge) 113 109 "-g", 114 110 "--no-playlist", 115 111 "--no-warnings", ··· 120 116 .output() 121 117 }) 122 118 .await 123 - .map_err(|e| SourceError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?; 119 + .map_err(|e| SourceError::Io(std::io::Error::other(e)))?; 124 120 125 121 let output = result.map_err(|e| { 126 122 if e.kind() == std::io::ErrorKind::NotFound { ··· 152 148 Ok(stream_url) 153 149 } 154 150 } 155 - 156 - // --------------------------------------------------------------------------- 157 - // Tests 158 - // --------------------------------------------------------------------------- 159 151 160 152 #[cfg(test)] 161 153 mod tests {
+41 -81
src/state.rs
··· 1 - //! Pure playback state machine — deterministic, testable, IO-free. 1 + //! Pure playback state machine: deterministic, testable, IO-free. 2 2 //! 3 3 //! Owns the in-memory queue, active track, and history. All transitions 4 4 //! are pure functions returning [`Effect`]s that the caller executes. 5 - //! 6 - //! # Impure-pure-impure sandwich 7 - //! 8 - //! 1. **Gather** — caller fetches data (DB, wall clock) 9 - //! 2. **Transition** — `PlaybackState::transition(event, now)` — pure 10 - //! 3. **Commit** — caller executes returned effects (DB writes, pipeline start) 11 - //! 12 - //! # Testing 13 - //! 14 - //! ```ignore 15 - //! let mut state = PlaybackState::default(); 16 - //! let effects = state.transition(&Event::TrackQueued(my_track), 1_700_000_000_000); 17 - //! assert!(effects.contains(&Effect::StartPipeline(...))); 18 - //! ``` 19 5 20 6 use crate::types::ActiveTrackInfo; 21 7 ··· 77 63 PublishSnapshot, 78 64 } 79 65 80 - /// Pure playback state — no IO handles, no DB connections. 81 - #[derive(Debug, Clone)] 66 + /// Pure playback state: no IO handles, no DB connections. 67 + #[derive(Debug, Clone, Default)] 82 68 pub(crate) struct PlaybackState { 83 69 /// Currently playing track, if any. 84 70 pub active: Option<ActiveTrackInfo>, ··· 88 74 pub history: Vec<FinishedTrack>, 89 75 } 90 76 91 - impl Default for PlaybackState { 92 - fn default() -> Self { 93 - Self { 94 - active: None, 95 - queue: Vec::new(), 96 - history: Vec::new(), 97 - } 98 - } 99 - } 100 - 101 77 impl PlaybackState { 102 78 /// Pure transition function. 103 79 /// 104 - /// - `event` — what happened 105 - /// - `now` — current wall-clock epoch ms (gathered by caller for determinism) 106 - /// 107 80 /// Returns effects for the caller to execute. Mutates `self` in place. 108 - /// Does NO I/O. Deterministic given the same inputs. 81 + /// Does no I/O. Deterministic given the same inputs. 109 82 pub fn transition(&mut self, event: &Event, now: i64) -> Vec<Effect> { 110 83 match event { 111 84 Event::TrackQueued(track) => self.handle_queued(track), ··· 124 97 fn handle_queued(&mut self, track: &QueuedTrack) -> Vec<Effect> { 125 98 self.queue.push(track.clone()); 126 99 127 - // If nothing is playing, start immediately. 128 - if self.active.is_none() { 129 - self.advance(track.clone()) 130 - } else { 131 - vec![Effect::PublishSnapshot] 100 + if self.active.is_some() { 101 + return vec![Effect::PublishSnapshot]; 132 102 } 103 + 104 + self.advance(track.clone()) 133 105 } 134 106 135 107 /// User requested skip. ··· 148 120 url: active.url, 149 121 duration: active.duration, 150 122 thumbnail: active.thumbnail, 151 - played_at: self.format_played_at(), 123 + // RoomActor overrides via PersistFinished handler which sets 124 + // the real played_at timestamp in the DB. 125 + played_at: String::new(), 152 126 }, 153 127 ); 154 128 } ··· 193 167 url: active.url, 194 168 duration: active.duration, 195 169 thumbnail: active.thumbnail, 196 - played_at: self.format_played_at(), 170 + // RoomActor overrides via PersistFinished handler which sets 171 + // the real played_at timestamp in the DB. 172 + played_at: String::new(), 197 173 }, 198 174 ); 199 175 } ··· 210 186 211 187 /// Asynchronous metadata resolution completed. Updates the matching item 212 188 /// in the queue and/or the active track in-place. Does not change playback 213 - /// state — only updates display fields. 189 + /// state, only updates display fields. 214 190 fn handle_metadata_updated( 215 191 &mut self, 216 192 item_id: i64, ··· 240 216 fn advance(&mut self, next: QueuedTrack) -> Vec<Effect> { 241 217 self.queue.retain(|t| t.id != next.id); 242 218 243 - // Store active track with placeholder timestamp — RoomActor calls 219 + // Store active track with placeholder timestamp. RoomActor calls 244 220 // `resolve_started_at` with the real wall-clock time. 245 221 let info = ActiveTrackInfo { 246 222 id: next.id, ··· 266 242 active.started_at_wall = real_started_at; 267 243 } 268 244 } 269 - 270 - fn format_played_at(&self) -> String { 271 - // Simple ISO-like timestamp for frontend display. 272 - // This is a mock since we don't have Utc::now() in pure code. 273 - // RoomActor will override via PersistFinished handler. 274 - String::new() 275 - } 276 245 } 277 246 278 247 /// # Pure unit tests (no async, no DB, no IO) ··· 291 260 } 292 261 } 293 262 294 - // ── Effect ordering tests ───────────────────────────────────────────── 295 - // These verify the exact effect sequence returned by transition(). Order 296 - // matters: PersistStarted must precede StartPipeline (DB commit before 297 - // pipeline spawn), AbortPipeline must precede PersistFinished, etc. 298 - 299 263 #[test] 300 - fn idle_to_playing_effect_order() { 264 + fn test_idle_to_playing_effect_order() { 301 265 let mut s = PlaybackState::default(); 302 266 let effects = s.transition(&Event::TrackQueued(queued("A", 1)), 0); 303 267 ··· 313 277 } 314 278 315 279 #[test] 316 - fn queue_second_while_playing_effect_order() { 280 + fn test_queue_second_while_playing_effect_order() { 317 281 let mut s = PlaybackState::default(); 318 282 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 319 283 let effects = s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 321 285 assert_eq!( 322 286 effects, 323 287 vec![Effect::PublishSnapshot], 324 - "playing → queue another: just notify — pipeline untouched" 288 + "playing -> queue another: just notify, pipeline untouched" 325 289 ); 326 290 } 327 291 328 292 #[test] 329 - fn skip_with_next_effect_order() { 293 + fn test_skip_with_next_effect_order() { 330 294 let mut s = PlaybackState::default(); 331 295 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 332 296 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 354 318 } 355 319 356 320 #[test] 357 - fn skip_last_track_effect_order() { 321 + fn test_skip_last_track_effect_order() { 358 322 let mut s = PlaybackState::default(); 359 323 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 360 324 let effects = s.transition(&Event::Skip, 0); ··· 366 330 Effect::PersistFinished(1), 367 331 Effect::PublishSnapshot, 368 332 ], 369 - "skip last: abort, finalize, notify — no StartPipeline" 333 + "skip last: abort, finalize, notify, no StartPipeline" 370 334 ); 371 335 } 372 336 373 337 #[test] 374 - fn track_ended_with_next_effect_order() { 338 + fn test_track_ended_with_next_effect_order() { 375 339 let mut s = PlaybackState::default(); 376 340 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 377 341 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 380 344 assert_eq!( 381 345 &effects[..2], 382 346 &[Effect::PersistFinished(1), Effect::PersistStarted(2),], 383 - "track ended (next): finalize A, persist B started_at — no AbortPipeline" 347 + "track ended (next): finalize A, persist B started_at, no AbortPipeline" 384 348 ); 385 349 assert!( 386 350 effects ··· 395 359 } 396 360 397 361 #[test] 398 - fn track_ended_last_goes_idle_effect_order() { 362 + fn test_track_ended_last_goes_idle_effect_order() { 399 363 let mut s = PlaybackState::default(); 400 364 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 401 365 let effects = s.transition(&Event::TrackEnded { item_id: 1 }, 0); ··· 403 367 assert_eq!( 404 368 effects, 405 369 vec![Effect::PersistFinished(1), Effect::PublishSnapshot,], 406 - "track ended last: finalize, notify — no StartPipeline, no AbortPipeline" 370 + "track ended last: finalize, notify, no StartPipeline, no AbortPipeline" 407 371 ); 408 372 } 409 373 410 - // ── State invariant tests ───────────────────────────────────────────── 411 - 412 374 #[test] 413 - fn idle_room_starts_playing_on_first_track() { 375 + fn test_idle_room_starts_playing_on_first_track() { 414 376 let mut s = PlaybackState::default(); 415 377 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 416 378 ··· 420 382 } 421 383 422 384 #[test] 423 - fn second_track_stays_in_queue() { 385 + fn test_second_track_stays_in_queue() { 424 386 let mut s = PlaybackState::default(); 425 387 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 426 388 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 431 393 } 432 394 433 395 #[test] 434 - fn skip_advances_to_next_track() { 396 + fn test_skip_advances_to_next_track() { 435 397 let mut s = PlaybackState::default(); 436 398 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 437 399 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 444 406 } 445 407 446 408 #[test] 447 - fn skip_on_idle_does_nothing() { 409 + fn test_skip_on_idle_does_nothing() { 448 410 let mut s = PlaybackState::default(); 449 411 let effects = s.transition(&Event::Skip, 0); 450 412 ··· 453 415 } 454 416 455 417 #[test] 456 - fn skip_last_track_goes_idle() { 418 + fn test_skip_last_track_goes_idle() { 457 419 let mut s = PlaybackState::default(); 458 420 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 459 421 s.transition(&Event::Skip, 0); ··· 464 426 } 465 427 466 428 #[test] 467 - fn track_ended_advances_to_next() { 429 + fn test_track_ended_advances_to_next() { 468 430 let mut s = PlaybackState::default(); 469 431 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 470 432 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 476 438 } 477 439 478 440 #[test] 479 - fn track_ended_ignores_stale_id() { 441 + fn test_track_ended_ignores_stale_id() { 480 442 let mut s = PlaybackState::default(); 481 443 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 482 444 ··· 488 450 } 489 451 490 452 #[test] 491 - fn track_ended_last_track_goes_idle() { 453 + fn test_track_ended_last_track_goes_idle() { 492 454 let mut s = PlaybackState::default(); 493 455 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 494 456 s.transition(&Event::TrackEnded { item_id: 1 }, 0); ··· 498 460 } 499 461 500 462 #[test] 501 - fn resolve_started_at_updates_active_track() { 463 + fn test_resolve_started_at_updates_active_track() { 502 464 let mut s = PlaybackState::default(); 503 465 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 504 466 ··· 507 469 } 508 470 509 471 #[test] 510 - fn skip_then_track_ended_stale_is_ignored() { 472 + fn test_skip_then_track_ended_stale_is_ignored() { 511 473 let mut s = PlaybackState::default(); 512 474 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 513 475 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 520 482 } 521 483 522 484 #[test] 523 - fn resolve_started_at_noop_when_idle() { 485 + fn test_resolve_started_at_noop_when_idle() { 524 486 let mut s = PlaybackState::default(); 525 487 s.resolve_started_at(42); 526 488 assert!(s.active.is_none()); 527 489 } 528 490 529 - // ── Metadata update tests ───────────────────────────────────────── 530 - 531 491 #[test] 532 - fn metadata_update_updates_queue_item() { 492 + fn test_metadata_update_updates_queue_item() { 533 493 let mut s = PlaybackState::default(); 534 494 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 535 495 s.transition( ··· 553 513 } 554 514 555 515 #[test] 556 - fn metadata_update_on_queued_item() { 516 + fn test_metadata_update_on_queued_item() { 557 517 let mut s = PlaybackState::default(); 558 518 s.transition(&Event::TrackQueued(queued("A", 1)), 0); 559 519 s.transition(&Event::TrackQueued(queued("B", 2)), 0); ··· 574 534 } 575 535 576 536 #[test] 577 - fn metadata_update_on_unknown_id_is_noop() { 537 + fn test_metadata_update_on_unknown_id_is_noop() { 578 538 let mut s = PlaybackState::default(); 579 539 let effects = s.transition( 580 540 &Event::MetadataUpdated { ··· 585 545 }, 586 546 0, 587 547 ); 588 - // Still publishes snapshot even if nothing changed — clients may want 548 + // Still publishes snapshot even if nothing changed: clients may want 589 549 // to know the update was processed. 590 550 assert_eq!(effects, vec![Effect::PublishSnapshot]); 591 551 assert!(s.active.is_none());
+31 -87
src/transport.rs
··· 44 44 } 45 45 } 46 46 47 + #[must_use] 47 48 pub(crate) fn decode_varint(buf: &[u8]) -> Option<(u64, usize)> { 48 49 let first = *buf.first()?; 49 50 match first >> 6 { ··· 89 90 buf 90 91 } 91 92 93 + #[must_use] 92 94 fn decode_string(buf: &[u8]) -> Option<(String, usize)> { 93 95 let (len, mut offset) = decode_varint(buf)?; 94 96 let end = offset.checked_add(len as usize)?; ··· 177 179 Bytes::from(buf) 178 180 } 179 181 182 + #[must_use] 180 183 pub(crate) fn decode_message(buf: &[u8]) -> Option<(MoqMessage, usize)> { 181 184 let (msg_type, mut offset) = decode_varint(buf)?; 182 185 match msg_type { ··· 252 255 format!("moqbox/room/{room_id}") 253 256 } 254 257 255 - // --------------------------------------------------------------------------- 256 - // Track publishers 257 - // --------------------------------------------------------------------------- 258 - 259 258 /// Per-room MoQ track publishers, shared across all client sessions. 260 259 /// 261 260 /// Cloning is cheap (all inner channels are `Arc`-based). ··· 272 271 273 272 impl TrackPublishers { 274 273 pub(crate) fn new() -> Self { 275 - let (video, _) = broadcast::channel(256); 274 + let (video, _) = broadcast::channel(256); // enough for bursty media objects without backpressure 276 275 let (chat, _) = broadcast::channel(256); 277 276 let (state, _) = watch::channel(StateValue { 278 277 payload: Bytes::new(), ··· 345 344 } 346 345 347 346 /// Handle a WebSocket upgrade and run the MoQ session lifecycle. 348 - /// 349 - /// 1. Sends `ANNOUNCE` for the room namespace. 350 - /// 2. Loops, reading MoQ messages from the client. 351 - /// 3. On `SUBSCRIBE`, subscribes to the corresponding broadcast receiver 352 - /// and spawns a forward task that relays objects to the WebSocket. 353 - /// 4. On `OBJECT` with track ID 2 (chat), forwards to the chat publisher. 354 - /// 5. On close, cancels all forward tasks. 355 347 pub(crate) async fn handle_ws_session( 356 348 mut ws: WebSocket, 357 349 room_id: RoomId, ··· 373 365 374 366 let (mut ws_tx, mut ws_rx) = ws.split(); 375 367 376 - // 1. Send ANNOUNCE for this room. 377 368 let announce = encode_message(&MoqMessage::Announce { 378 369 namespace: room_namespace(&room_id.0), 379 370 }); ··· 381 372 return; 382 373 } 383 374 384 - // Register this client with the room actor. 385 375 let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); 386 376 let _ = cmd_tx 387 377 .send(RoomCommand::RegisterClient { resp: resp_tx }) ··· 391 381 .ok() 392 382 .and_then(|r| r.ok()); 393 383 394 - // Channel that broadcast forward tasks use to send objects to the WS writer. 395 - // Unbounded to avoid deadlock between subscribe processing and the WS send loop. 396 384 let (object_tx, mut object_rx) = mpsc::unbounded_channel::<Bytes>(); 397 385 398 - // Handles of spawned forward tasks, cancelled on disconnect. 399 386 let mut task_handles: Vec<tokio::task::JoinHandle<()>> = Vec::new(); 400 387 401 388 loop { ··· 423 410 break; 424 411 } 425 412 None => break, 426 - // Ignore Ping / Pong / Text frames. 427 413 _ => {} 428 414 } 429 415 } 430 416 431 - // Forward objects from broadcast receivers to the WebSocket. 432 417 obj_bytes = object_rx.recv() => { 433 418 match obj_bytes { 434 419 Some(bytes) => { ··· 436 421 break; 437 422 } 438 423 } 439 - // All senders dropped — no more objects will arrive. 440 424 None => break, 441 425 } 442 426 } 443 427 } 444 428 } 445 429 446 - // Unregister this client with the room actor. 447 430 let _ = cmd_tx.send(RoomCommand::UnregisterClient).await; 448 431 449 - // Cancel all forward tasks on disconnect. 450 432 for handle in task_handles { 451 433 handle.abort(); 452 434 } ··· 454 436 455 437 /// WebSocket session for video-only delivery. 456 438 /// 457 - /// No MoQ protocol negotiation — the server auto-subscribes the client to 439 + /// No MoQ protocol negotiation: the server auto-subscribes the client to 458 440 /// the video broadcast. Messages are raw fMP4 bytes (no MoQ framing). 459 441 /// A separate WS-control connection handles chat, state, and MoQ handshake. 460 - pub(crate) async fn handle_video_session( 461 - ws: WebSocket, 462 - publishers: TrackPublishers, 463 - ) { 442 + pub(crate) async fn handle_video_session(ws: WebSocket, publishers: TrackPublishers) { 464 443 let (mut ws_tx, mut ws_rx) = ws.split(); 465 444 466 - // Collect the cached init (or wait for the pipeline to produce one) 467 - // but don't send it yet — we need to subscribe to the broadcast first 468 - // so we can discard any init that was also published via broadcast, 469 - // which would otherwise arrive as a duplicate through the forward loop. 470 445 let init = match publishers.get_init_segment() { 471 446 Some(cached) => { 472 447 tracing::debug!("video ws: using cached init segment"); ··· 486 461 } 487 462 }; 488 463 489 - // Subscribe to the broadcast before sending cached init so that 490 - // any init published via broadcast is caught by the catch-up loop 491 - // and discarded, preventing a duplicate on the forward path. 492 464 let mut video_rx = publishers.video.subscribe(); 493 465 loop { 494 466 match video_rx.try_recv() { ··· 499 471 } 500 472 } 501 473 502 - // Now send the cached init — the broadcast buffer has been drained 503 - // so the forward loop below won't re-deliver it. 504 474 if let Some((_group_id, payload)) = init { 505 475 if ws_tx.send(Message::Binary(payload)).await.is_err() { 506 476 return; 507 477 } 508 478 } 509 479 510 - // Forward live video objects as raw fMP4 payloads. 511 480 loop { 512 481 tokio::select! { 513 482 msg = ws_rx.next() => { ··· 533 502 } 534 503 } 535 504 505 + #[allow(clippy::too_many_arguments)] 506 + // justified: all params are distinct concerns 536 507 async fn handle_moq_message( 537 508 msg: MoqMessage, 538 509 room_id: &RoomId, ··· 549 520 let track_id = match TrackId::from_name(&track) { 550 521 Some(id) => id, 551 522 None => { 552 - // Unknown track — send SUBSCRIBE_RST. 553 523 let rst = encode_message(&MoqMessage::SubscribeRst { 554 524 namespace, 555 525 track, ··· 560 530 } 561 531 }; 562 532 563 - // Send SUBSCRIBE_OK first. 564 533 let ok = encode_message(&MoqMessage::SubscribeOk { 565 534 namespace: namespace.clone(), 566 535 track: track.clone(), ··· 571 540 572 541 match track_id { 573 542 TrackId::Chat => { 574 - // Send recent chat history, then subscribe to live broadcast. 575 543 if let Ok(messages) = registry.recent_chat(room_id, 50).await { 576 544 for msg in messages.iter().rev() { 577 545 let json = serde_json::json!({ ··· 594 562 } 595 563 } 596 564 597 - // Subscribe to live chat broadcast. 598 565 let rx = publishers.chat.subscribe(); 599 566 let tx = object_tx.clone(); 600 567 task_handles.push(tokio::spawn(async move { ··· 619 586 })); 620 587 } 621 588 TrackId::State => { 622 - // State uses watch (latest-only). Send current value 623 - // immediately, then forward changes. 624 589 let initial = publishers.state.borrow().clone(); 625 590 let obj = encode_message(&MoqMessage::Object { 626 591 track_id: TrackId::State as u64, ··· 651 616 } 652 617 } 653 618 })); 654 - } 655 - _ => { 656 - // Video and audio use a dedicated WebSocket endpoint. 657 - // Ignore subscribe requests on the control WS. 619 + } 620 + _ => {} 658 621 } 659 622 } 660 - } 661 623 662 624 MoqMessage::Object { 663 625 track_id, ··· 665 627 object_id: _, 666 628 payload, 667 629 } => { 668 - // Incoming chat message from client: persist and rebroadcast. 669 630 if track_id == TrackId::Chat as u64 { 670 631 let json: serde_json::Value = 671 632 serde_json::from_slice(&payload).unwrap_or(serde_json::Value::Null); ··· 683 644 } 684 645 } 685 646 686 - // Server-initiated messages received from client are unexpected. 687 647 MoqMessage::Announce { .. } 688 648 | MoqMessage::AnnounceOk { .. } 689 649 | MoqMessage::SubscribeOk { .. } ··· 857 817 858 818 publishers.publish_video(5, 99, Bytes::from("video-frame")); 859 819 860 - let obj = rx.recv().await.expect("should receive video object"); 820 + let obj = rx.recv().await.unwrap(); 861 821 assert_eq!(obj.track_id, TrackId::Video); 862 822 assert_eq!(obj.payload, Bytes::from("video-frame")); 863 823 } ··· 869 829 870 830 publishers.publish_chat(0, Bytes::from("hello")); 871 831 872 - let obj = rx.recv().await.expect("should receive chat object"); 832 + let obj = rx.recv().await.unwrap(); 873 833 assert_eq!(obj.track_id, TrackId::Chat); 874 834 assert_eq!(obj.object_id, 0); 875 835 assert_eq!(obj.payload, Bytes::from("hello")); ··· 884 844 publishers.publish_chat(0, Bytes::from("second")); 885 845 publishers.publish_chat(0, Bytes::from("third")); 886 846 887 - let obj1 = rx.recv().await.expect("msg 1"); 847 + let obj1 = rx.recv().await.unwrap(); 888 848 assert_eq!(obj1.object_id, 0); 889 849 assert_eq!(obj1.payload, Bytes::from("first")); 890 850 891 - let obj2 = rx.recv().await.expect("msg 2"); 851 + let obj2 = rx.recv().await.unwrap(); 892 852 assert_eq!(obj2.object_id, 1); 893 853 assert_eq!(obj2.payload, Bytes::from("second")); 894 854 895 - let obj3 = rx.recv().await.expect("msg 3"); 855 + let obj3 = rx.recv().await.unwrap(); 896 856 assert_eq!(obj3.object_id, 2); 897 857 assert_eq!(obj3.payload, Bytes::from("third")); 898 858 } ··· 904 864 905 865 publishers.publish_state(Bytes::from("playing")); 906 866 907 - // Watch receiver needs changed() to observe the update. 908 - rx.changed().await.expect("state should change"); 867 + rx.changed().await.unwrap(); 909 868 let val = rx.borrow().clone(); 910 869 assert_eq!(val.payload, Bytes::from("playing")); 911 870 assert_eq!(val.seq, 1); // started at 0, now 1 ··· 919 878 920 879 publishers.publish_video(0, 0, Bytes::from("fanout")); 921 880 922 - let obj1 = rx1.recv().await.expect("subscriber 1"); 923 - let obj2 = rx2.recv().await.expect("subscriber 2"); 881 + let obj1 = rx1.recv().await.unwrap(); 882 + let obj2 = rx2.recv().await.unwrap(); 924 883 assert_eq!(obj1.payload, Bytes::from("fanout")); 925 884 assert_eq!(obj2.payload, Bytes::from("fanout")); 926 885 } 927 - 928 - // ── Init segment cache tests ─────────────────────────────────────────── 929 886 930 887 #[test] 931 888 fn test_init_cache_and_retrieve() { 932 889 let publishers = TrackPublishers::new(); 933 890 934 - assert!( 935 - publishers.get_init_segment().is_none(), 936 - "init should be None before caching" 937 - ); 891 + assert!(publishers.get_init_segment().is_none()); 938 892 939 893 let payload = Bytes::from("ftyp-moov-data"); 940 894 publishers.cache_init_segment(payload.clone(), 42); 941 895 942 896 let retrieved = publishers.get_init_segment(); 943 - assert!(retrieved.is_some(), "init should be Some after caching"); 897 + assert!(retrieved.is_some()); 944 898 let (group_id, data) = retrieved.unwrap(); 945 - assert_eq!(group_id, 42, "group_id should match what was cached"); 946 - assert_eq!(data, payload, "payload should match what was cached"); 899 + assert_eq!(group_id, 42); 900 + assert_eq!(data, payload); 947 901 } 948 902 949 903 #[tokio::test] ··· 951 905 let publishers = TrackPublishers::new(); 952 906 953 907 let mut waiter = publishers.video_init_waiter(); 954 - assert!(!*waiter.borrow(), "initial watch value should be false"); 908 + assert!(!*waiter.borrow()); 955 909 956 910 let pubs = publishers.clone(); 957 911 tokio::spawn(async move { ··· 961 915 962 916 let _ = tokio::time::timeout(std::time::Duration::from_secs(2), waiter.changed()) 963 917 .await 964 - .expect("watch should fire within timeout when init is cached"); 918 + .expect("watch should fire within timeout"); 965 919 966 - assert!( 967 - *waiter.borrow(), 968 - "watch value should be true after cache_init_segment" 969 - ); 920 + assert!(*waiter.borrow()); 970 921 971 922 let retrieved = publishers.get_init_segment(); 972 - assert!( 973 - retrieved.is_some(), 974 - "init should be retrievable after watch fires" 975 - ); 923 + assert!(retrieved.is_some()); 976 924 let (group_id, data) = retrieved.unwrap(); 977 - assert_eq!(group_id, 1, "group_id should match"); 978 - assert_eq!(data, Bytes::from("init-payload"), "payload should match"); 925 + assert_eq!(group_id, 1); 926 + assert_eq!(data, Bytes::from("init-payload")); 979 927 } 980 928 981 929 #[test] ··· 986 934 publishers.cache_init_segment(Bytes::from("init-2"), 2); 987 935 988 936 let retrieved = publishers.get_init_segment(); 989 - assert!(retrieved.is_some(), "init should be cached"); 937 + assert!(retrieved.is_some()); 990 938 let (group_id, data) = retrieved.unwrap(); 991 - assert_eq!(group_id, 2, "group_id should be from the latest cache call"); 992 - assert_eq!( 993 - data, 994 - Bytes::from("init-2"), 995 - "payload should be from the latest cache call" 996 - ); 939 + assert_eq!(group_id, 2); 940 + assert_eq!(data, Bytes::from("init-2")); 997 941 } 998 942 }
+7 -9
src/types.rs
··· 49 49 50 50 /// Metadata for a single queued track. 51 51 /// 52 - /// Source-agnostic — produced by any [`MediaSource`](crate::source::MediaSource) 53 - /// implementation (yt-dlp, direct URL detection, etc.). 52 + /// Source-agnostic: produced by any [`MediaSource`](crate::source::MediaSource) 53 + /// implementation. 54 54 #[derive(Debug, Clone, Serialize)] 55 55 pub(crate) struct TrackMeta { 56 56 pub title: String, ··· 68 68 pub(crate) title: String, 69 69 pub(crate) url: String, 70 70 pub(crate) duration: String, 71 + pub(crate) thumbnail: Option<String>, 71 72 pub(crate) started_at: i64, 72 73 } 73 74 ··· 145 146 pub(crate) seq: u64, 146 147 } 147 148 148 - /// All room operations — processed sequentially by the per-room actor. 149 + /// All room operations, processed sequentially by the per-room actor. 149 150 pub(crate) enum RoomCommand { 150 - /// Track with already-resolved metadata (legacy path, used by tests). 151 - #[allow(dead_code)] 152 - QueueTrack(TrackMeta), 153 - /// Raw URL — metadata extraction happens asynchronously in a spawned task. 151 + /// Raw URL: metadata extraction happens asynchronously in a spawned task. 154 152 QueueUrl(String), 155 153 /// Async metadata extraction result. 156 154 MetadataReady { ··· 188 186 pub(crate) url: String, 189 187 pub(crate) duration: String, 190 188 pub(crate) thumbnail: Option<String>, 191 - /// Epoch ms when the track started — for client-side elapsed computation. 189 + /// Epoch ms when the track started, for client-side elapsed computation. 192 190 pub(crate) started_at_wall: i64, 193 191 } 194 192 195 - /// Public handle to a room — allows sending commands and reading publishers. 193 + /// Public handle to a room: allows sending commands and reading publishers. 196 194 #[derive(Clone)] 197 195 pub(crate) struct RoomHandle { 198 196 pub(crate) cmd_tx: mpsc::Sender<RoomCommand>,
+5
src/util.rs
··· 1 + pub(crate) fn now_iso() -> String { 2 + chrono::Utc::now() 3 + .format("%Y-%m-%dT%H:%M:%S%.3fZ") 4 + .to_string() 5 + }
+16 -45
src/web.rs
··· 1 - //! SSR frontend — Axum router, handlers, and templates. 1 + //! SSR frontend: Axum router, handlers, and templates. 2 2 3 3 use std::collections::HashMap; 4 4 use std::net::SocketAddr; ··· 59 59 pub rate_limiter: RateLimiter, 60 60 } 61 61 62 - pub(crate) fn router( 63 - rooms: room::Registry, 64 - pool: Pool<Sqlite>, 65 - ) -> Router { 62 + pub(crate) fn router(rooms: room::Registry, pool: Pool<Sqlite>) -> Router { 66 63 let rate_limiter = RateLimiter::new(); 67 64 68 - // Periodic cleanup for rate limiter entries (every 30s). 69 - let rl = rate_limiter.clone(); 65 + let cleanup_limiter = rate_limiter.clone(); 70 66 tokio::spawn(async move { 71 67 let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); 72 68 loop { 73 69 interval.tick().await; 74 - rl.cleanup().await; 70 + cleanup_limiter.cleanup().await; 75 71 } 76 72 }); 77 73 ··· 123 119 State(state): State<AppState>, 124 120 Path(id): Path<String>, 125 121 ) -> Result<Html<String>, StatusCode> { 126 - // Validate room ID format (snowflake digits only). 127 122 if !id.chars().all(|c| c.is_ascii_digit()) { 128 123 return Err(StatusCode::BAD_REQUEST); 129 124 } ··· 131 126 let queue = state.rooms.queue(&room_id).await; 132 127 let queue_empty = queue.is_empty(); 133 128 134 - // Fetch history via Playlist. 135 129 let history = { 136 130 let playlist = crate::playlist::Playlist::new(state.pool.clone(), &id); 137 131 playlist ··· 144 138 duration: i.duration, 145 139 thumbnail: i.thumbnail, 146 140 url: i.url, 147 - source: i 148 - .source 149 - .parse() 150 - .unwrap_or_else(|s: String| { 151 - tracing::warn!(source = %s, "unknown source kind in queue_items"); 152 - crate::types::SourceKind::Direct 153 - }), 141 + source: i.source.parse().unwrap_or_else(|s: String| { 142 + tracing::warn!(source = %s, "unknown source kind in queue_items"); 143 + crate::types::SourceKind::Direct 144 + }), 154 145 }) 155 146 .collect::<Vec<_>>() 156 147 }; ··· 172 163 173 164 async fn create_room(State(state): State<AppState>) -> Result<impl IntoResponse, StatusCode> { 174 165 let room = state.rooms.create().await; 175 - Ok(Json(serde_json::json!({ "room_id": room.id.0 }))) 166 + Ok(Json(serde_json::json!({ "room_id": room.0 }))) 176 167 } 177 168 178 169 #[derive(serde::Deserialize)] ··· 190 181 ConnectInfo(addr): ConnectInfo<SocketAddr>, 191 182 axum::Json(req): axum::Json<IngestRequest>, 192 183 ) -> Result<Json<serde_json::Value>, axum::response::Response> { 193 - // Input validation — URL max 2048 characters. 194 184 if req.url.len() > 2048 { 185 + // 2048 is the practical URL max across browsers 195 186 return Err(err_response(StatusCode::BAD_REQUEST, "URL too long")); 196 187 } 197 188 198 - // Input validation — room ID must be digits (snowflake format). 199 189 if !req.room_id.chars().all(|c| c.is_ascii_digit()) { 200 190 return Err(err_response(StatusCode::BAD_REQUEST, "invalid room ID")); 201 191 } 202 192 203 - // Rate limiting — max 1 request per 5 seconds per IP. 204 193 if let Err(retry_after) = state.rate_limiter.check(addr).await { 205 194 let mut res = err_response(StatusCode::TOO_MANY_REQUESTS, "rate limited"); 206 195 res.headers_mut().insert( 207 196 axum::http::header::RETRY_AFTER, 208 - retry_after.as_secs().to_string().parse().expect("valid retry-after seconds"), 197 + retry_after 198 + .as_secs() 199 + .to_string() 200 + .parse() 201 + .expect("valid retry-after seconds"), 209 202 ); 210 203 return Err(res); 211 204 } 212 205 213 206 let room_id = RoomId(req.room_id); 214 207 215 - let t0 = std::time::Instant::now(); 216 - 217 - // Validate the room exists. 218 208 if !state.rooms.exists(&room_id).await { 219 209 return Err(err_response(StatusCode::NOT_FOUND, "room not found")); 220 210 } 221 - let t1 = t0.elapsed(); 222 211 223 - // Push URL to room — metadata extraction runs async, no client wait. 224 212 state.rooms.push_url(&room_id, req.url).await; 225 - let t2 = t0.elapsed(); 226 - 227 - tracing::info!( 228 - "ingest_url timing: exists={:.0?} push_url={:.0?} total={:.0?}", 229 - t1, t2 - t1, t2 230 - ); 231 213 232 214 Ok(Json(serde_json::json!({ "ok": true }))) 233 215 } ··· 243 225 ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { 244 226 let room_id = RoomId(req.room_id); 245 227 246 - // Validate the room exists. 247 228 if !state.rooms.exists(&room_id).await { 248 229 return Err(( 249 230 StatusCode::NOT_FOUND, ··· 256 237 Ok(Json(serde_json::json!({ "ok": true }))) 257 238 } 258 239 259 - /// WebSocket upgrade endpoint: `/ws/{room_id}?name={user_name}`. 260 - /// 261 - /// Validates the room exists, then upgrades to the MoQ session handler. 262 240 async fn ws_handler( 263 241 ws: WebSocketUpgrade, 264 242 Path(room_id): Path<String>, 265 243 Query(params): Query<HashMap<String, String>>, 266 244 State(state): State<AppState>, 267 245 ) -> Result<impl IntoResponse, (StatusCode, &'static str)> { 268 - // Validate room ID format (snowflake digits only). 269 246 if !room_id.chars().all(|c| c.is_ascii_digit()) { 270 247 return Err((StatusCode::BAD_REQUEST, "invalid room ID")); 271 248 } 272 249 273 250 let rid = RoomId(room_id); 274 251 275 - // Reject unknown rooms before upgrading. 276 252 if !state.rooms.exists(&rid).await { 277 253 return Err((StatusCode::NOT_FOUND, "room not found")); 278 254 } ··· 288 264 .cloned() 289 265 .unwrap_or_else(|| "anonymous".into()); 290 266 291 - // Validate username. 292 267 if user_name.is_empty() || user_name.len() > 32 { 268 + // 32 chars is enough for display names 293 269 return Err((StatusCode::BAD_REQUEST, "invalid username")); 294 270 } 295 271 ··· 306 282 })) 307 283 } 308 284 309 - /// WebSocket upgrade endpoint for video-only delivery: `/ws/{room_id}/video`. 310 - /// 311 - /// No MoQ protocol — server auto-subscribes to the video broadcast and 312 - /// forwards raw fMP4 payloads. The client identifies init segments by 313 - /// checking for the ftyp box in the data. 314 285 async fn video_ws_handler( 315 286 ws: WebSocketUpgrade, 316 287 Path(room_id): Path<String>,
+155 -157
templates/index.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 3 <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>moqbox</title> 7 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 9 <link 10 10 href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 11 rel="stylesheet" 12 - /> 12 + > 13 13 <style> 14 - :root { 15 - --base: #24273a; 16 - --mantle: #1e2030; 17 - --crust: #181926; 18 - --surface0: #363a4f; 19 - --surface1: #494d64; 20 - --surface2: #5b6078; 21 - --overlay0: #6c7086; 22 - --overlay1: #7f849c; 23 - --overlay2: #939ab7; 24 - --subtext0: #a5adcb; 25 - --subtext1: #b8c0e0; 26 - --text: #cad3f5; 27 - --lavender: #b7bdf8; 28 - --blue: #8aadf4; 29 - --sapphire: #7dc4e4; 30 - --sky: #91d7e3; 31 - --teal: #8bd5ca; 32 - --green: #a6da95; 33 - --yellow: #eed49f; 34 - --peach: #f5a97f; 35 - --maroon: #ee99a0; 36 - --red: #ed8796; 37 - --mauve: #c6a0f6; 38 - --pink: #f5bde6; 39 - --flamingo: #f0c6c6; 40 - --rosewater: #f4dbd6; 41 - --radius-sm: 6px; 42 - --radius-md: 10px; 43 - --radius-lg: 14px; 44 - --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 45 - } 46 - *, 47 - *::before, 48 - *::after { 49 - box-sizing: border-box; 50 - margin: 0; 51 - padding: 0; 52 - } 53 - html { 54 - -webkit-font-smoothing: antialiased; 55 - -moz-osx-font-smoothing: grayscale; 56 - } 57 - body { 58 - font-family: var(--font-sans); 59 - background: var(--base); 60 - color: var(--text); 61 - min-height: 100vh; 62 - display: grid; 63 - place-items: center; 64 - padding: 1.5rem; 65 - } 66 - .card { 67 - background: var(--surface0); 68 - border-radius: var(--radius-lg); 69 - box-shadow: 70 - 0 8px 32px rgba(0, 0, 0, 0.35), 71 - 0 2px 8px rgba(0, 0, 0, 0.2); 72 - padding: 3rem 2.5rem; 73 - max-width: 420px; 74 - width: 100%; 75 - text-align: center; 76 - } 77 - h1 { 78 - font-size: 2.5rem; 79 - font-weight: 700; 80 - letter-spacing: -0.02em; 81 - text-wrap: balance; 82 - color: var(--text); 83 - } 84 - .card p { 85 - color: var(--subtext0); 86 - margin-top: 0.75rem; 87 - line-height: 1.6; 88 - font-size: 0.9375rem; 89 - } 90 - .actions { 91 - margin-top: 2rem; 92 - display: flex; 93 - flex-direction: column; 94 - gap: 0.75rem; 95 - } 96 - input, 97 - button { 98 - font: inherit; 99 - padding: 0.75rem 1.25rem; 100 - border-radius: var(--radius-md); 101 - border: none; 102 - background: var(--mantle); 103 - color: var(--text); 104 - font-size: 1rem; 105 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 106 - } 107 - input { 108 - transition: box-shadow 150ms ease-out; 109 - } 110 - input:focus { 111 - outline: none; 112 - box-shadow: 113 - 0 0 0 2px var(--blue), 114 - 0 4px 16px rgba(0, 0, 0, 0.3); 115 - } 116 - input::placeholder { 117 - color: var(--overlay0); 118 - } 119 - button { 120 - background: var(--green); 121 - color: var(--crust); 122 - cursor: pointer; 123 - font-weight: 600; 124 - transition: 125 - transform 150ms ease-out, 126 - box-shadow 150ms ease-out, 127 - filter 150ms ease-out; 128 - min-height: 48px; 129 - display: inline-flex; 130 - align-items: center; 131 - justify-content: center; 132 - } 133 - button:hover { 134 - filter: brightness(1.12); 135 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 136 - } 137 - button:active { 138 - transform: scale(0.96); 139 - } 140 - .divider { 141 - color: var(--overlay0); 142 - font-size: 0.875rem; 143 - } 144 - .footer { 145 - margin-top: 3rem; 146 - font-size: 0.875rem; 147 - } 148 - .footer a { 149 - color: var(--blue); 150 - text-decoration: none; 151 - transition: color 150ms ease-out; 152 - } 153 - .footer a:hover { 154 - color: var(--sapphire); 155 - } 14 + :root { 15 + --base: #24273a; 16 + --mantle: #1e2030; 17 + --crust: #181926; 18 + --surface0: #363a4f; 19 + --surface1: #494d64; 20 + --surface2: #5b6078; 21 + --overlay0: #6c7086; 22 + --overlay1: #7f849c; 23 + --overlay2: #939ab7; 24 + --subtext0: #a5adcb; 25 + --subtext1: #b8c0e0; 26 + --text: #cad3f5; 27 + --lavender: #b7bdf8; 28 + --blue: #8aadf4; 29 + --sapphire: #7dc4e4; 30 + --sky: #91d7e3; 31 + --teal: #8bd5ca; 32 + --green: #a6da95; 33 + --yellow: #eed49f; 34 + --peach: #f5a97f; 35 + --maroon: #ee99a0; 36 + --red: #ed8796; 37 + --mauve: #c6a0f6; 38 + --pink: #f5bde6; 39 + --flamingo: #f0c6c6; 40 + --rosewater: #f4dbd6; 41 + --radius-sm: 6px; 42 + --radius-md: 10px; 43 + --radius-lg: 14px; 44 + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 45 + } 46 + *, 47 + *::before, 48 + *::after { 49 + box-sizing: border-box; 50 + margin: 0; 51 + padding: 0; 52 + } 53 + html { 54 + -webkit-font-smoothing: antialiased; 55 + -moz-osx-font-smoothing: grayscale; 56 + } 57 + body { 58 + font-family: var(--font-sans); 59 + background: var(--base); 60 + color: var(--text); 61 + min-height: 100vh; 62 + display: grid; 63 + place-items: center; 64 + padding: 1.5rem; 65 + } 66 + .card { 67 + background: var(--surface0); 68 + border-radius: var(--radius-lg); 69 + box-shadow: 70 + 0 8px 32px rgba(0, 0, 0, 0.35), 71 + 0 2px 8px rgba(0, 0, 0, 0.2); 72 + padding: 3rem 2.5rem; 73 + max-width: 420px; 74 + width: 100%; 75 + text-align: center; 76 + } 77 + h1 { 78 + font-size: 2.5rem; 79 + font-weight: 700; 80 + letter-spacing: -0.02em; 81 + text-wrap: balance; 82 + color: var(--text); 83 + } 84 + .card p { 85 + color: var(--subtext0); 86 + margin-top: 0.75rem; 87 + line-height: 1.6; 88 + font-size: 0.9375rem; 89 + } 90 + .actions { 91 + margin-top: 2rem; 92 + display: flex; 93 + flex-direction: column; 94 + gap: 0.75rem; 95 + } 96 + input, 97 + button { 98 + font: inherit; 99 + padding: 0.75rem 1.25rem; 100 + border-radius: var(--radius-md); 101 + border: none; 102 + background: var(--mantle); 103 + color: var(--text); 104 + font-size: 1rem; 105 + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 106 + } 107 + input { 108 + transition: box-shadow 150ms ease-out; 109 + } 110 + input:focus { 111 + outline: none; 112 + box-shadow: 113 + 0 0 0 2px var(--blue), 114 + 0 4px 16px rgba(0, 0, 0, 0.3); 115 + } 116 + input::placeholder { 117 + color: var(--overlay0); 118 + } 119 + button { 120 + background: var(--green); 121 + color: var(--crust); 122 + cursor: pointer; 123 + font-weight: 600; 124 + transition: 125 + transform 150ms ease-out, 126 + box-shadow 150ms ease-out, 127 + filter 150ms ease-out; 128 + min-height: 48px; 129 + display: inline-flex; 130 + align-items: center; 131 + justify-content: center; 132 + } 133 + button:hover { 134 + filter: brightness(1.12); 135 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 136 + } 137 + button:active { 138 + transform: scale(0.96); 139 + } 140 + .divider { 141 + color: var(--overlay0); 142 + font-size: 0.875rem; 143 + } 144 + .footer { 145 + margin-top: 3rem; 146 + font-size: 0.875rem; 147 + } 148 + .footer a { 149 + color: var(--blue); 150 + text-decoration: none; 151 + transition: color 150ms ease-out; 152 + } 153 + .footer a:hover { 154 + color: var(--sapphire); 155 + } 156 156 </style> 157 157 </head> 158 158 <body> 159 159 <main class="card"> 160 160 <h1>moqbox</h1> 161 161 <p> 162 - Ultra-low latency shared media rooms over MoQ.<br /> 162 + Ultra-low latency shared media rooms over MoQ.<br> 163 163 Paste a link from YouTube, SoundCloud, or Bandcamp to start. 164 164 </p> 165 165 ··· 172 172 placeholder="Enter room code..." 173 173 maxlength="64" 174 174 onkeydown="if(event.key==='Enter'){window.location='/room/'+this.value}" 175 - /> 175 + > 176 176 </div> 177 177 178 178 <div class="footer"> 179 - <a href="https://tangled.org/karitham.dev/moqbox" 180 - >moqbox v{{ version }}</a 181 - > 179 + <a href="https://tangled.org/karitham.dev/moqbox">moqbox v{{ version }}</a> 182 180 </div> 183 181 </main> 184 182 185 183 <script> 186 - async function createRoom() { 187 - const resp = await fetch("/api/rooms", { method: "POST" }); 188 - const data = await resp.json(); 189 - window.location.href = "/room/" + data.room_id; 190 - } 184 + async function createRoom() { 185 + const resp = await fetch("/api/rooms", { method: "POST" }); 186 + const data = await resp.json(); 187 + window.location.href = "/room/" + data.room_id; 188 + } 191 189 </script> 192 190 </body> 193 191 </html>
+1350 -1404
templates/room.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 3 <head> 4 - <meta charset="UTF-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 6 <title>{{ room_name }} — moqbox</title> 7 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 7 + <link rel="preconnect" href="https://fonts.googleapis.com"> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 9 <link 10 10 href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 11 rel="stylesheet" 12 - /> 12 + > 13 13 <style> 14 - :root { 15 - --base: #24273a; 16 - --mantle: #1e2030; 17 - --crust: #181926; 18 - --surface0: #363a4f; 19 - --surface1: #494d64; 20 - --surface2: #5b6078; 21 - --overlay0: #6c7086; 22 - --overlay1: #7f849c; 23 - --overlay2: #939ab7; 24 - --subtext0: #a5adcb; 25 - --subtext1: #b8c0e0; 26 - --text: #cad3f5; 27 - --lavender: #b7bdf8; 28 - --blue: #8aadf4; 29 - --sapphire: #7dc4e4; 30 - --sky: #91d7e3; 31 - --teal: #8bd5ca; 32 - --green: #a6da95; 33 - --yellow: #eed49f; 34 - --peach: #f5a97f; 35 - --maroon: #ee99a0; 36 - --red: #ed8796; 37 - --mauve: #c6a0f6; 38 - --pink: #f5bde6; 39 - --flamingo: #f0c6c6; 40 - --rosewater: #f4dbd6; 41 - --radius-sm: 6px; 42 - --radius-md: 10px; 43 - --radius-lg: 14px; 44 - --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 45 - --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 46 - --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 47 - --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 48 - --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); 49 - } 50 - *, 51 - *::before, 52 - *::after { 53 - box-sizing: border-box; 54 - margin: 0; 55 - padding: 0; 56 - } 57 - html { 58 - -webkit-font-smoothing: antialiased; 59 - -moz-osx-font-smoothing: grayscale; 60 - } 61 - html, body { 62 - height: 100%; 63 - } 64 - body { 65 - font-family: var(--font-sans); 66 - background: var(--base); 67 - color: var(--text); 68 - padding: 1rem 1.5rem; 69 - display: flex; 70 - flex-direction: column; 71 - } 14 + :root { 15 + --base: #24273a; 16 + --mantle: #1e2030; 17 + --crust: #181926; 18 + --surface0: #363a4f; 19 + --surface1: #494d64; 20 + --surface2: #5b6078; 21 + --overlay0: #6c7086; 22 + --overlay1: #7f849c; 23 + --overlay2: #939ab7; 24 + --subtext0: #a5adcb; 25 + --subtext1: #b8c0e0; 26 + --text: #cad3f5; 27 + --lavender: #b7bdf8; 28 + --blue: #8aadf4; 29 + --sapphire: #7dc4e4; 30 + --sky: #91d7e3; 31 + --teal: #8bd5ca; 32 + --green: #a6da95; 33 + --yellow: #eed49f; 34 + --peach: #f5a97f; 35 + --maroon: #ee99a0; 36 + --red: #ed8796; 37 + --mauve: #c6a0f6; 38 + --pink: #f5bde6; 39 + --flamingo: #f0c6c6; 40 + --rosewater: #f4dbd6; 41 + --radius-sm: 6px; 42 + --radius-md: 10px; 43 + --radius-lg: 14px; 44 + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 45 + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 46 + --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 47 + --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 48 + --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); 49 + } 50 + *, 51 + *::before, 52 + *::after { 53 + box-sizing: border-box; 54 + margin: 0; 55 + padding: 0; 56 + } 57 + html { 58 + -webkit-font-smoothing: antialiased; 59 + -moz-osx-font-smoothing: grayscale; 60 + } 61 + html, 62 + body { 63 + height: 100%; 64 + } 65 + body { 66 + font-family: var(--font-sans); 67 + background: var(--base); 68 + color: var(--text); 69 + padding: 1rem 1.5rem; 70 + display: flex; 71 + flex-direction: column; 72 + } 73 + 74 + /* Top bar */ 75 + .top-bar { 76 + display: flex; 77 + align-items: center; 78 + justify-content: space-between; 79 + margin-bottom: 1rem; 80 + gap: 1rem; 81 + } 82 + .top-bar-home { 83 + color: var(--overlay1); 84 + text-decoration: none; 85 + font-size: 0.875rem; 86 + font-weight: 600; 87 + letter-spacing: 0.02em; 88 + text-transform: uppercase; 89 + flex-shrink: 0; 90 + transition: color 150ms ease-out; 91 + } 92 + .top-bar-home:hover { 93 + color: var(--text); 94 + } 95 + .top-bar-center { 96 + flex: 1; 97 + text-align: center; 98 + min-width: 0; 99 + } 100 + .room-name { 101 + display: block; 102 + font-weight: 700; 103 + font-size: 1.125rem; 104 + color: var(--text); 105 + line-height: 1.3; 106 + text-wrap: balance; 107 + } 108 + .room-id { 109 + display: block; 110 + font-size: 0.75rem; 111 + color: var(--overlay0); 112 + font-variant-numeric: tabular-nums; 113 + letter-spacing: 0.03em; 114 + } 115 + .top-bar-actions { 116 + display: flex; 117 + align-items: center; 118 + gap: 0.5rem; 119 + flex-shrink: 0; 120 + } 121 + .btn-icon { 122 + display: inline-flex; 123 + align-items: center; 124 + justify-content: center; 125 + width: 36px; 126 + height: 36px; 127 + border-radius: var(--radius-sm); 128 + border: none; 129 + background: var(--surface0); 130 + color: var(--subtext0); 131 + cursor: pointer; 132 + font-size: 1rem; 133 + line-height: 1; 134 + transition: 135 + background 150ms ease-out, 136 + color 150ms ease-out; 137 + } 138 + .btn-icon:hover { 139 + background: var(--surface1); 140 + color: var(--text); 141 + } 142 + .btn-icon:active { 143 + transform: scale(0.92); 144 + } 145 + .viewer-pill { 146 + display: inline-flex; 147 + align-items: center; 148 + gap: 0.25rem; 149 + padding: 0.25rem 0.625rem; 150 + border-radius: 999px; 151 + background: var(--surface0); 152 + color: var(--overlay1); 153 + font-size: 0.75rem; 154 + font-weight: 500; 155 + white-space: nowrap; 156 + font-variant-numeric: tabular-nums; 157 + } 72 158 73 - /* Top bar */ 74 - .top-bar { 75 - display: flex; 76 - align-items: center; 77 - justify-content: space-between; 78 - margin-bottom: 1rem; 79 - gap: 1rem; 80 - } 81 - .top-bar-home { 82 - color: var(--overlay1); 83 - text-decoration: none; 84 - font-size: 0.875rem; 85 - font-weight: 600; 86 - letter-spacing: 0.02em; 87 - text-transform: uppercase; 88 - flex-shrink: 0; 89 - transition: color 150ms ease-out; 90 - } 91 - .top-bar-home:hover { 92 - color: var(--text); 93 - } 94 - .top-bar-center { 95 - flex: 1; 96 - text-align: center; 97 - min-width: 0; 98 - } 99 - .room-name { 100 - display: block; 101 - font-weight: 700; 102 - font-size: 1.125rem; 103 - color: var(--text); 104 - line-height: 1.3; 105 - text-wrap: balance; 106 - } 107 - .room-id { 108 - display: block; 109 - font-size: 0.75rem; 110 - color: var(--overlay0); 111 - font-variant-numeric: tabular-nums; 112 - letter-spacing: 0.03em; 113 - } 114 - .top-bar-actions { 115 - display: flex; 116 - align-items: center; 117 - gap: 0.5rem; 118 - flex-shrink: 0; 119 - } 120 - .btn-icon { 121 - display: inline-flex; 122 - align-items: center; 123 - justify-content: center; 124 - width: 36px; 125 - height: 36px; 126 - border-radius: var(--radius-sm); 127 - border: none; 128 - background: var(--surface0); 129 - color: var(--subtext0); 130 - cursor: pointer; 131 - font-size: 1rem; 132 - line-height: 1; 133 - transition: background 150ms ease-out, color 150ms ease-out; 134 - } 135 - .btn-icon:hover { 136 - background: var(--surface1); 137 - color: var(--text); 138 - } 139 - .btn-icon:active { 140 - transform: scale(0.92); 141 - } 142 - .viewer-pill { 143 - display: inline-flex; 144 - align-items: center; 145 - gap: 0.25rem; 146 - padding: 0.25rem 0.625rem; 147 - border-radius: 999px; 148 - background: var(--surface0); 149 - color: var(--overlay1); 150 - font-size: 0.75rem; 151 - font-weight: 500; 152 - white-space: nowrap; 153 - font-variant-numeric: tabular-nums; 154 - } 159 + /* Layout */ 160 + .layout { 161 + display: flex; 162 + gap: 1rem; 163 + flex: 1; 164 + min-height: 0; 165 + } 166 + .main-col { 167 + flex: 1; 168 + min-width: 0; 169 + display: flex; 170 + flex-direction: column; 171 + overflow-y: auto; 172 + } 173 + .chat-col { 174 + width: 320px; 175 + flex-shrink: 0; 176 + display: flex; 177 + flex-direction: column; 178 + min-height: 0; 179 + border-left: 1px solid var(--surface2); 180 + padding-left: 1rem; 181 + } 155 182 156 - /* Layout */ 157 - .layout { 158 - display: flex; 159 - gap: 1rem; 160 - flex: 1; 161 - min-height: 0; 162 - } 163 - .main-col { 164 - flex: 1; 165 - min-width: 0; 166 - display: flex; 167 - flex-direction: column; 168 - overflow-y: auto; 169 - } 170 - .chat-col { 171 - width: 320px; 172 - flex-shrink: 0; 173 - display: flex; 174 - flex-direction: column; 175 - min-height: 0; 176 - border-left: 1px solid var(--surface2); 177 - padding-left: 1rem; 178 - } 183 + /* Video */ 184 + #video-container { 185 + position: relative; 186 + width: 100%; 187 + margin-bottom: 1rem; 188 + border-radius: var(--radius-lg); 189 + overflow: hidden; 190 + background: var(--crust); 191 + box-shadow: var(--shadow-card); 192 + } 193 + #video-player { 194 + width: 100%; 195 + aspect-ratio: 16 / 9; 196 + max-height: 60vh; 197 + object-fit: contain; 198 + display: block; 199 + background: var(--crust); 200 + } 201 + /* Video idle state (no track playing) */ 202 + #video-container.idle { 203 + display: flex; 204 + align-items: center; 205 + justify-content: center; 206 + min-height: 200px; 207 + } 208 + #video-container.idle #video-player { 209 + display: none; 210 + } 211 + #video-container.idle .idle-msg { 212 + display: block; 213 + } 214 + .idle-msg { 215 + display: none; 216 + color: var(--overlay0); 217 + font-size: 0.9375rem; 218 + text-align: center; 219 + padding: 3rem 1.5rem; 220 + } 221 + .idle-msg .icon { 222 + font-size: 2rem; 223 + display: block; 224 + margin-bottom: 0.5rem; 225 + opacity: 0.5; 226 + } 179 227 180 - /* Video */ 181 - #video-container { 182 - position: relative; 183 - width: 100%; 184 - margin-bottom: 1rem; 185 - border-radius: var(--radius-lg); 186 - overflow: hidden; 187 - background: var(--crust); 188 - box-shadow: var(--shadow-card); 189 - } 190 - #video-player { 191 - width: 100%; 192 - aspect-ratio: 16 / 9; 193 - max-height: 60vh; 194 - object-fit: contain; 195 - display: block; 196 - background: var(--crust); 197 - } 198 - /* Video idle state (no track playing) */ 199 - #video-container.idle { 200 - display: flex; 201 - align-items: center; 202 - justify-content: center; 203 - min-height: 200px; 204 - } 205 - #video-container.idle #video-player { 206 - display: none; 207 - } 208 - #video-container.idle .idle-msg { 209 - display: block; 210 - } 211 - .idle-msg { 212 - display: none; 213 - color: var(--overlay0); 214 - font-size: 0.9375rem; 215 - text-align: center; 216 - padding: 3rem 1.5rem; 217 - } 218 - .idle-msg .icon { 219 - font-size: 2rem; 220 - display: block; 221 - margin-bottom: 0.5rem; 222 - opacity: 0.5; 223 - } 228 + /* Track info overlay on video */ 229 + #track-overlay { 230 + position: absolute; 231 + bottom: 0; 232 + left: 0; 233 + right: 0; 234 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); 235 + padding: 2rem 1rem 0.75rem; 236 + opacity: 0; 237 + transition: opacity 200ms ease-out; 238 + pointer-events: none; 239 + z-index: 5; 240 + } 241 + #video-container:hover #track-overlay, 242 + #track-overlay.show { 243 + opacity: 1; 244 + pointer-events: auto; 245 + } 246 + .to-row { 247 + display: flex; 248 + align-items: center; 249 + gap: 0.75rem; 250 + } 251 + .to-thumb { 252 + width: 36px; 253 + height: 36px; 254 + border-radius: var(--radius-sm); 255 + object-fit: cover; 256 + flex-shrink: 0; 257 + outline: 1px solid rgba(0, 0, 0, 0.3); 258 + outline-offset: -1px; 259 + } 260 + .to-info { 261 + flex: 1; 262 + min-width: 0; 263 + } 264 + .to-title { 265 + display: block; 266 + color: #fff; 267 + font-weight: 600; 268 + font-size: 0.9375rem; 269 + line-height: 1.3; 270 + overflow: hidden; 271 + text-overflow: ellipsis; 272 + white-space: nowrap; 273 + } 274 + .to-meta { 275 + display: block; 276 + color: rgba(255, 255, 255, 0.6); 277 + font-size: 0.75rem; 278 + line-height: 1.4; 279 + } 280 + .to-meta a { 281 + color: inherit; 282 + text-decoration: underline; 283 + text-underline-offset: 2px; 284 + } 285 + .to-viewers { 286 + font-variant-numeric: tabular-nums; 287 + } 288 + #to-progress-wrap { 289 + width: 100%; 290 + height: 2px; 291 + background: rgba(255, 255, 255, 0.15); 292 + border-radius: 1px; 293 + overflow: hidden; 294 + margin-top: 0.5rem; 295 + } 296 + #to-progress-fill { 297 + height: 100%; 298 + width: 0%; 299 + background: var(--mauve); 300 + border-radius: 1px; 301 + transition: width 1s linear; 302 + } 224 303 225 - /* Track info overlay on video */ 226 - #track-overlay { 227 - position: absolute; 228 - bottom: 0; 229 - left: 0; 230 - right: 0; 231 - background: linear-gradient(transparent, rgba(0,0,0,0.75)); 232 - padding: 2rem 1rem 0.75rem; 233 - opacity: 0; 234 - transition: opacity 200ms ease-out; 235 - pointer-events: none; 236 - z-index: 5; 237 - } 238 - #video-container:hover #track-overlay, 239 - #track-overlay.show { 240 - opacity: 1; 241 - pointer-events: auto; 242 - } 243 - .to-row { 244 - display: flex; 245 - align-items: center; 246 - gap: 0.75rem; 247 - } 248 - .to-thumb { 249 - width: 36px; 250 - height: 36px; 251 - border-radius: var(--radius-sm); 252 - object-fit: cover; 253 - flex-shrink: 0; 254 - outline: 1px solid rgba(0,0,0,0.3); 255 - outline-offset: -1px; 256 - } 257 - .to-info { 258 - flex: 1; 259 - min-width: 0; 260 - } 261 - .to-title { 262 - display: block; 263 - color: #fff; 264 - font-weight: 600; 265 - font-size: 0.9375rem; 266 - line-height: 1.3; 267 - overflow: hidden; 268 - text-overflow: ellipsis; 269 - white-space: nowrap; 270 - } 271 - .to-meta { 272 - display: block; 273 - color: rgba(255,255,255,0.6); 274 - font-size: 0.75rem; 275 - line-height: 1.4; 276 - } 277 - .to-meta a { 278 - color: inherit; 279 - text-decoration: underline; 280 - text-underline-offset: 2px; 281 - } 282 - .to-viewers { 283 - font-variant-numeric: tabular-nums; 284 - } 285 - #to-progress-wrap { 286 - width: 100%; 287 - height: 2px; 288 - background: rgba(255,255,255,0.15); 289 - border-radius: 1px; 290 - overflow: hidden; 291 - margin-top: 0.5rem; 292 - } 293 - #to-progress-fill { 294 - height: 100%; 295 - width: 0%; 296 - background: var(--mauve); 297 - border-radius: 1px; 298 - transition: width 1s linear; 299 - } 304 + .volume-overlay { 305 + position: absolute; 306 + bottom: 0.75rem; 307 + right: 0.75rem; 308 + display: flex; 309 + align-items: center; 310 + gap: 0.5rem; 311 + background: rgba(0, 0, 0, 0.6); 312 + border-radius: var(--radius-md); 313 + padding: 0.375rem 0.625rem; 314 + opacity: 0; 315 + transition: opacity 150ms ease-out; 316 + pointer-events: none; 317 + z-index: 10; 318 + } 319 + #video-container:hover .volume-overlay { 320 + opacity: 1; 321 + pointer-events: auto; 322 + } 323 + .volume-overlay .icon { 324 + font-size: 1.125rem; 325 + line-height: 1; 326 + color: var(--text); 327 + cursor: pointer; 328 + user-select: none; 329 + } 330 + .volume-overlay input[type="range"] { 331 + width: 80px; 332 + height: 4px; 333 + accent-color: var(--blue); 334 + cursor: pointer; 335 + background: transparent; 336 + } 300 337 301 - .volume-overlay { 302 - position: absolute; 303 - bottom: 0.75rem; 304 - right: 0.75rem; 305 - display: flex; 306 - align-items: center; 307 - gap: 0.5rem; 308 - background: rgba(0, 0, 0, 0.6); 309 - border-radius: var(--radius-md); 310 - padding: 0.375rem 0.625rem; 311 - opacity: 0; 312 - transition: opacity 150ms ease-out; 313 - pointer-events: none; 314 - z-index: 10; 315 - } 316 - #video-container:hover .volume-overlay { 317 - opacity: 1; 318 - pointer-events: auto; 319 - } 320 - .volume-overlay .icon { 321 - font-size: 1.125rem; 322 - line-height: 1; 323 - color: var(--text); 324 - cursor: pointer; 325 - user-select: none; 326 - } 327 - .volume-overlay input[type="range"] { 328 - width: 80px; 329 - height: 4px; 330 - accent-color: var(--blue); 331 - cursor: pointer; 332 - background: transparent; 333 - } 338 + /* Input Row */ 339 + .queue-group { 340 + margin-bottom: 0.5rem; 341 + } 342 + .input-row { 343 + display: flex; 344 + gap: 0.5rem; 345 + margin-top: 0.75rem; 346 + } 347 + .input-row input { 348 + flex: 1; 349 + font: inherit; 350 + padding: 0.75rem 1rem; 351 + border-radius: var(--radius-md); 352 + border: none; 353 + background: var(--mantle); 354 + color: var(--text); 355 + font-size: 0.9375rem; 356 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 357 + transition: box-shadow 150ms ease-out; 358 + } 359 + .input-row input:focus { 360 + outline: none; 361 + box-shadow: 362 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 363 + 0 0 0 2px var(--blue); 364 + } 365 + .input-row input::placeholder { 366 + color: var(--overlay0); 367 + } 334 368 335 - /* Input Row */ 336 - .queue-group { 337 - margin-bottom: 0.5rem; 338 - } 339 - .input-row { 340 - display: flex; 341 - gap: 0.5rem; 342 - margin-top: 0.75rem; 343 - } 344 - .input-row input { 345 - flex: 1; 346 - font: inherit; 347 - padding: 0.75rem 1rem; 348 - border-radius: var(--radius-md); 349 - border: none; 350 - background: var(--mantle); 351 - color: var(--text); 352 - font-size: 0.9375rem; 353 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 354 - transition: box-shadow 150ms ease-out; 355 - } 356 - .input-row input:focus { 357 - outline: none; 358 - box-shadow: 359 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 360 - 0 0 0 2px var(--blue); 361 - } 362 - .input-row input::placeholder { 363 - color: var(--overlay0); 364 - } 369 + /* Buttons */ 370 + button { 371 + font: inherit; 372 + padding: 0.75rem 1.25rem; 373 + border-radius: var(--radius-md); 374 + border: none; 375 + cursor: pointer; 376 + font-weight: 600; 377 + font-size: 0.9375rem; 378 + transition: 379 + transform 150ms ease-out, 380 + box-shadow 150ms ease-out, 381 + filter 150ms ease-out; 382 + min-height: 44px; 383 + display: inline-flex; 384 + align-items: center; 385 + justify-content: center; 386 + } 387 + button:active { 388 + transform: scale(0.96); 389 + } 390 + button:disabled { 391 + opacity: 0.5; 392 + cursor: not-allowed; 393 + transform: none; 394 + filter: none; 395 + } 396 + button.btn-primary { 397 + background: var(--green); 398 + color: var(--crust); 399 + box-shadow: var(--shadow-card); 400 + } 401 + button.btn-primary:hover { 402 + filter: brightness(1.12); 403 + box-shadow: var(--shadow-card-hover); 404 + } 405 + button.btn-secondary { 406 + background: var(--surface1); 407 + color: var(--text); 408 + box-shadow: var(--shadow-card); 409 + } 410 + button.btn-secondary:hover { 411 + background: var(--surface2); 412 + filter: brightness(1.08); 413 + box-shadow: var(--shadow-card-hover); 414 + } 415 + button.btn-accent { 416 + background: var(--mauve); 417 + color: var(--crust); 418 + box-shadow: var(--shadow-card); 419 + } 420 + button.btn-accent:hover { 421 + filter: brightness(1.12); 422 + box-shadow: var(--shadow-card-hover); 423 + } 365 424 366 - /* Buttons */ 367 - button { 368 - font: inherit; 369 - padding: 0.75rem 1.25rem; 370 - border-radius: var(--radius-md); 371 - border: none; 372 - cursor: pointer; 373 - font-weight: 600; 374 - font-size: 0.9375rem; 375 - transition: 376 - transform 150ms ease-out, 377 - box-shadow 150ms ease-out, 378 - filter 150ms ease-out; 379 - min-height: 44px; 380 - display: inline-flex; 381 - align-items: center; 382 - justify-content: center; 383 - } 384 - button:active { 385 - transform: scale(0.96); 386 - } 387 - button:disabled { 388 - opacity: 0.5; 389 - cursor: not-allowed; 390 - transform: none; 391 - filter: none; 392 - } 393 - button.btn-primary { 394 - background: var(--green); 395 - color: var(--crust); 396 - box-shadow: var(--shadow-card); 397 - } 398 - button.btn-primary:hover { 399 - filter: brightness(1.12); 400 - box-shadow: var(--shadow-card-hover); 401 - } 402 - button.btn-secondary { 403 - background: var(--surface1); 404 - color: var(--text); 405 - box-shadow: var(--shadow-card); 406 - } 407 - button.btn-secondary:hover { 408 - background: var(--surface2); 409 - filter: brightness(1.08); 410 - box-shadow: var(--shadow-card-hover); 411 - } 412 - button.btn-accent { 413 - background: var(--mauve); 414 - color: var(--crust); 415 - box-shadow: var(--shadow-card); 416 - } 417 - button.btn-accent:hover { 418 - filter: brightness(1.12); 419 - box-shadow: var(--shadow-card-hover); 420 - } 425 + /* Queue */ 426 + .queue { 427 + list-style: none; 428 + } 429 + .queue li { 430 + background: var(--surface0); 431 + border-radius: var(--radius-md); 432 + box-shadow: var(--shadow-card); 433 + padding: 0.75rem; 434 + display: flex; 435 + align-items: center; 436 + gap: 0.75rem; 437 + margin-bottom: 0.5rem; 438 + transition: 439 + background 150ms ease-out, 440 + box-shadow 150ms ease-out; 441 + animation: queueFadeIn 0.12s ease-out both; 442 + } 443 + .queue li:hover { 444 + background: var(--surface1); 445 + box-shadow: var(--shadow-card-hover); 446 + } 447 + .queue li .thumb, 448 + .queue li .thumb-img { 449 + width: 40px; 450 + height: 40px; 451 + border-radius: var(--radius-sm); 452 + background: var(--mantle); 453 + flex-shrink: 0; 454 + object-fit: cover; 455 + outline: 1px solid var(--surface2); 456 + outline-offset: -1px; 457 + } 458 + .queue li .title { 459 + flex: 1; 460 + min-width: 0; 461 + overflow: hidden; 462 + text-overflow: ellipsis; 463 + white-space: nowrap; 464 + text-wrap: pretty; 465 + } 466 + .queue li .duration { 467 + color: var(--subtext0); 468 + font-size: 0.875rem; 469 + flex-shrink: 0; 470 + font-variant-numeric: tabular-nums; 471 + } 421 472 422 - /* Queue */ 423 - .queue { 424 - list-style: none; 425 - } 426 - .queue li { 427 - background: var(--surface0); 428 - border-radius: var(--radius-md); 429 - box-shadow: var(--shadow-card); 430 - padding: 0.75rem; 431 - display: flex; 432 - align-items: center; 433 - gap: 0.75rem; 434 - margin-bottom: 0.5rem; 435 - transition: 436 - background 150ms ease-out, 437 - box-shadow 150ms ease-out; 438 - animation: queueFadeIn 0.12s ease-out both; 439 - } 440 - .queue li:hover { 441 - background: var(--surface1); 442 - box-shadow: var(--shadow-card-hover); 443 - } 444 - .queue li .thumb, 445 - .queue li .thumb-img { 446 - width: 40px; 447 - height: 40px; 448 - border-radius: var(--radius-sm); 449 - background: var(--mantle); 450 - flex-shrink: 0; 451 - object-fit: cover; 452 - outline: 1px solid var(--surface2); 453 - outline-offset: -1px; 454 - } 455 - .queue li .title { 456 - flex: 1; 457 - min-width: 0; 458 - overflow: hidden; 459 - text-overflow: ellipsis; 460 - white-space: nowrap; 461 - text-wrap: pretty; 462 - } 463 - .queue li .duration { 464 - color: var(--subtext0); 465 - font-size: 0.875rem; 466 - flex-shrink: 0; 467 - font-variant-numeric: tabular-nums; 468 - } 473 + /* Source link */ 474 + .source-link { 475 + color: var(--blue); 476 + text-decoration: none; 477 + font-size: 0.8rem; 478 + padding: 6px 10px; 479 + border-radius: var(--radius-sm); 480 + background: color-mix(in srgb, var(--blue) 15%, transparent); 481 + transition: background 150ms ease-out; 482 + flex-shrink: 0; 483 + min-height: 40px; 484 + display: inline-flex; 485 + align-items: center; 486 + } 487 + .source-link:hover { 488 + background: color-mix(in srgb, var(--blue) 25%, transparent); 489 + } 469 490 470 - /* Source link */ 471 - .source-link { 472 - color: var(--blue); 473 - text-decoration: none; 474 - font-size: 0.8rem; 475 - padding: 6px 10px; 476 - border-radius: var(--radius-sm); 477 - background: color-mix(in srgb, var(--blue) 15%, transparent); 478 - transition: background 150ms ease-out; 479 - flex-shrink: 0; 480 - min-height: 40px; 481 - display: inline-flex; 482 - align-items: center; 483 - } 484 - .source-link:hover { 485 - background: color-mix(in srgb, var(--blue) 25%, transparent); 486 - } 491 + .history-item { 492 + opacity: 1; 493 + border-radius: var(--radius-sm); 494 + padding: 0.5rem 0.75rem; 495 + margin-bottom: 0.25rem; 496 + background: transparent; 497 + box-shadow: none; 498 + } 499 + .history-item:hover { 500 + background: transparent; 501 + box-shadow: none; 502 + } 503 + .history-item .thumb, 504 + .history-item .thumb-img { 505 + width: 28px; 506 + height: 28px; 507 + } 508 + .history-item .title { 509 + color: var(--overlay1); 510 + font-size: 0.8125rem; 511 + } 512 + .history-item .duration { 513 + color: var(--overlay0); 514 + font-size: 0.75rem; 515 + } 487 516 488 - .history-item { 489 - opacity: 1; 490 - border-radius: var(--radius-sm); 491 - padding: 0.5rem 0.75rem; 492 - margin-bottom: 0.25rem; 493 - background: transparent; 494 - box-shadow: none; 495 - } 496 - .history-item:hover { 497 - background: transparent; 498 - box-shadow: none; 499 - } 500 - .history-item .thumb, 501 - .history-item .thumb-img { 502 - width: 28px; 503 - height: 28px; 504 - } 505 - .history-item .title { 506 - color: var(--overlay1); 507 - font-size: 0.8125rem; 508 - } 509 - .history-item .duration { 510 - color: var(--overlay0); 511 - font-size: 0.75rem; 512 - } 517 + /* Connection status */ 518 + .conn-status { 519 + font-size: 0.75rem; 520 + color: var(--overlay0); 521 + margin-bottom: 0.5rem; 522 + text-align: right; 523 + transition: color 300ms ease-out; 524 + } 525 + .conn-status.connected { 526 + color: var(--green); 527 + } 528 + .conn-status.disconnected { 529 + color: var(--red); 530 + } 513 531 514 - /* Connection status */ 515 - .conn-status { 516 - font-size: 0.75rem; 517 - color: var(--overlay0); 518 - margin-bottom: 0.5rem; 519 - text-align: right; 520 - transition: color 300ms ease-out; 521 - } 522 - .conn-status.connected { 523 - color: var(--green); 524 - } 525 - .conn-status.disconnected { 526 - color: var(--red); 527 - } 532 + /* History header */ 533 + .history-header { 534 + margin-top: 1.5rem; 535 + margin-bottom: 0.5rem; 536 + padding-top: 1rem; 537 + border-top: 1px solid var(--surface2); 538 + font-size: 0.8125rem; 539 + color: var(--overlay0); 540 + font-weight: 500; 541 + text-transform: uppercase; 542 + letter-spacing: 0.04em; 543 + } 528 544 529 - /* History header */ 530 - .history-header { 531 - margin-top: 1.5rem; 532 - margin-bottom: 0.5rem; 533 - padding-top: 1rem; 534 - border-top: 1px solid var(--surface2); 535 - font-size: 0.8125rem; 536 - color: var(--overlay0); 537 - font-weight: 500; 538 - text-transform: uppercase; 539 - letter-spacing: 0.04em; 540 - } 545 + /* Empty state */ 546 + .empty-state { 547 + display: flex; 548 + flex-direction: column; 549 + align-items: center; 550 + justify-content: center; 551 + gap: 0.75rem; 552 + padding: 3rem 1.5rem; 553 + text-align: center; 554 + color: var(--overlay1); 555 + font-size: 0.875rem; 556 + line-height: 1.5; 557 + background: var(--surface0); 558 + border-radius: var(--radius-lg); 559 + border: 1px dashed var(--surface2); 560 + margin-bottom: 0.5rem; 561 + } 562 + .empty-state .icon { 563 + font-size: 1.75rem; 564 + line-height: 1; 565 + opacity: 0.5; 566 + } 567 + .empty-state .label { 568 + color: var(--subtext0); 569 + font-weight: 500; 570 + } 571 + .empty-state .hint { 572 + color: var(--overlay0); 573 + font-size: 0.8125rem; 574 + max-width: 24em; 575 + } 541 576 542 - /* Empty state */ 543 - .empty-state { 544 - display: flex; 545 - flex-direction: column; 546 - align-items: center; 547 - justify-content: center; 548 - gap: 0.75rem; 549 - padding: 3rem 1.5rem; 550 - text-align: center; 551 - color: var(--overlay1); 552 - font-size: 0.875rem; 553 - line-height: 1.5; 554 - background: var(--surface0); 555 - border-radius: var(--radius-lg); 556 - border: 1px dashed var(--surface2); 557 - margin-bottom: 0.5rem; 558 - } 559 - .empty-state .icon { 560 - font-size: 1.75rem; 561 - line-height: 1; 562 - opacity: 0.5; 563 - } 564 - .empty-state .label { 565 - color: var(--subtext0); 566 - font-weight: 500; 567 - } 568 - .empty-state .hint { 569 - color: var(--overlay0); 570 - font-size: 0.8125rem; 571 - max-width: 24em; 572 - } 577 + /* Chat */ 578 + .chat-messages { 579 + flex: 1; 580 + overflow-y: auto; 581 + min-height: 0; 582 + background: var(--surface0); 583 + border-radius: var(--radius-lg); 584 + box-shadow: var(--shadow-card); 585 + padding: 0.5rem; 586 + display: flex; 587 + flex-direction: column; 588 + gap: 4px; 589 + margin-bottom: 0.5rem; 590 + } 591 + .chat-messages:empty::after { 592 + content: "No messages yet"; 593 + display: flex; 594 + align-items: center; 595 + justify-content: center; 596 + height: 100%; 597 + min-height: 80px; 598 + color: var(--overlay0); 599 + font-size: 0.875rem; 600 + font-style: italic; 601 + } 602 + .chat-msg { 603 + padding: 6px 10px; 604 + border-left: 3px solid var(--blue); 605 + padding-left: 10px; 606 + font-size: 0.875rem; 607 + line-height: 1.4; 608 + } 609 + .chat-msg .user { 610 + color: var(--blue); 611 + font-weight: 600; 612 + font-size: 0.8rem; 613 + } 614 + .chat-msg .time { 615 + color: var(--overlay0); 616 + font-size: 0.7rem; 617 + float: right; 618 + margin-left: 8px; 619 + } 620 + .chat-msg .text { 621 + color: var(--text); 622 + } 623 + .chat-msg.system { 624 + border-left-color: var(--overlay0); 625 + } 626 + .chat-msg.system .user { 627 + color: var(--overlay0); 628 + } 629 + .chat-msg.system .text { 630 + color: var(--subtext0); 631 + font-style: italic; 632 + } 573 633 574 - /* Chat */ 575 - .chat-messages { 576 - flex: 1; 577 - overflow-y: auto; 578 - min-height: 0; 579 - background: var(--surface0); 580 - border-radius: var(--radius-lg); 581 - box-shadow: var(--shadow-card); 582 - padding: 0.5rem; 583 - display: flex; 584 - flex-direction: column; 585 - gap: 4px; 586 - margin-bottom: 0.5rem; 587 - } 588 - .chat-messages:empty::after { 589 - content: "No messages yet"; 590 - display: flex; 591 - align-items: center; 592 - justify-content: center; 593 - height: 100%; 594 - min-height: 80px; 595 - color: var(--overlay0); 596 - font-size: 0.875rem; 597 - font-style: italic; 598 - } 599 - .chat-msg { 600 - padding: 6px 10px; 601 - border-left: 3px solid var(--blue); 602 - padding-left: 10px; 603 - font-size: 0.875rem; 604 - line-height: 1.4; 605 - } 606 - .chat-msg .user { 607 - color: var(--blue); 608 - font-weight: 600; 609 - font-size: 0.8rem; 610 - } 611 - .chat-msg .time { 612 - color: var(--overlay0); 613 - font-size: 0.7rem; 614 - float: right; 615 - margin-left: 8px; 616 - } 617 - .chat-msg .text { 618 - color: var(--text); 619 - } 620 - .chat-msg.system { 621 - border-left-color: var(--overlay0); 622 - } 623 - .chat-msg.system .user { 624 - color: var(--overlay0); 625 - } 626 - .chat-msg.system .text { 627 - color: var(--subtext0); 628 - font-style: italic; 629 - } 634 + /* Chat input */ 635 + .chat-input-row { 636 + display: flex; 637 + gap: 0.5rem; 638 + } 639 + .chat-input-row input { 640 + flex: 1; 641 + font: inherit; 642 + padding: 0.625rem 0.875rem; 643 + border-radius: var(--radius-md); 644 + border: none; 645 + background: var(--mantle); 646 + color: var(--text); 647 + font-size: 0.875rem; 648 + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 649 + transition: box-shadow 150ms ease-out; 650 + } 651 + .chat-input-row input:focus { 652 + outline: none; 653 + box-shadow: 654 + inset 0 2px 4px rgba(0, 0, 0, 0.2), 655 + 0 0 0 2px var(--blue); 656 + } 657 + .chat-input-row input::placeholder { 658 + color: var(--overlay0); 659 + } 660 + .chat-input-row button { 661 + padding: 0.625rem 1rem; 662 + min-height: 40px; 663 + background: var(--mauve); 664 + color: var(--crust); 665 + box-shadow: var(--shadow-card); 666 + } 667 + .chat-input-row button:hover { 668 + filter: brightness(1.12); 669 + box-shadow: var(--shadow-card-hover); 670 + } 630 671 631 - /* Chat input */ 632 - .chat-input-row { 633 - display: flex; 634 - gap: 0.5rem; 635 - } 636 - .chat-input-row input { 637 - flex: 1; 638 - font: inherit; 639 - padding: 0.625rem 0.875rem; 640 - border-radius: var(--radius-md); 641 - border: none; 642 - background: var(--mantle); 643 - color: var(--text); 644 - font-size: 0.875rem; 645 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 646 - transition: box-shadow 150ms ease-out; 647 - } 648 - .chat-input-row input:focus { 649 - outline: none; 650 - box-shadow: 651 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 652 - 0 0 0 2px var(--blue); 653 - } 654 - .chat-input-row input::placeholder { 655 - color: var(--overlay0); 656 - } 657 - .chat-input-row button { 658 - padding: 0.625rem 1rem; 659 - min-height: 40px; 660 - background: var(--mauve); 661 - color: var(--crust); 662 - box-shadow: var(--shadow-card); 672 + /* Skeleton loading state for pending queue items */ 673 + @keyframes shimmer { 674 + 0% { 675 + background-position: -200% 0; 663 676 } 664 - .chat-input-row button:hover { 665 - filter: brightness(1.12); 666 - box-shadow: var(--shadow-card-hover); 677 + 100% { 678 + background-position: 200% 0; 667 679 } 680 + } 681 + .queue li.pending { 682 + position: relative; 683 + } 684 + .queue li.pending .title, 685 + .queue li.pending .duration { 686 + display: inline-block; 687 + border-radius: var(--radius-sm); 688 + background: linear-gradient( 689 + 90deg, 690 + var(--surface1) 25%, 691 + var(--surface2) 37%, 692 + var(--surface1) 63% 693 + ); 694 + background-size: 200% 100%; 695 + animation: shimmer 1.5s ease-in-out infinite; 696 + color: transparent !important; 697 + user-select: none; 698 + } 699 + .queue li.pending .title { 700 + min-width: 16ch; 701 + max-width: 24ch; 702 + } 703 + .queue li.pending .duration { 704 + min-width: 5ch; 705 + } 706 + .queue li.pending .source-link { 707 + visibility: hidden; 708 + } 668 709 669 - /* Skeleton loading state for pending queue items */ 670 - @keyframes shimmer { 671 - 0% { 672 - background-position: -200% 0; 673 - } 674 - 100% { 675 - background-position: 200% 0; 676 - } 677 - } 678 - .queue li.pending { 679 - position: relative; 710 + /* Stagger animation */ 711 + @keyframes queueFadeIn { 712 + from { 713 + opacity: 0.3; 714 + transform: translateY(4px); 680 715 } 681 - .queue li.pending .title, 682 - .queue li.pending .duration { 683 - display: inline-block; 684 - border-radius: var(--radius-sm); 685 - background: linear-gradient( 686 - 90deg, 687 - var(--surface1) 25%, 688 - var(--surface2) 37%, 689 - var(--surface1) 63% 690 - ); 691 - background-size: 200% 100%; 692 - animation: shimmer 1.5s ease-in-out infinite; 693 - color: transparent !important; 694 - user-select: none; 716 + to { 717 + opacity: 1; 718 + transform: translateY(0); 695 719 } 696 - .queue li.pending .title { 697 - min-width: 16ch; 698 - max-width: 24ch; 699 - } 700 - .queue li.pending .duration { 701 - min-width: 5ch; 702 - } 703 - .queue li.pending .source-link { 704 - visibility: hidden; 705 - } 720 + } 721 + .queue li:nth-child(1) { 722 + animation-delay: 0s; 723 + } 724 + .queue li:nth-child(2) { 725 + animation-delay: 0.01s; 726 + } 727 + .queue li:nth-child(3) { 728 + animation-delay: 0.02s; 729 + } 730 + .queue li:nth-child(4) { 731 + animation-delay: 0.03s; 732 + } 733 + .queue li:nth-child(5) { 734 + animation-delay: 0.04s; 735 + } 736 + .queue li:nth-child(6) { 737 + animation-delay: 0.05s; 738 + } 739 + .queue li:nth-child(n + 7) { 740 + animation-delay: 0.05s; 741 + } 706 742 707 - /* Stagger animation */ 708 - @keyframes queueFadeIn { 709 - from { 710 - opacity: 0.3; 711 - transform: translateY(4px); 712 - } 713 - to { 714 - opacity: 1; 715 - transform: translateY(0); 716 - } 743 + /* Mobile responsive */ 744 + @media (max-width: 768px) { 745 + .layout { 746 + flex-direction: column; 747 + overflow-y: visible; 717 748 } 718 - .queue li:nth-child(1) { animation-delay: 0s; } 719 - .queue li:nth-child(2) { animation-delay: 0.01s; } 720 - .queue li:nth-child(3) { animation-delay: 0.02s; } 721 - .queue li:nth-child(4) { animation-delay: 0.03s; } 722 - .queue li:nth-child(5) { animation-delay: 0.04s; } 723 - .queue li:nth-child(6) { animation-delay: 0.05s; } 724 - .queue li:nth-child(n+7) { animation-delay: 0.05s; } 725 - 726 - /* Mobile responsive */ 727 - @media (max-width: 768px) { 728 - .layout { 729 - flex-direction: column; 730 - overflow-y: visible; 731 - } 732 - .chat-col { 733 - width: 100%; 734 - padding-left: 0; 735 - border-left: none; 736 - border-top: 1px solid var(--surface2); 737 - padding-top: 1rem; 738 - } 749 + .chat-col { 750 + width: 100%; 751 + padding-left: 0; 752 + border-left: none; 753 + border-top: 1px solid var(--surface2); 754 + padding-top: 1rem; 755 + } 739 756 /* Touch devices: always show overlays (no hover dependency) */ 740 757 @media (hover: none) { 741 758 #track-overlay { 742 759 opacity: 1; 743 760 pointer-events: auto; 744 - background: linear-gradient(transparent, rgba(0,0,0,0.85)); 761 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); 745 762 padding-bottom: 1rem; 746 763 } 747 764 .volume-overlay { ··· 749 766 pointer-events: auto; 750 767 } 751 768 } 769 + } 752 770 753 - @media (max-width: 768px) { 754 - html, body { 755 - height: auto; 756 - } 757 - body { 758 - min-height: 100vh; 759 - } 760 - .layout { 761 - flex-direction: column; 762 - overflow-y: visible; 763 - flex: none; 764 - } 765 - .main-col { 766 - overflow-y: visible; 767 - flex: none; 768 - } 769 - .chat-col { 770 - width: 100%; 771 - padding-left: 0; 772 - border-left: none; 773 - border-top: 1px solid var(--surface2); 774 - padding-top: 1rem; 775 - flex: none; 776 - } 777 - #video-player { 778 - max-height: 40vh; 779 - } 780 - #video-container.idle { 781 - min-height: 120px; 782 - } 783 - .top-bar { 784 - flex-wrap: wrap; 785 - gap: 0.5rem; 786 - } 787 - .top-bar-center { 788 - order: 3; 789 - width: 100%; 790 - text-align: left; 791 - } 771 + @media (max-width: 768px) { 772 + html, 773 + body { 774 + height: auto; 775 + } 776 + body { 777 + min-height: 100vh; 778 + } 779 + .layout { 780 + flex-direction: column; 781 + overflow-y: visible; 782 + flex: none; 783 + } 784 + .main-col { 785 + overflow-y: visible; 786 + flex: none; 787 + } 788 + .chat-col { 789 + width: 100%; 790 + padding-left: 0; 791 + border-left: none; 792 + border-top: 1px solid var(--surface2); 793 + padding-top: 1rem; 794 + flex: none; 795 + } 796 + #video-player { 797 + max-height: 40vh; 798 + } 799 + #video-container.idle { 800 + min-height: 120px; 792 801 } 802 + .top-bar { 803 + flex-wrap: wrap; 804 + gap: 0.5rem; 805 + } 806 + .top-bar-center { 807 + order: 3; 808 + width: 100%; 809 + text-align: left; 810 + } 811 + } 793 812 </style> 794 813 </head> 795 814 <body> ··· 800 819 <span class="room-id">{{ room_id }}</span> 801 820 </div> 802 821 <div class="top-bar-actions"> 803 - <button 804 - class="btn-icon" 805 - id="copy-link" 806 - title="Copy room link" 807 - onclick="copyRoomLink()" 808 - > 822 + <button class="btn-icon" id="copy-link" title="Copy room link" onclick="copyRoomLink()"> 809 823 <span class="icon">&#x1F517;</span> 810 824 </button> 811 825 <span class="viewer-pill" id="viewer-pill">0 viewers</span> ··· 817 831 <div id="video-container" class="idle"> 818 832 <div class="volume-overlay" id="vol-overlay"> 819 833 <span class="icon" id="vol-icon">&#x1F507;</span> 820 - <input 821 - type="range" 822 - id="vol-slider" 823 - min="0" 824 - max="1" 825 - step="0.05" 826 - value="0" 827 - /> 834 + <input type="range" id="vol-slider" min="0" max="1" step="0.05" value="0"> 828 835 </div> 829 836 <div id="track-overlay"> 830 837 <div class="to-row"> 831 - <img id="to-thumb" class="to-thumb" alt="" /> 838 + <img id="to-thumb" class="to-thumb" alt=""> 832 839 <div class="to-info"> 833 840 <span id="to-title" class="to-title"></span> 834 841 <span class="to-meta"> ··· 854 861 <div class="icon">&#x1F3B5;</div> 855 862 <div class="label">Queue is empty</div> 856 863 <div class="hint"> 857 - Paste a link from YouTube, SoundCloud, or Bandcamp above to get 858 - started. 864 + Paste a link from YouTube, SoundCloud, or Bandcamp above to get started. 859 865 </div> 860 866 </div> 861 867 {% else %} ··· 863 869 {% for item in queue %} 864 870 <li> 865 871 {% if let Some(thumb) = item.thumbnail %} 866 - <img src="{{ thumb }}" class="thumb-img" alt="" /> 872 + <img src="{{ thumb }}" class="thumb-img" alt=""> 867 873 {% else %} 868 874 <div class="thumb"></div> 869 875 {% endif %} ··· 887 893 type="text" 888 894 id="url-input" 889 895 placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 890 - /> 896 + > 891 897 <button class="btn-primary" onclick="ingest()">Add</button> 892 - <button 893 - class="btn-secondary" 894 - onclick="skip()" 895 - title="Skip current track" 896 - > 897 - Skip 898 - </button> 898 + <button class="btn-secondary" onclick="skip()" title="Skip current track">Skip</button> 899 899 </div> 900 900 </div> 901 901 ··· 906 906 {% for item in history %} 907 907 <li class="history-item"> 908 908 {% if let Some(thumb) = item.thumbnail %} 909 - <img src="{{ thumb }}" class="thumb-img" alt="" /> 909 + <img src="{{ thumb }}" class="thumb-img" alt=""> 910 910 {% else %} 911 911 <div class="thumb"></div> 912 912 {% endif %} 913 913 <div class="title">{{ item.title }}</div> 914 914 <div class="duration">{{ item.duration }}</div> 915 - <a 916 - href="{{ item.url }}" 917 - class="source-link" 918 - target="_blank" 919 - rel="noopener noreferrer" 915 + <a href="{{ item.url }}" class="source-link" target="_blank" rel="noopener noreferrer" 920 916 >source</a 921 917 > 922 918 </li> ··· 927 923 </div> 928 924 929 925 <div class="chat-col"> 930 - <div id="conn-status" class="conn-status disconnected"> 931 - disconnected 932 - </div> 926 + <div id="conn-status" class="conn-status disconnected">disconnected</div> 933 927 <div id="chat-messages" class="chat-messages"></div> 934 928 <div class="chat-input-row"> 935 - <input 936 - type="text" 937 - id="chat-input" 938 - placeholder="Type a message..." 939 - maxlength="2000" 940 - /> 929 + <input type="text" id="chat-input" placeholder="Type a message..." maxlength="2000"> 941 930 <button id="chat-send" class="btn-accent">Send</button> 942 931 </div> 943 932 </div> 944 933 </div> 945 934 946 935 <script> 947 - // ── MoQ binary helpers ────────────────────────────────────────── 948 - function encVarint(n) { 949 - if (n < 64) return new Uint8Array([n]); 950 - if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 951 - if (n < 1073741824) 952 - return new Uint8Array([ 953 - (n >> 24) | 0x80, 954 - (n >> 16) & 0xff, 955 - (n >> 8) & 0xff, 956 - n & 0xff, 957 - ]); 958 - const b = new Uint8Array(8); 959 - b[0] = (n >> 56) | 0xc0; 960 - b[1] = (n >> 48) & 0xff; 961 - b[2] = (n >> 40) & 0xff; 962 - b[3] = (n >> 32) & 0xff; 963 - b[4] = (n >> 24) & 0xff; 964 - b[5] = (n >> 16) & 0xff; 965 - b[6] = (n >> 8) & 0xff; 966 - b[7] = n & 0xff; 967 - return b; 968 - } 969 - function encString(s) { 970 - const bytes = new TextEncoder().encode(s); 971 - const len = encVarint(bytes.length); 972 - const out = new Uint8Array(len.length + bytes.length); 973 - out.set(len); 974 - out.set(bytes, len.length); 975 - return out; 976 - } 977 - function concat(arrays) { 978 - const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 979 - const out = new Uint8Array(total); 980 - let off = 0; 981 - for (const a of arrays) { 982 - out.set(a, off); 983 - off += a.length; 984 - } 985 - return out; 936 + // ── MoQ binary helpers ────────────────────────────────────────── 937 + function encVarint(n) { 938 + if (n < 64) return new Uint8Array([n]); 939 + if (n < 16384) return new Uint8Array([(n >> 8) | 0x40, n & 0xff]); 940 + if (n < 1073741824) 941 + return new Uint8Array([(n >> 24) | 0x80, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]); 942 + const b = new Uint8Array(8); 943 + b[0] = (n >> 56) | 0xc0; 944 + b[1] = (n >> 48) & 0xff; 945 + b[2] = (n >> 40) & 0xff; 946 + b[3] = (n >> 32) & 0xff; 947 + b[4] = (n >> 24) & 0xff; 948 + b[5] = (n >> 16) & 0xff; 949 + b[6] = (n >> 8) & 0xff; 950 + b[7] = n & 0xff; 951 + return b; 952 + } 953 + function encString(s) { 954 + const bytes = new TextEncoder().encode(s); 955 + const len = encVarint(bytes.length); 956 + const out = new Uint8Array(len.length + bytes.length); 957 + out.set(len); 958 + out.set(bytes, len.length); 959 + return out; 960 + } 961 + function concat(arrays) { 962 + const total = arrays.reduce((sum, arr) => sum + arr.length, 0); 963 + const out = new Uint8Array(total); 964 + let off = 0; 965 + for (const a of arrays) { 966 + out.set(a, off); 967 + off += a.length; 986 968 } 969 + return out; 970 + } 987 971 988 - function decVarint(buf, off) { 989 - const first = buf[off]; 990 - const tag = first >> 6; 991 - if (tag === 0) return [first & 0x3f, off + 1]; 992 - if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 993 - if (tag === 2) 994 - return [ 995 - ((first & 0x3f) << 24) | 996 - (buf[off + 1] << 16) | 997 - (buf[off + 2] << 8) | 998 - buf[off + 3], 999 - off + 4, 1000 - ]; 1001 - let val = BigInt(first & 0x3f) << 56n; 1002 - for (let i = 1; i < 8; i++) 1003 - val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 1004 - return [Number(val), off + 8]; 1005 - } 1006 - function decString(buf, off) { 1007 - const [len, o] = decVarint(buf, off); 1008 - const str = new TextDecoder().decode(buf.slice(o, o + len)); 1009 - return [str, o + len]; 1010 - } 972 + function decVarint(buf, off) { 973 + const first = buf[off]; 974 + const tag = first >> 6; 975 + if (tag === 0) return [first & 0x3f, off + 1]; 976 + if (tag === 1) return [((first & 0x3f) << 8) | buf[off + 1], off + 2]; 977 + if (tag === 2) 978 + return [ 979 + ((first & 0x3f) << 24) | (buf[off + 1] << 16) | (buf[off + 2] << 8) | buf[off + 3], 980 + off + 4, 981 + ]; 982 + let val = BigInt(first & 0x3f) << 56n; 983 + for (let i = 1; i < 8; i++) val |= BigInt(buf[off + i]) << BigInt(8 * (7 - i)); 984 + return [Number(val), off + 8]; 985 + } 986 + function decString(buf, off) { 987 + const [len, o] = decVarint(buf, off); 988 + const str = new TextDecoder().decode(buf.slice(o, o + len)); 989 + return [str, o + len]; 990 + } 1011 991 1012 - // ── Debug logging (opt-in via ?debug or localStorage) ──────────── 1013 - const DEBUG = 1014 - new URLSearchParams(location.search).has("debug") || 1015 - localStorage.getItem("moqbox_debug"); 1016 - function debug(...args) { 1017 - if (DEBUG) console.log(...args); 1018 - } 992 + // ── Debug logging (opt-in via ?debug or localStorage) ──────────── 993 + const DEBUG = 994 + new URLSearchParams(location.search).has("debug") || localStorage.getItem("moqbox_debug"); 995 + function debug(...args) { 996 + if (DEBUG) console.log(...args); 997 + } 1019 998 1020 - // ── Chat WebSocket client ─────────────────────────────────────── 1021 - const roomId = "{{ room_id }}"; 1022 - let userName = sessionStorage.getItem("moqbox_user"); 1023 - if (!userName) { 1024 - userName = prompt("Enter your name:", "anonymous") || "anonymous"; 1025 - sessionStorage.setItem("moqbox_user", userName); 1026 - } 999 + // ── Chat WebSocket client ─────────────────────────────────────── 1000 + const roomId = "{{ room_id }}"; 1001 + let userName = sessionStorage.getItem("moqbox_user"); 1002 + if (!userName) { 1003 + userName = prompt("Enter your name:", "anonymous") || "anonymous"; 1004 + sessionStorage.setItem("moqbox_user", userName); 1005 + } 1027 1006 1028 - const proto = location.protocol === "https:" ? "wss:" : "ws:"; 1029 - let ws = null; 1030 - let reconnectDelay = 1000; 1007 + const proto = location.protocol === "https:" ? "wss:" : "ws:"; 1008 + let ws = null; 1009 + let reconnectDelay = 1000; 1031 1010 1032 - // ── MediaSource video playback ──────────────────────────────────── 1033 - let mediaSource = null; 1034 - let sourceBuffer = null; 1035 - const pendingBoxes = []; // buffered while waiting for sourceopen 1036 - let currentTrackId = null; // tracks the currently playing track (from state) 1037 - let awaitingInit = true; // true until an init segment is processed 1038 - let seekInitDone = false; // true after first seek on join or track change 1011 + // ── MediaSource video playback ──────────────────────────────────── 1012 + let mediaSource = null; 1013 + let sourceBuffer = null; 1014 + const pendingBoxes = []; // buffered while waiting for sourceopen 1015 + let currentTrackId = null; // tracks the currently playing track (from state) 1016 + let awaitingInit = true; // true until an init segment is processed 1017 + let seekInitDone = false; // true after first seek on join or track change 1039 1018 1040 - function initVideo() { 1041 - // Guard: don't create a second player if already initialized. 1042 - if (document.getElementById("video-player")) return; 1043 - debug("initVideo called"); 1044 - const container = 1045 - document.getElementById("video-container") || 1046 - document.querySelector(".main-col"); 1047 - const video = document.createElement("video"); 1048 - video.id = "video-player"; 1049 - video.controls = false; 1050 - video.autoplay = true; 1051 - video.muted = true; 1052 - container.insertBefore(video, container.firstChild); 1019 + function initVideo() { 1020 + // Guard: don't create a second player if already initialized. 1021 + if (document.getElementById("video-player")) return; 1022 + debug("initVideo called"); 1023 + const container = 1024 + document.getElementById("video-container") || document.querySelector(".main-col"); 1025 + const video = document.createElement("video"); 1026 + video.id = "video-player"; 1027 + video.controls = false; 1028 + video.autoplay = true; 1029 + video.muted = true; 1030 + container.insertBefore(video, container.firstChild); 1053 1031 1054 - // Wire up volume controls (element is in HTML, not created in JS) 1055 - const volSlider = document.getElementById("vol-slider"); 1056 - const volIcon = document.getElementById("vol-icon"); 1057 - // Starts muted; user must gesture (click icon or drag slider) to enable audio. 1058 - volSlider.addEventListener("input", () => { 1059 - const v = parseFloat(volSlider.value); 1060 - video.volume = v; 1061 - video.muted = v === 0; 1062 - volIcon.textContent = 1063 - v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 1064 - }); 1065 - volIcon.addEventListener("click", () => { 1066 - if (video.muted) { 1067 - video.muted = false; 1068 - video.volume = 1; 1069 - volSlider.value = "1"; 1070 - volIcon.textContent = "\u{1F50A}"; 1071 - } else { 1072 - video.muted = true; 1073 - volSlider.value = "0"; 1074 - volIcon.textContent = "\u{1F507}"; 1075 - } 1076 - }); 1032 + // Wire up volume controls (element is in HTML, not created in JS) 1033 + const volSlider = document.getElementById("vol-slider"); 1034 + const volIcon = document.getElementById("vol-icon"); 1035 + // Starts muted; user must gesture (click icon or drag slider) to enable audio. 1036 + volSlider.addEventListener("input", () => { 1037 + const v = parseFloat(volSlider.value); 1038 + video.volume = v; 1039 + video.muted = v === 0; 1040 + volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 1041 + }); 1042 + volIcon.addEventListener("click", () => { 1043 + if (video.muted) { 1044 + video.muted = false; 1045 + video.volume = 1; 1046 + volSlider.value = "1"; 1047 + volIcon.textContent = "\u{1F50A}"; 1048 + } else { 1049 + video.muted = true; 1050 + volSlider.value = "0"; 1051 + volIcon.textContent = "\u{1F507}"; 1052 + } 1053 + }); 1077 1054 1078 - mediaSource = new MediaSource(); 1079 - mediaSource.addEventListener("sourceopen", () => { 1080 - debug("MediaSource sourceopen"); 1081 - try { 1082 - sourceBuffer = mediaSource.addSourceBuffer( 1083 - 'video/mp4; codecs="avc1.4d0028,mp4a.40.2"', 1084 - ); 1085 - sourceBuffer.mode = "segments"; 1086 - sourceBuffer.addEventListener("updateend", () => { 1087 - if (pendingBoxes.length > 0) { 1088 - const next = pendingBoxes.shift(); 1089 - try { 1090 - sourceBuffer.appendBuffer(next); 1091 - } catch (e) { 1092 - console.warn("SourceBuffer append failed:", e); 1093 - } 1055 + mediaSource = new MediaSource(); 1056 + mediaSource.addEventListener("sourceopen", () => { 1057 + debug("MediaSource sourceopen"); 1058 + try { 1059 + sourceBuffer = mediaSource.addSourceBuffer('video/mp4; codecs="avc1.4d0028,mp4a.40.2"'); 1060 + sourceBuffer.mode = "segments"; 1061 + sourceBuffer.addEventListener("updateend", () => { 1062 + if (pendingBoxes.length > 0) { 1063 + const next = pendingBoxes.shift(); 1064 + try { 1065 + sourceBuffer.appendBuffer(next); 1066 + } catch (e) { 1067 + console.warn("SourceBuffer append failed:", e); 1094 1068 } 1095 - if (!seekInitDone) { 1096 - const v = document.getElementById("video-player"); 1097 - if (v && v.buffered.length > 0) { 1098 - seekInitDone = true; 1099 - v.currentTime = v.buffered.start(0); 1100 - } 1069 + } 1070 + if (!seekInitDone) { 1071 + const v = document.getElementById("video-player"); 1072 + if (v && v.buffered.length > 0) { 1073 + seekInitDone = true; 1074 + v.currentTime = v.buffered.start(0); 1101 1075 } 1102 - }); 1103 - // Flush boxes that arrived before sourceopen. 1104 - while (pendingBoxes.length > 0) { 1105 - const box = pendingBoxes.shift(); 1106 - if (!sourceBuffer.updating) { 1107 - try { 1108 - sourceBuffer.appendBuffer(box); 1109 - } catch (e) { 1110 - console.warn("SourceBuffer flush failed:", e); 1111 - } 1112 - } else { 1113 - pendingBoxes.unshift(box); 1114 - break; 1076 + } 1077 + }); 1078 + // Flush boxes that arrived before sourceopen. 1079 + while (pendingBoxes.length > 0) { 1080 + const box = pendingBoxes.shift(); 1081 + if (!sourceBuffer.updating) { 1082 + try { 1083 + sourceBuffer.appendBuffer(box); 1084 + } catch (e) { 1085 + console.warn("SourceBuffer flush failed:", e); 1115 1086 } 1087 + } else { 1088 + pendingBoxes.unshift(box); 1089 + break; 1116 1090 } 1117 - } catch (e) { 1118 - console.warn("MediaSource not supported:", e); 1119 1091 } 1120 - }); 1121 - video.src = URL.createObjectURL(mediaSource); 1122 - } 1123 - initVideo(); 1092 + } catch (e) { 1093 + console.warn("MediaSource not supported:", e); 1094 + } 1095 + }); 1096 + video.src = URL.createObjectURL(mediaSource); 1097 + } 1098 + initVideo(); 1124 1099 1125 - function appendVideo(data) { 1126 - if (sourceBuffer && !sourceBuffer.updating) { 1127 - try { 1128 - sourceBuffer.appendBuffer(data); 1129 - } catch (e) { 1130 - console.warn("SourceBuffer append failed:", e); 1131 - } 1132 - } else { 1133 - pendingBoxes.push(data); 1100 + function appendVideo(data) { 1101 + if (sourceBuffer && !sourceBuffer.updating) { 1102 + try { 1103 + sourceBuffer.appendBuffer(data); 1104 + } catch (e) { 1105 + console.warn("SourceBuffer append failed:", e); 1134 1106 } 1107 + } else { 1108 + pendingBoxes.push(data); 1135 1109 } 1110 + } 1136 1111 1137 - function subscribeTracks() { 1138 - // Subscribe to chat track. 1139 - ws.send( 1140 - concat([ 1141 - encVarint(0x01), 1142 - encString("moqbox/room/" + roomId + "/chat"), 1143 - encString("chat"), 1144 - ]), 1145 - ); 1146 - // Subscribe to state track for now-playing updates. 1147 - ws.send( 1148 - concat([ 1149 - encVarint(0x01), 1150 - encString("moqbox/room/" + roomId + "/state"), 1151 - encString("state"), 1152 - ]), 1153 - ); 1154 - } 1112 + function subscribeTracks() { 1113 + // Subscribe to chat track. 1114 + ws.send( 1115 + concat([encVarint(0x01), encString("moqbox/room/" + roomId + "/chat"), encString("chat")]), 1116 + ); 1117 + // Subscribe to state track for now-playing updates. 1118 + ws.send( 1119 + concat([ 1120 + encVarint(0x01), 1121 + encString("moqbox/room/" + roomId + "/state"), 1122 + encString("state"), 1123 + ]), 1124 + ); 1125 + } 1155 1126 1156 - function updateConnStatus(connected) { 1157 - const el = document.getElementById("conn-status"); 1158 - if (!el) return; 1159 - el.className = 1160 - "conn-status " + (connected ? "connected" : "disconnected"); 1161 - el.textContent = connected ? "connected" : "disconnected"; 1162 - } 1127 + function updateConnStatus(connected) { 1128 + const el = document.getElementById("conn-status"); 1129 + if (!el) return; 1130 + el.className = "conn-status " + (connected ? "connected" : "disconnected"); 1131 + el.textContent = connected ? "connected" : "disconnected"; 1132 + } 1163 1133 1164 - function connect() { 1165 - ws = new WebSocket( 1166 - proto + 1167 - "//" + 1168 - location.host + 1169 - "/ws/" + 1170 - roomId + 1171 - "?name=" + 1172 - encodeURIComponent(userName), 1173 - ); 1174 - ws.binaryType = "arraybuffer"; 1134 + function connect() { 1135 + ws = new WebSocket( 1136 + proto + "//" + location.host + "/ws/" + roomId + "?name=" + encodeURIComponent(userName), 1137 + ); 1138 + ws.binaryType = "arraybuffer"; 1175 1139 1176 - ws.onopen = () => { 1177 - debug("WS connected"); 1178 - updateConnStatus(true); 1179 - reconnectDelay = 1000; 1180 - currentTrackId = null; 1181 - subscribeTracks(); 1182 - }; 1140 + ws.onopen = () => { 1141 + debug("WS connected"); 1142 + updateConnStatus(true); 1143 + reconnectDelay = 1000; 1144 + currentTrackId = null; 1145 + subscribeTracks(); 1146 + }; 1183 1147 1184 - ws.onmessage = (event) => { 1185 - const buf = new Uint8Array(event.data); 1186 - let off = 0; 1187 - while (off < buf.length) { 1188 - const [msgType, o1] = decVarint(buf, off); 1189 - if (msgType === 0x00) { 1190 - const [trackId, o2] = decVarint(buf, o1); 1191 - const [groupId, o3] = decVarint(buf, o2); 1192 - const [objectId, o4] = decVarint(buf, o3); 1193 - if (trackId === 2) { 1194 - const payload = new TextDecoder().decode(buf.slice(o4)); 1195 - try { 1196 - const chat = JSON.parse(payload); 1197 - appendChat(chat); 1198 - } catch (_) {} 1199 - off = buf.length; 1200 - } else if (trackId === 3) { 1201 - const payload = new TextDecoder().decode(buf.slice(o4)); 1202 - try { 1203 - const state = JSON.parse(payload); 1204 - updateNowPlaying(state); 1205 - } catch (_) {} 1206 - off = buf.length; 1207 - } else { 1208 - off = buf.length; 1209 - } 1210 - } else if (msgType === 0x02) { 1211 - const [ns, o2] = decString(buf, o1); 1212 - const [tr, o3] = decString(buf, o2); 1213 - off = o3; 1214 - } else if (msgType === 0x06) { 1215 - const [ns, o2] = decString(buf, o1); 1216 - const [tr, o3] = decString(buf, o2); 1217 - const [reason, o4] = decString(buf, o3); 1218 - off = o4; 1148 + ws.onmessage = (event) => { 1149 + const buf = new Uint8Array(event.data); 1150 + let off = 0; 1151 + while (off < buf.length) { 1152 + const [msgType, o1] = decVarint(buf, off); 1153 + if (msgType === 0x00) { 1154 + const [trackId, o2] = decVarint(buf, o1); 1155 + const [groupId, o3] = decVarint(buf, o2); 1156 + const [objectId, o4] = decVarint(buf, o3); 1157 + if (trackId === 2) { 1158 + const payload = new TextDecoder().decode(buf.slice(o4)); 1159 + try { 1160 + const chat = JSON.parse(payload); 1161 + appendChat(chat); 1162 + } catch (_) {} 1163 + off = buf.length; 1164 + } else if (trackId === 3) { 1165 + const payload = new TextDecoder().decode(buf.slice(o4)); 1166 + try { 1167 + const state = JSON.parse(payload); 1168 + updateNowPlaying(state); 1169 + } catch (_) {} 1170 + off = buf.length; 1219 1171 } else { 1220 - break; 1172 + off = buf.length; 1221 1173 } 1174 + } else if (msgType === 0x02) { 1175 + const [ns, o2] = decString(buf, o1); 1176 + const [tr, o3] = decString(buf, o2); 1177 + off = o3; 1178 + } else if (msgType === 0x06) { 1179 + const [ns, o2] = decString(buf, o1); 1180 + const [tr, o3] = decString(buf, o2); 1181 + const [reason, o4] = decString(buf, o3); 1182 + off = o4; 1183 + } else { 1184 + break; 1222 1185 } 1223 - }; 1186 + } 1187 + }; 1224 1188 1225 - ws.onclose = () => { 1226 - updateConnStatus(false); 1227 - setTimeout(() => { 1228 - reconnectDelay = Math.min(reconnectDelay * 2, 30000); 1229 - connect(); 1230 - }, reconnectDelay); 1231 - }; 1232 - } 1189 + ws.onclose = () => { 1190 + updateConnStatus(false); 1191 + setTimeout(() => { 1192 + reconnectDelay = Math.min(reconnectDelay * 2, 30000); 1193 + connect(); 1194 + }, reconnectDelay); 1195 + }; 1196 + } 1233 1197 1234 - connect(); 1198 + connect(); 1235 1199 1236 - // ── Video WebSocket (separate connection, no MoQ framing) ────────── 1237 - let videoWs = null; 1238 - let videoReconnectDelay = 1000; 1200 + // ── Video WebSocket (separate connection, no MoQ framing) ────────── 1201 + let videoWs = null; 1202 + let videoReconnectDelay = 1000; 1239 1203 1240 - function connectVideoWs() { 1241 - videoWs = new WebSocket( 1242 - proto + "//" + location.host + "/ws/" + roomId + "/video", 1243 - ); 1244 - videoWs.binaryType = "arraybuffer"; 1204 + function connectVideoWs() { 1205 + videoWs = new WebSocket(proto + "//" + location.host + "/ws/" + roomId + "/video"); 1206 + videoWs.binaryType = "arraybuffer"; 1245 1207 1246 - videoWs.onopen = () => { 1247 - debug("video WS connected"); 1248 - videoReconnectDelay = 1000; 1249 - awaitingInit = true; 1250 - pendingBoxes.length = 0; 1251 - seekInitDone = false; 1252 - }; 1208 + videoWs.onopen = () => { 1209 + debug("video WS connected"); 1210 + videoReconnectDelay = 1000; 1211 + awaitingInit = true; 1212 + pendingBoxes.length = 0; 1213 + seekInitDone = false; 1214 + }; 1253 1215 1254 - videoWs.onmessage = (event) => { 1255 - const data = new Uint8Array(event.data); 1216 + videoWs.onmessage = (event) => { 1217 + const data = new Uint8Array(event.data); 1256 1218 1257 - // Always detect init segments, even when not awaitingInit. 1258 - // The video WS and control WS are independent connections with 1259 - // no ordering guarantee — init can arrive before the state update. 1260 - const isInit = 1261 - data.length >= 8 && 1262 - String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 1219 + // Always detect init segments, even when not awaitingInit. 1220 + // The video WS and control WS are independent connections with 1221 + // no ordering guarantee — init can arrive before the state update. 1222 + const isInit = 1223 + data.length >= 8 && String.fromCharCode(data[4], data[5], data[6], data[7]) === "ftyp"; 1263 1224 1264 - if (isInit) { 1265 - debug("init segment received (video WS)"); 1266 - awaitingInit = false; 1267 - pendingBoxes.length = 0; 1268 - if (sourceBuffer && !sourceBuffer.updating) { 1269 - try { 1270 - sourceBuffer.abort(); 1271 - if (sourceBuffer.buffered.length > 0) { 1272 - const end = sourceBuffer.buffered.end( 1273 - sourceBuffer.buffered.length - 1, 1274 - ); 1275 - sourceBuffer.remove(0, end); 1276 - } 1277 - } catch (_) {} 1278 - } 1279 - appendVideo(data); 1280 - return; 1225 + if (isInit) { 1226 + debug("init segment received (video WS)"); 1227 + awaitingInit = false; 1228 + pendingBoxes.length = 0; 1229 + if (sourceBuffer && !sourceBuffer.updating) { 1230 + try { 1231 + sourceBuffer.abort(); 1232 + if (sourceBuffer.buffered.length > 0) { 1233 + const end = sourceBuffer.buffered.end(sourceBuffer.buffered.length - 1); 1234 + sourceBuffer.remove(0, end); 1235 + } 1236 + } catch (_) {} 1281 1237 } 1238 + appendVideo(data); 1239 + return; 1240 + } 1282 1241 1283 - if (awaitingInit) { 1284 - pendingBoxes.push(data); 1285 - return; 1286 - } 1242 + if (awaitingInit) { 1243 + pendingBoxes.push(data); 1244 + return; 1245 + } 1287 1246 1288 - appendVideo(data); 1289 - }; 1247 + appendVideo(data); 1248 + }; 1290 1249 1291 - videoWs.onclose = () => { 1250 + videoWs.onclose = () => { 1251 + setTimeout(() => { 1252 + videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); 1253 + connectVideoWs(); 1254 + }, videoReconnectDelay); 1255 + }; 1256 + } 1257 + 1258 + // Open video connection after a short delay to let the control WS 1259 + // establish the room subscription first. 1260 + setTimeout(connectVideoWs, 100); 1261 + 1262 + function copyRoomLink() { 1263 + const url = window.location.href; 1264 + navigator.clipboard 1265 + .writeText(url) 1266 + .then(() => { 1267 + const btn = document.getElementById("copy-link"); 1268 + if (!btn) return; 1269 + const original = btn.innerHTML; 1270 + btn.innerHTML = '<span class="icon">&#x2713;</span>'; 1292 1271 setTimeout(() => { 1293 - videoReconnectDelay = Math.min(videoReconnectDelay * 2, 30000); 1294 - connectVideoWs(); 1295 - }, videoReconnectDelay); 1296 - }; 1272 + btn.innerHTML = original; 1273 + }, 1500); 1274 + }) 1275 + .catch(() => { 1276 + const input = document.createElement("input"); 1277 + input.value = url; 1278 + document.body.appendChild(input); 1279 + input.select(); 1280 + document.execCommand("copy"); 1281 + document.body.removeChild(input); 1282 + }); 1283 + } 1284 + 1285 + function appendChat(chat) { 1286 + const container = document.getElementById("chat-messages"); 1287 + const div = document.createElement("div"); 1288 + div.className = "chat-msg" + (chat.type === "system_event" ? " system" : ""); 1289 + 1290 + let ts = ""; 1291 + if (chat.ts) { 1292 + const d = new Date(chat.ts); 1293 + ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 1297 1294 } 1298 1295 1299 - // Open video connection after a short delay to let the control WS 1300 - // establish the room subscription first. 1301 - setTimeout(connectVideoWs, 100); 1296 + div.innerHTML = 1297 + '<span class="user">' + 1298 + esc(chat.user || "anonymous") + 1299 + "</span>" + 1300 + '<span class="time">' + 1301 + ts + 1302 + "</span><br>" + 1303 + '<span class="text">' + 1304 + esc(chat.content || "") + 1305 + "</span>"; 1306 + container.appendChild(div); 1302 1307 1303 - function copyRoomLink() { 1304 - const url = window.location.href; 1305 - navigator.clipboard 1306 - .writeText(url) 1307 - .then(() => { 1308 - const btn = document.getElementById("copy-link"); 1309 - if (!btn) return; 1310 - const original = btn.innerHTML; 1311 - btn.innerHTML = '<span class="icon">&#x2713;</span>'; 1312 - setTimeout(() => { 1313 - btn.innerHTML = original; 1314 - }, 1500); 1315 - }) 1316 - .catch(() => { 1317 - const input = document.createElement("input"); 1318 - input.value = url; 1319 - document.body.appendChild(input); 1320 - input.select(); 1321 - document.execCommand("copy"); 1322 - document.body.removeChild(input); 1323 - }); 1308 + // Auto-scroll only if user is near the bottom (within 30px threshold). 1309 + const threshold = 30; 1310 + if (container.scrollHeight - container.scrollTop - container.clientHeight < threshold) { 1311 + container.scrollTop = container.scrollHeight; 1324 1312 } 1313 + } 1325 1314 1326 - function appendChat(chat) { 1327 - const container = document.getElementById("chat-messages"); 1328 - const div = document.createElement("div"); 1329 - div.className = 1330 - "chat-msg" + (chat.type === "system_event" ? " system" : ""); 1315 + let elapsedInterval = null; 1331 1316 1332 - let ts = ""; 1333 - if (chat.ts) { 1334 - const d = new Date(chat.ts); 1335 - ts = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); 1336 - } 1317 + function parseDuration(s) { 1318 + // Converts "M:SS" or "H:MM:SS" to seconds 1319 + const parts = s.split(":").map(Number); 1320 + if (parts.length === 2) return parts[0] * 60 + parts[1]; 1321 + if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; 1322 + return 0; 1323 + } 1337 1324 1338 - div.innerHTML = 1339 - '<span class="user">' + 1340 - esc(chat.user || "anonymous") + 1341 - "</span>" + 1342 - '<span class="time">' + 1343 - ts + 1344 - "</span><br>" + 1345 - '<span class="text">' + 1346 - esc(chat.content || "") + 1347 - "</span>"; 1348 - container.appendChild(div); 1325 + function updateNowPlaying(state) { 1326 + const container = document.getElementById("video-container"); 1327 + if (!container) return; 1349 1328 1350 - // Auto-scroll only if user is near the bottom (within 30px threshold). 1351 - const threshold = 30; 1352 - if ( 1353 - container.scrollHeight - 1354 - container.scrollTop - 1355 - container.clientHeight < 1356 - threshold 1357 - ) { 1358 - container.scrollTop = container.scrollHeight; 1359 - } 1329 + // Update room name in top bar from state 1330 + if (state.room_name) { 1331 + const nameEl = document.getElementById("room-name"); 1332 + if (nameEl) nameEl.textContent = state.room_name; 1360 1333 } 1361 1334 1362 - let elapsedInterval = null; 1363 - 1364 - function parseDuration(s) { 1365 - // Converts "M:SS" or "H:MM:SS" to seconds 1366 - const parts = s.split(":").map(Number); 1367 - if (parts.length === 2) return parts[0] * 60 + parts[1]; 1368 - if (parts.length === 3) 1369 - return parts[0] * 3600 + parts[1] * 60 + parts[2]; 1370 - return 0; 1335 + // Update viewer count pill 1336 + const pill = document.getElementById("viewer-pill"); 1337 + if (pill) { 1338 + const c = state.clients || 0; 1339 + pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 1371 1340 } 1372 1341 1373 - function updateNowPlaying(state) { 1374 - const container = document.getElementById("video-container"); 1375 - if (!container) return; 1342 + if (state.current_track) { 1343 + const trackId = state.current_track.id; 1344 + const trackChanged = currentTrackId !== trackId; 1376 1345 1377 - // Update room name in top bar from state 1378 - if (state.room_name) { 1379 - const nameEl = document.getElementById("room-name"); 1380 - if (nameEl) nameEl.textContent = state.room_name; 1346 + // Detect track change — reset video state. 1347 + if (currentTrackId !== null && trackChanged) { 1348 + debug("track change", currentTrackId, "->", trackId); 1349 + awaitingInit = true; 1350 + pendingBoxes.length = 0; 1351 + seekInitDone = false; 1352 + if (elapsedInterval) { 1353 + clearInterval(elapsedInterval); 1354 + elapsedInterval = null; 1355 + } 1381 1356 } 1357 + currentTrackId = trackId; 1382 1358 1383 - // Update viewer count pill 1384 - const pill = document.getElementById("viewer-pill"); 1385 - if (pill) { 1386 - const c = state.clients || 0; 1387 - pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 1359 + // Skip overlay DOM work when the track hasn't changed. 1360 + // Queue display is still updated below. 1361 + if (!trackChanged) { 1362 + updateQueueDisplay(state); 1363 + return; 1388 1364 } 1389 1365 1390 - if (state.current_track) { 1391 - const trackId = state.current_track.id; 1392 - const trackChanged = currentTrackId !== trackId; 1366 + const startedAt = state.current_track.started_at; 1367 + const sourceUrl = state.current_track.url || ""; 1393 1368 1394 - // Detect track change — reset video state. 1395 - if (currentTrackId !== null && trackChanged) { 1396 - debug("track change", currentTrackId, "->", trackId); 1397 - awaitingInit = true; 1398 - pendingBoxes.length = 0; 1399 - seekInitDone = false; 1400 - if (elapsedInterval) { 1401 - clearInterval(elapsedInterval); 1402 - elapsedInterval = null; 1403 - } 1404 - } 1405 - currentTrackId = trackId; 1369 + // Show track overlay, hide idle message 1370 + container.classList.remove("idle"); 1406 1371 1407 - // Skip overlay DOM work when the track hasn't changed. 1408 - // Queue display is still updated below. 1409 - if (!trackChanged) { 1410 - updateQueueDisplay(state); 1411 - return; 1412 - } 1413 - 1414 - const startedAt = state.current_track.started_at; 1415 - const sourceUrl = state.current_track.url || ""; 1416 - 1417 - // Show track overlay, hide idle message 1418 - container.classList.remove("idle"); 1419 - 1420 - const toThumb = document.getElementById("to-thumb"); 1421 - const toTitle = document.getElementById("to-title"); 1422 - const toElapsed = document.getElementById("to-elapsed"); 1423 - const toDuration = document.getElementById("to-duration"); 1424 - const toViewers = document.getElementById("to-viewers"); 1425 - const toSource = document.getElementById("to-source"); 1426 - const toFill = document.getElementById("to-progress-fill"); 1427 - const toOverlay = document.getElementById("track-overlay"); 1372 + const toThumb = document.getElementById("to-thumb"); 1373 + const toTitle = document.getElementById("to-title"); 1374 + const toElapsed = document.getElementById("to-elapsed"); 1375 + const toDuration = document.getElementById("to-duration"); 1376 + const toViewers = document.getElementById("to-viewers"); 1377 + const toSource = document.getElementById("to-source"); 1378 + const toFill = document.getElementById("to-progress-fill"); 1379 + const toOverlay = document.getElementById("track-overlay"); 1428 1380 1429 - // Thumbnail 1430 - if (state.current_track.thumbnail) { 1431 - toThumb.src = state.current_track.thumbnail; 1432 - toThumb.style.display = "block"; 1433 - } else { 1434 - toThumb.style.display = "none"; 1435 - } 1381 + // Thumbnail 1382 + if (state.current_track.thumbnail) { 1383 + toThumb.src = state.current_track.thumbnail; 1384 + toThumb.style.display = "block"; 1385 + } else { 1386 + toThumb.style.display = "none"; 1387 + } 1436 1388 1437 - // Title 1438 - toTitle.textContent = state.current_track.title; 1389 + // Title 1390 + toTitle.textContent = state.current_track.title; 1439 1391 1440 - // Duration 1441 - toDuration.textContent = " / " + state.current_track.duration; 1392 + // Duration 1393 + toDuration.textContent = " / " + state.current_track.duration; 1442 1394 1443 - // Source link 1444 - if (sourceUrl) { 1445 - toSource.href = sourceUrl; 1446 - toSource.textContent = "source"; 1447 - toSource.style.display = "inline"; 1448 - } else { 1449 - toSource.style.display = "none"; 1450 - } 1395 + // Source link 1396 + if (sourceUrl) { 1397 + toSource.href = sourceUrl; 1398 + toSource.textContent = "source"; 1399 + toSource.style.display = "inline"; 1400 + } else { 1401 + toSource.style.display = "none"; 1402 + } 1451 1403 1452 - // Viewers 1453 - toViewers.textContent = state.clients 1454 - ? " · " + 1455 - state.clients + 1456 - " viewer" + 1457 - (state.clients !== 1 ? "s" : "") 1458 - : ""; 1404 + // Viewers 1405 + toViewers.textContent = state.clients 1406 + ? " · " + state.clients + " viewer" + (state.clients !== 1 ? "s" : "") 1407 + : ""; 1459 1408 1460 - // Elapsed (initial) 1461 - toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); 1409 + // Elapsed (initial) 1410 + toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); 1462 1411 1463 - // Ensure overlay is visible on first play 1464 - toOverlay.classList.add("show"); 1465 - setTimeout(() => toOverlay.classList.remove("show"), 3000); 1412 + // Ensure overlay is visible on first play 1413 + toOverlay.classList.add("show"); 1414 + setTimeout(() => toOverlay.classList.remove("show"), 3000); 1466 1415 1467 - // Update elapsed every second from local clock. 1468 - elapsedInterval = setInterval(() => { 1469 - const elapsed = (Date.now() - startedAt) / 1000; 1470 - const elSpan = document.getElementById("to-elapsed"); 1471 - if (elSpan) elSpan.textContent = formatTime(elapsed); 1416 + // Update elapsed every second from local clock. 1417 + elapsedInterval = setInterval(() => { 1418 + const elapsed = (Date.now() - startedAt) / 1000; 1419 + const elSpan = document.getElementById("to-elapsed"); 1420 + if (elSpan) elSpan.textContent = formatTime(elapsed); 1472 1421 1473 - const fill = document.getElementById("to-progress-fill"); 1474 - if (fill && state.current_track) { 1475 - const total = parseDuration(state.current_track.duration); 1476 - if (total > 0) { 1477 - fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 1478 - } 1422 + const fill = document.getElementById("to-progress-fill"); 1423 + if (fill && state.current_track) { 1424 + const total = parseDuration(state.current_track.duration); 1425 + if (total > 0) { 1426 + fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 1479 1427 } 1480 - }, 1000); 1428 + } 1429 + }, 1000); 1481 1430 1482 - // Forward-only sync: if video is more than 3 seconds behind 1483 - // the live edge, seek forward to catch up. Never seek backward. 1484 - const video = document.getElementById("video-player"); 1485 - if (video && video.buffered.length > 0 && startedAt) { 1486 - const target = (Date.now() - startedAt) / 1000; 1487 - if (target > 0) { 1488 - const behind = target - video.currentTime; 1489 - if (behind > 3) { 1490 - video.currentTime = target; 1491 - } 1431 + // Forward-only sync: if video is more than 3 seconds behind 1432 + // the live edge, seek forward to catch up. Never seek backward. 1433 + const video = document.getElementById("video-player"); 1434 + if (video && video.buffered.length > 0 && startedAt) { 1435 + const target = (Date.now() - startedAt) / 1000; 1436 + if (target > 0) { 1437 + const behind = target - video.currentTime; 1438 + if (behind > 3) { 1439 + video.currentTime = target; 1492 1440 } 1493 1441 } 1494 - } else { 1495 - if (elapsedInterval) { 1496 - clearInterval(elapsedInterval); 1497 - elapsedInterval = null; 1498 - } 1499 - currentTrackId = null; 1500 - container.classList.add("idle"); 1442 + } 1443 + } else { 1444 + if (elapsedInterval) { 1445 + clearInterval(elapsedInterval); 1446 + elapsedInterval = null; 1501 1447 } 1502 - 1503 - // Update queue section from state — inline, not deferred. 1504 - updateQueueDisplay(state); 1448 + currentTrackId = null; 1449 + container.classList.add("idle"); 1505 1450 } 1506 1451 1507 - function updateQueueDisplay(state) { 1508 - const qSection = document.getElementById("queue-section"); 1509 - const hSection = document.getElementById("history-section"); 1510 - if (!qSection) return; 1452 + // Update queue section from state — inline, not deferred. 1453 + updateQueueDisplay(state); 1454 + } 1511 1455 1512 - if (state.queue && state.queue.length > 0) { 1513 - let html = '<ul class="queue">'; 1514 - for (const item of state.queue) { 1515 - const thumb = item.thumbnail 1516 - ? '<img src="' + 1517 - esc(item.thumbnail) + 1518 - '" class="thumb-img" alt="">' 1519 - : '<div class="thumb"></div>'; 1520 - const pendingClass = item.pending ? ' class="pending"' : ''; 1521 - html += 1522 - '<li' + pendingClass + '>' + 1523 - thumb + 1524 - '<div class="title">' + 1525 - esc(item.title) + 1526 - "</div>" + 1527 - '<div class="duration">' + 1528 - esc(item.duration) + 1529 - "</div>" + 1530 - '<a href="' + 1531 - safeUrl(item.url) + 1532 - '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1533 - "</li>"; 1534 - } 1535 - html += "</ul>"; 1536 - qSection.innerHTML = html; 1537 - } else if (state.current_track) { 1538 - qSection.innerHTML = 1539 - '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 1540 - } else { 1541 - qSection.innerHTML = 1542 - '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 1543 - } 1456 + function updateQueueDisplay(state) { 1457 + const qSection = document.getElementById("queue-section"); 1458 + const hSection = document.getElementById("history-section"); 1459 + if (!qSection) return; 1544 1460 1545 - if (hSection && state.history && state.history.length > 0) { 1546 - let html = 1547 - '<div class="history-header">Played</div><ul class="queue">'; 1548 - for (const item of state.history) { 1549 - const thumb = item.thumbnail 1550 - ? '<img src="' + 1551 - esc(item.thumbnail) + 1552 - '" class="thumb-img" alt="">' 1553 - : '<div class="thumb"></div>'; 1554 - html += 1555 - '<li style="opacity:0.7">' + 1556 - thumb + 1557 - '<div class="title">' + 1558 - esc(item.title) + 1559 - "</div>" + 1560 - '<div class="duration">' + 1561 - esc(item.duration) + 1562 - "</div>" + 1563 - '<a href="' + 1564 - safeUrl(item.url) + 1565 - '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1566 - "</li>"; 1567 - } 1568 - html += "</ul>"; 1569 - hSection.innerHTML = html; 1570 - } else if (hSection) { 1571 - hSection.innerHTML = ""; 1461 + if (state.queue && state.queue.length > 0) { 1462 + let html = '<ul class="queue">'; 1463 + for (const item of state.queue) { 1464 + const thumb = item.thumbnail 1465 + ? '<img src="' + esc(item.thumbnail) + '" class="thumb-img" alt="">' 1466 + : '<div class="thumb"></div>'; 1467 + const pendingClass = item.pending ? ' class="pending"' : ""; 1468 + html += 1469 + "<li" + 1470 + pendingClass + 1471 + ">" + 1472 + thumb + 1473 + '<div class="title">' + 1474 + esc(item.title) + 1475 + "</div>" + 1476 + '<div class="duration">' + 1477 + esc(item.duration) + 1478 + "</div>" + 1479 + '<a href="' + 1480 + safeUrl(item.url) + 1481 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1482 + "</li>"; 1572 1483 } 1484 + html += "</ul>"; 1485 + qSection.innerHTML = html; 1486 + } else if (state.current_track) { 1487 + qSection.innerHTML = 1488 + '<div class="empty-state"><div class="icon">&#x25B6;</div><div class="label">No upcoming tracks</div><div class="hint">Add another link to keep the queue going.</div></div>'; 1489 + } else { 1490 + qSection.innerHTML = 1491 + '<div class="empty-state"><div class="icon">&#x1F3B5;</div><div class="label">Queue is empty</div><div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div></div>'; 1573 1492 } 1574 1493 1575 - function formatTime(secs) { 1576 - const m = Math.floor(secs / 60); 1577 - const s = Math.floor(secs % 60); 1578 - return m + ":" + String(s).padStart(2, "0"); 1579 - } 1580 - 1581 - function esc(s) { 1582 - const d = document.createElement("div"); 1583 - d.textContent = s; 1584 - return d.innerHTML; 1585 - } 1586 - 1587 - function safeUrl(url) { 1588 - if (!url) return ""; 1589 - // Only allow http:// and https:// URLs. 1590 - if (url.startsWith("http://") || url.startsWith("https://")) { 1591 - return esc(url); 1494 + if (hSection && state.history && state.history.length > 0) { 1495 + let html = '<div class="history-header">Played</div><ul class="queue">'; 1496 + for (const item of state.history) { 1497 + const thumb = item.thumbnail 1498 + ? '<img src="' + esc(item.thumbnail) + '" class="thumb-img" alt="">' 1499 + : '<div class="thumb"></div>'; 1500 + html += 1501 + '<li style="opacity:0.7">' + 1502 + thumb + 1503 + '<div class="title">' + 1504 + esc(item.title) + 1505 + "</div>" + 1506 + '<div class="duration">' + 1507 + esc(item.duration) + 1508 + "</div>" + 1509 + '<a href="' + 1510 + safeUrl(item.url) + 1511 + '" target="_blank" class="source-link" rel="noopener noreferrer">source</a>' + 1512 + "</li>"; 1592 1513 } 1593 - return ""; // Reject all other URL schemes (javascript:, data:, file:, etc.) 1514 + html += "</ul>"; 1515 + hSection.innerHTML = html; 1516 + } else if (hSection) { 1517 + hSection.innerHTML = ""; 1594 1518 } 1519 + } 1595 1520 1596 - // Send chat 1597 - document.getElementById("chat-send").addEventListener("click", sendChat); 1598 - document.getElementById("chat-input").addEventListener("keydown", (e) => { 1599 - if (e.key === "Enter") sendChat(); 1600 - }); 1521 + function formatTime(secs) { 1522 + const m = Math.floor(secs / 60); 1523 + const s = Math.floor(secs % 60); 1524 + return m + ":" + String(s).padStart(2, "0"); 1525 + } 1601 1526 1602 - function sendChat() { 1603 - const input = document.getElementById("chat-input"); 1604 - const content = input.value.trim(); 1605 - if (!content) return; 1606 - const payload = new TextEncoder().encode(JSON.stringify({ content })); 1607 - const obj = concat([ 1608 - encVarint(0x00), 1609 - encVarint(2), // track_id = Chat 1610 - encVarint(0), // group_id 1611 - encVarint(0), // object_id 1612 - payload, 1613 - ]); 1614 - ws.send(obj); 1615 - input.value = ""; 1616 - } 1527 + function esc(s) { 1528 + const d = document.createElement("div"); 1529 + d.textContent = s; 1530 + return d.innerHTML; 1531 + } 1617 1532 1618 - // ── Ingest helper ─────────────────────────────────────────────── 1619 - async function ingest() { 1620 - const input = document.getElementById("url-input"); 1621 - const btn = document.querySelector(".input-row .btn-primary"); 1622 - if (!input.value.trim()) return; 1623 - btn.disabled = true; 1624 - btn.textContent = "Adding…"; 1625 - try { 1626 - const resp = await fetch("/api/ingest", { 1627 - method: "POST", 1628 - headers: { "Content-Type": "application/json" }, 1629 - body: JSON.stringify({ url: input.value, room_id: roomId }), 1630 - }); 1631 - if (resp.ok) { 1632 - input.value = ""; 1633 - } else { 1634 - const err = await resp.json(); 1635 - alert(err.error || "ingest failed"); 1636 - } 1637 - } finally { 1638 - btn.disabled = false; 1639 - btn.textContent = "Add"; 1640 - } 1533 + function safeUrl(url) { 1534 + if (!url) return ""; 1535 + // Only allow http:// and https:// URLs. 1536 + if (url.startsWith("http://") || url.startsWith("https://")) { 1537 + return esc(url); 1641 1538 } 1539 + return ""; // Reject all other URL schemes (javascript:, data:, file:, etc.) 1540 + } 1642 1541 1643 - // ── Skip current track ────────────────────────────────────────── 1644 - async function skip() { 1645 - await fetch("/api/skip", { 1542 + // Send chat 1543 + document.getElementById("chat-send").addEventListener("click", sendChat); 1544 + document.getElementById("chat-input").addEventListener("keydown", (e) => { 1545 + if (e.key === "Enter") sendChat(); 1546 + }); 1547 + 1548 + function sendChat() { 1549 + const input = document.getElementById("chat-input"); 1550 + const content = input.value.trim(); 1551 + if (!content) return; 1552 + const payload = new TextEncoder().encode(JSON.stringify({ content })); 1553 + const obj = concat([ 1554 + encVarint(0x00), 1555 + encVarint(2), // track_id = Chat 1556 + encVarint(0), // group_id 1557 + encVarint(0), // object_id 1558 + payload, 1559 + ]); 1560 + ws.send(obj); 1561 + input.value = ""; 1562 + } 1563 + 1564 + // ── Ingest helper ─────────────────────────────────────────────── 1565 + async function ingest() { 1566 + const input = document.getElementById("url-input"); 1567 + const btn = document.querySelector(".input-row .btn-primary"); 1568 + if (!input.value.trim()) return; 1569 + btn.disabled = true; 1570 + btn.textContent = "Adding…"; 1571 + try { 1572 + const resp = await fetch("/api/ingest", { 1646 1573 method: "POST", 1647 1574 headers: { "Content-Type": "application/json" }, 1648 - body: JSON.stringify({ room_id: roomId }), 1575 + body: JSON.stringify({ url: input.value, room_id: roomId }), 1649 1576 }); 1577 + if (resp.ok) { 1578 + input.value = ""; 1579 + } else { 1580 + const err = await resp.json(); 1581 + alert(err.error || "ingest failed"); 1582 + } 1583 + } finally { 1584 + btn.disabled = false; 1585 + btn.textContent = "Add"; 1650 1586 } 1587 + } 1588 + 1589 + // ── Skip current track ────────────────────────────────────────── 1590 + async function skip() { 1591 + await fetch("/api/skip", { 1592 + method: "POST", 1593 + headers: { "Content-Type": "application/json" }, 1594 + body: JSON.stringify({ room_id: roomId }), 1595 + }); 1596 + } 1651 1597 </script> 1652 1598 </body> 1653 1599 </html>
+11
treefmt.toml
··· 1 + excludes = ["flake.lock", ".envrc"] 2 + 3 + [formatter.nix] 4 + command = "nixfmt" 5 + includes = ["*.nix"] 6 + 7 + [formatter.rust] 8 + command = "rustfmt" 9 + includes = ["*.rs"] 10 + 11 +