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.

fix: frontend changes

karitham (May 17, 2026, 2:42 PM +0200) edd96aaa 39d3206d

+923 -168
+30 -3
src/db.rs
··· 117 117 } 118 118 } 119 119 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); 128 + 129 + if !has_name { 130 + sqlx::query("ALTER TABLE rooms ADD COLUMN name TEXT NOT NULL DEFAULT ''") 131 + .execute(pool) 132 + .await?; 133 + } 134 + 120 135 Ok(()) 121 136 } 122 137 ··· 146 161 pub(crate) async fn room_insert( 147 162 pool: &Pool<Sqlite>, 148 163 id: &str, 164 + name: &str, 149 165 persistent: bool, 150 166 ) -> Result<(), sqlx::Error> { 151 167 let now = now_iso(); 152 168 sqlx::query( 153 - "INSERT INTO rooms (id, created_at, updated_at, persistent) VALUES (?1, ?2, ?3, ?4)", 169 + "INSERT INTO rooms (id, name, created_at, updated_at, persistent) VALUES (?1, ?2, ?3, ?4, ?5)", 154 170 ) 155 171 .bind(id) 172 + .bind(name) 156 173 .bind(&now) 157 174 .bind(&now) 158 175 .bind(persistent as i32) 159 176 .execute(pool) 160 177 .await?; 161 178 Ok(()) 179 + } 180 + 181 + pub(crate) async fn get_room_name(pool: &Pool<Sqlite>, room_id: &str) -> String { 182 + sqlx::query_scalar::<_, String>("SELECT name FROM rooms WHERE id = ?") 183 + .bind(room_id) 184 + .fetch_optional(pool) 185 + .await 186 + .ok() 187 + .flatten() 188 + .unwrap_or_default() 162 189 } 163 190 164 191 pub(crate) async fn room_exists(pool: &Pool<Sqlite>, id: &str) -> Result<bool, sqlx::Error> { ··· 240 267 #[tokio::test] 241 268 async fn test_room_crud() { 242 269 let pool = setup().await; 243 - room_insert(&pool, "test-room", false).await.unwrap(); 270 + room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 244 271 assert!(room_exists(&pool, "test-room").await.unwrap()); 245 272 room_delete(&pool, "test-room").await.unwrap(); 246 273 assert!(!room_exists(&pool, "test-room").await.unwrap()); ··· 249 276 #[tokio::test] 250 277 async fn test_chat_crud() { 251 278 let pool = setup().await; 252 - room_insert(&pool, "test-room", false).await.unwrap(); 279 + room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 253 280 254 281 for i in 0..5 { 255 282 let msg = NewChatMessage {
+1
src/main.rs
··· 5 5 6 6 mod db; 7 7 mod media; 8 + mod names; 8 9 mod playback; 9 10 mod playlist; 10 11 mod room;
+339
src/names.rs
··· 1 + pub(crate) fn generate_room_name(room_id: &str) -> String { 2 + use std::hash::{Hash, Hasher}; 3 + let mut hasher = std::collections::hash_map::DefaultHasher::new(); 4 + room_id.hash(&mut hasher); 5 + let h = hasher.finish(); 6 + 7 + let adj_idx = (h % ADJECTIVES.len() as u64) as usize; 8 + let noun_idx = ((h / ADJECTIVES.len() as u64) % NOUNS.len() as u64) as usize; 9 + 10 + format!("{}-{}", ADJECTIVES[adj_idx], NOUNS[noun_idx]) 11 + } 12 + 13 + const ADJECTIVES: &[&str] = &[ 14 + "ancient", 15 + "autumn", 16 + "blessed", 17 + "blooming", 18 + "breezy", 19 + "bright", 20 + "calm", 21 + "clear", 22 + "copper", 23 + "cosmic", 24 + "crimson", 25 + "crystal", 26 + "curious", 27 + "dancing", 28 + "dawn", 29 + "deep", 30 + "delicate", 31 + "divine", 32 + "dreamy", 33 + "drift", 34 + "dusk", 35 + "eager", 36 + "early", 37 + "earthy", 38 + "electric", 39 + "empty", 40 + "endless", 41 + "warm", 42 + "faint", 43 + "falling", 44 + "fancy", 45 + "fast", 46 + "fiery", 47 + "floating", 48 + "floral", 49 + "flowing", 50 + "flying", 51 + "foggy", 52 + "frozen", 53 + "gentle", 54 + "glad", 55 + "glowing", 56 + "golden", 57 + "graceful", 58 + "grand", 59 + "gravel", 60 + "happy", 61 + "hidden", 62 + "hollow", 63 + "holy", 64 + "honest", 65 + "hopeful", 66 + "icy", 67 + "jolly", 68 + "kind", 69 + "laughing", 70 + "lazy", 71 + "lively", 72 + "lonely", 73 + "loud", 74 + "lovely", 75 + "lucky", 76 + "luminous", 77 + "magic", 78 + "mellow", 79 + "merry", 80 + "misty", 81 + "modern", 82 + "modest", 83 + "muddy", 84 + "mute", 85 + "nameless", 86 + "noble", 87 + "noisy", 88 + "open", 89 + "pale", 90 + "patient", 91 + "peaceful", 92 + "plain", 93 + "playful", 94 + "pleasant", 95 + "polite", 96 + "proud", 97 + "pure", 98 + "purple", 99 + "quiet", 100 + "rapid", 101 + "rare", 102 + "restless", 103 + "rough", 104 + "royal", 105 + "sacred", 106 + "safe", 107 + "shy", 108 + "silly", 109 + "silver", 110 + "simple", 111 + "sleepy", 112 + "slow", 113 + "smooth", 114 + "soft", 115 + "solar", 116 + "solid", 117 + "sparkling", 118 + "still", 119 + "strange", 120 + "subtle", 121 + "summer", 122 + "sunny", 123 + "swift", 124 + "tender", 125 + "thawed", 126 + "tiny", 127 + "tranquil", 128 + "true", 129 + "vivid", 130 + "wild", 131 + "winter", 132 + "wispy", 133 + "wooden", 134 + "young", 135 + "zealous", 136 + ]; 137 + 138 + const NOUNS: &[&str] = &[ 139 + "antelope", 140 + "arch", 141 + "arrow", 142 + "aurora", 143 + "badger", 144 + "basin", 145 + "bay", 146 + "bear", 147 + "bee", 148 + "blossom", 149 + "branch", 150 + "breeze", 151 + "bridge", 152 + "brook", 153 + "butterfly", 154 + "cabin", 155 + "cactus", 156 + "canyon", 157 + "capybara", 158 + "cascade", 159 + "castle", 160 + "cat", 161 + "cave", 162 + "cedar", 163 + "cherry", 164 + "cliff", 165 + "cloud", 166 + "coast", 167 + "comet", 168 + "coral", 169 + "cottage", 170 + "cove", 171 + "crane", 172 + "creek", 173 + "crown", 174 + "current", 175 + "cypress", 176 + "dale", 177 + "dawn", 178 + "deer", 179 + "delta", 180 + "dew", 181 + "dove", 182 + "dragon", 183 + "dune", 184 + "eagle", 185 + "echo", 186 + "elm", 187 + "ember", 188 + "falcon", 189 + "fern", 190 + "field", 191 + "finch", 192 + "fir", 193 + "firefly", 194 + "flame", 195 + "flower", 196 + "fog", 197 + "forest", 198 + "fox", 199 + "frost", 200 + "geyser", 201 + "glacier", 202 + "glade", 203 + "glen", 204 + "goat", 205 + "gorge", 206 + "grove", 207 + "gull", 208 + "harbor", 209 + "haven", 210 + "hawk", 211 + "heather", 212 + "heron", 213 + "hill", 214 + "horizon", 215 + "ibis", 216 + "iris", 217 + "island", 218 + "ivy", 219 + "jade", 220 + "jasmine", 221 + "jay", 222 + "journey", 223 + "koala", 224 + "lagoon", 225 + "lake", 226 + "lark", 227 + "lattice", 228 + "lily", 229 + "lion", 230 + "lotus", 231 + "lynx", 232 + "maple", 233 + "marble", 234 + "marsh", 235 + "meadow", 236 + "mist", 237 + "moon", 238 + "moss", 239 + "moth", 240 + "mountain", 241 + "nebula", 242 + "oak", 243 + "oasis", 244 + "ocean", 245 + "opossum", 246 + "orchid", 247 + "osprey", 248 + "otter", 249 + "owl", 250 + "palm", 251 + "panda", 252 + "pass", 253 + "path", 254 + "peak", 255 + "pebble", 256 + "pelican", 257 + "phoenix", 258 + "pigeon", 259 + "pillar", 260 + "pilot", 261 + "pine", 262 + "plain", 263 + "planet", 264 + "plateau", 265 + "plaza", 266 + "pond", 267 + "prairie", 268 + "puma", 269 + "quail", 270 + "quartz", 271 + "rabbit", 272 + "rain", 273 + "raven", 274 + "reef", 275 + "ridge", 276 + "river", 277 + "robin", 278 + "rock", 279 + "rose", 280 + "ruins", 281 + "salmon", 282 + "sand", 283 + "savanna", 284 + "seal", 285 + "shadow", 286 + "shard", 287 + "shark", 288 + "sheep", 289 + "shell", 290 + "shore", 291 + "sky", 292 + "slope", 293 + "snail", 294 + "snake", 295 + "snow", 296 + "solar", 297 + "sparrow", 298 + "spider", 299 + "spirit", 300 + "spring", 301 + "squid", 302 + "squirrel", 303 + "star", 304 + "storm", 305 + "stream", 306 + "summit", 307 + "sun", 308 + "swallow", 309 + "swan", 310 + "temple", 311 + "thistle", 312 + "thorn", 313 + "thunder", 314 + "tiger", 315 + "tower", 316 + "trail", 317 + "tree", 318 + "trout", 319 + "tulip", 320 + "tunnel", 321 + "turtle", 322 + "valley", 323 + "vapor", 324 + "vessel", 325 + "violet", 326 + "vulture", 327 + "warbler", 328 + "water", 329 + "wave", 330 + "weaver", 331 + "whale", 332 + "willow", 333 + "wind", 334 + "winter", 335 + "wolf", 336 + "wren", 337 + "yacht", 338 + "zebra", 339 + ];
+1 -1
src/playback.rs
··· 175 175 .await 176 176 .unwrap(); 177 177 db::run_migrations(&pool).await.unwrap(); 178 - db::room_insert(&pool, "test-room", false) 178 + db::room_insert(&pool, "test-room", "test-name", false) 179 179 .await 180 180 .unwrap(); 181 181 pool
+1 -1
src/playlist.rs
··· 182 182 .unwrap(); 183 183 db::run_migrations(&pool).await.unwrap(); 184 184 // Create a room row so foreign-key constraints are satisfied. 185 - db::room_insert(&pool, "test-room", false).await.unwrap(); 185 + db::room_insert(&pool, "test-room", "test-name", false).await.unwrap(); 186 186 Playlist::new(pool, "test-room") 187 187 } 188 188
+6 -1
src/room.rs
··· 25 25 use tokio::sync::{RwLock, mpsc, watch}; 26 26 27 27 use crate::db; 28 + use crate::names::generate_room_name; 28 29 use crate::playback::Player; 29 30 use crate::playlist::Playlist; 30 31 use crate::source::SourceRegistry; ··· 118 119 struct RoomActor { 119 120 rx: mpsc::Receiver<RoomCommand>, 120 121 room_id: RoomId, 122 + room_name: String, 121 123 publishers: TrackPublishers, 122 124 pool: Pool<Sqlite>, 123 125 ··· 163 165 /// Create a new room with a snowflake ID, persist to SQLite. 164 166 pub(crate) async fn create(&self) -> RoomRef { 165 167 let id = RoomId(self.id_gen.next_id().await.to_string()); 168 + let room_name = generate_room_name(&id.0); 166 169 let now = Utc::now(); 167 - let _ = db::room_insert(&self.pool, &id.0, false).await; 170 + let _ = db::room_insert(&self.pool, &id.0, &room_name, false).await; 168 171 169 172 let publishers = TrackPublishers::new(); 170 173 let (tx, rx) = mpsc::channel(256); ··· 178 181 let actor = RoomActor { 179 182 rx, 180 183 room_id: id.clone(), 184 + room_name: room_name.clone(), 181 185 publishers: publishers.clone(), 182 186 pool: self.pool.clone(), 183 187 state: PlaybackState::default(), ··· 608 612 ); 609 613 610 614 let snapshot = serde_json::json!({ 615 + "room_name": self.room_name, 611 616 "current_track": current, 612 617 "queue": queue, 613 618 "history": history,
+6
src/web.rs
··· 16 16 use sqlx::{Pool, Sqlite}; 17 17 use tokio::sync::Mutex; 18 18 19 + use crate::db; 19 20 use crate::room; 20 21 use crate::source::SourceRegistry; 21 22 use crate::types::{RoomId, TrackMeta}; ··· 108 109 #[template(path = "room.html")] 109 110 struct RoomTemplate { 110 111 room_id: String, 112 + #[allow(dead_code)] 113 + room_name: String, 111 114 queue: Vec<TrackMeta>, 112 115 history: Vec<TrackMeta>, 113 116 queue_empty: bool, ··· 159 162 .collect::<Vec<_>>() 160 163 }; 161 164 165 + let room_name = db::get_room_name(&state.pool, &id).await; 166 + 162 167 let tpl = RoomTemplate { 163 168 room_id: id, 169 + room_name, 164 170 queue, 165 171 history, 166 172 queue_empty,
+7 -1
templates/index.html
··· 4 4 <meta charset="UTF-8" /> 5 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 /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + /> 7 13 <style> 8 14 :root { 9 15 --base: #24273a; ··· 35 41 --radius-sm: 6px; 36 42 --radius-md: 10px; 37 43 --radius-lg: 14px; 38 - --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 44 + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 39 45 } 40 46 *, 41 47 *::before,
+532 -161
templates/room.html
··· 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 - <title>{{ room_id }} — moqbox</title> 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 /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + /> 7 13 <style> 8 14 :root { 9 15 --base: #24273a; ··· 35 41 --radius-sm: 6px; 36 42 --radius-md: 10px; 37 43 --radius-lg: 14px; 38 - --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; 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); 39 49 } 40 50 *, 41 51 *::before, ··· 47 57 html { 48 58 -webkit-font-smoothing: antialiased; 49 59 -moz-osx-font-smoothing: grayscale; 60 + } 61 + html, body { 62 + height: 100%; 50 63 } 51 64 body { 52 65 font-family: var(--font-sans); 53 66 background: var(--base); 54 67 color: var(--text); 55 - padding: 1.5rem; 56 - min-height: 100vh; 68 + padding: 1rem 1.5rem; 69 + display: flex; 70 + flex-direction: column; 57 71 } 58 72 59 73 /* Top bar */ 60 74 .top-bar { 61 75 display: flex; 62 76 align-items: center; 63 - gap: 0.75rem; 64 - margin-bottom: 1.5rem; 77 + justify-content: space-between; 78 + margin-bottom: 1rem; 79 + gap: 1rem; 65 80 } 66 - .back-link { 67 - display: inline-flex; 68 - align-items: center; 69 - gap: 0.375rem; 70 - color: var(--blue); 81 + .top-bar-home { 82 + color: var(--overlay1); 71 83 text-decoration: none; 72 - font-size: 0.9375rem; 73 - font-weight: 500; 74 - padding: 0.5rem 1rem; 75 - border-radius: var(--radius-md); 76 - background: var(--surface0); 77 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 78 - transition: 79 - background 150ms ease-out, 80 - box-shadow 150ms ease-out; 81 - min-height: 40px; 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); 82 93 } 83 - .back-link:hover { 84 - background: var(--surface1); 85 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 94 + .top-bar-center { 95 + flex: 1; 96 + text-align: center; 97 + min-width: 0; 86 98 } 87 - .room-id { 88 - font-weight: 600; 99 + .room-name { 100 + display: block; 101 + font-weight: 700; 89 102 font-size: 1.125rem; 103 + color: var(--text); 104 + line-height: 1.3; 90 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); 91 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; 92 154 } 93 155 94 156 /* Layout */ 95 157 .layout { 96 158 display: flex; 97 - gap: 1.5rem; 159 + gap: 1rem; 160 + flex: 1; 161 + min-height: 0; 98 162 } 99 163 .main-col { 100 164 flex: 1; 101 165 min-width: 0; 102 166 display: flex; 103 167 flex-direction: column; 168 + overflow-y: auto; 104 169 } 105 170 .chat-col { 106 - width: 340px; 171 + width: 320px; 107 172 flex-shrink: 0; 108 173 display: flex; 109 174 flex-direction: column; 175 + min-height: 0; 176 + border-left: 1px solid var(--surface2); 177 + padding-left: 1rem; 110 178 } 111 179 112 180 /* Video */ 113 181 #video-container { 114 182 position: relative; 115 - margin-bottom: 1.5rem; 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); 116 189 } 117 190 #video-player { 118 191 width: 100%; 119 - max-height: 85vh; 192 + aspect-ratio: 16 / 9; 193 + max-height: 60vh; 120 194 object-fit: contain; 195 + display: block; 121 196 background: var(--crust); 122 - border-radius: var(--radius-lg); 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 + } 224 + 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 { 123 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; 124 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 + } 300 + 125 301 .volume-overlay { 126 302 position: absolute; 127 303 bottom: 0.75rem; ··· 155 331 cursor: pointer; 156 332 background: transparent; 157 333 } 158 - box-shadow: 159 - 0 8px 32px rgba(0, 0, 0, 0.35), 160 - 0 2px 8px rgba(0, 0, 0, 0.2); 161 - } 162 334 163 - /* Now Playing */ 164 - #now-playing { 165 - background: var(--surface0); 166 - border-radius: var(--radius-md); 167 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 168 - padding: 0.75rem 1rem; 169 - margin-bottom: 0.75rem; 170 - font-size: 0.9375rem; 171 - display: flex; 172 - align-items: center; 173 - flex-wrap: wrap; 174 - gap: 0.25rem 0.5rem; 175 - } 176 - #now-playing strong { 177 - font-weight: 600; 178 - color: var(--mauve); 335 + /* Input Row */ 336 + .queue-group { 337 + margin-bottom: 0.5rem; 179 338 } 180 - #elapsed { 181 - font-variant-numeric: tabular-nums; 182 - } 183 - 184 - /* Input Row */ 185 339 .input-row { 186 340 display: flex; 187 341 gap: 0.5rem; 188 - margin-bottom: 1.5rem; 342 + margin-top: 0.75rem; 189 343 } 190 344 .input-row input { 191 345 flex: 1; ··· 239 393 button.btn-primary { 240 394 background: var(--green); 241 395 color: var(--crust); 242 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 396 + box-shadow: var(--shadow-card); 243 397 } 244 398 button.btn-primary:hover { 245 399 filter: brightness(1.12); 246 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 400 + box-shadow: var(--shadow-card-hover); 247 401 } 248 402 button.btn-secondary { 249 403 background: var(--surface1); 250 404 color: var(--text); 251 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 405 + box-shadow: var(--shadow-card); 252 406 } 253 407 button.btn-secondary:hover { 254 408 background: var(--surface2); 255 409 filter: brightness(1.08); 256 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 410 + box-shadow: var(--shadow-card-hover); 257 411 } 258 412 button.btn-accent { 259 413 background: var(--mauve); 260 414 color: var(--crust); 261 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 415 + box-shadow: var(--shadow-card); 262 416 } 263 417 button.btn-accent:hover { 264 418 filter: brightness(1.12); 265 - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 419 + box-shadow: var(--shadow-card-hover); 266 420 } 267 421 268 422 /* Queue */ ··· 272 426 .queue li { 273 427 background: var(--surface0); 274 428 border-radius: var(--radius-md); 275 - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 429 + box-shadow: var(--shadow-card); 276 430 padding: 0.75rem; 277 431 display: flex; 278 432 align-items: center; ··· 285 439 } 286 440 .queue li:hover { 287 441 background: var(--surface1); 288 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 442 + box-shadow: var(--shadow-card-hover); 289 443 } 290 444 .queue li .thumb, 291 445 .queue li .thumb-img { ··· 304 458 overflow: hidden; 305 459 text-overflow: ellipsis; 306 460 white-space: nowrap; 461 + text-wrap: pretty; 307 462 } 308 463 .queue li .duration { 309 464 color: var(--subtext0); ··· 331 486 } 332 487 333 488 .history-item { 334 - opacity: 0.7; 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; 335 512 } 336 513 337 514 /* Connection status */ ··· 351 528 352 529 /* History header */ 353 530 .history-header { 354 - margin-top: 1rem; 531 + margin-top: 1.5rem; 355 532 margin-bottom: 0.5rem; 356 - font-size: 0.9375rem; 357 - color: var(--subtext0); 358 - font-weight: 600; 359 - text-wrap: balance; 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; 360 540 } 361 541 362 542 /* Empty state */ ··· 391 571 max-width: 24em; 392 572 } 393 573 394 - /* Now-playing empty */ 395 - #now-playing.empty { 396 - color: var(--overlay0); 397 - font-size: 0.875rem; 398 - font-style: italic; 399 - } 400 - 401 574 /* Chat */ 402 575 .chat-messages { 403 576 flex: 1; 404 577 overflow-y: auto; 405 - max-height: calc(100vh - 180px); 578 + min-height: 0; 406 579 background: var(--surface0); 407 580 border-radius: var(--radius-lg); 408 - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); 581 + box-shadow: var(--shadow-card); 409 582 padding: 0.5rem; 410 583 display: flex; 411 584 flex-direction: column; 412 585 gap: 4px; 413 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; 414 598 } 415 599 .chat-msg { 416 600 padding: 6px 10px; ··· 475 659 min-height: 40px; 476 660 background: var(--mauve); 477 661 color: var(--crust); 478 - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); 662 + box-shadow: var(--shadow-card); 479 663 } 480 664 .chat-input-row button:hover { 481 665 filter: brightness(1.12); 482 - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 666 + box-shadow: var(--shadow-card-hover); 483 667 } 484 668 485 669 /* Stagger animation */ ··· 534 718 @media (max-width: 768px) { 535 719 .layout { 536 720 flex-direction: column; 721 + overflow-y: visible; 537 722 } 538 723 .chat-col { 539 724 width: 100%; 725 + padding-left: 0; 726 + border-left: none; 727 + border-top: 1px solid var(--surface2); 728 + padding-top: 1rem; 729 + } 730 + /* Touch devices: always show overlays (no hover dependency) */ 731 + @media (hover: none) { 732 + #track-overlay { 733 + opacity: 1; 734 + pointer-events: auto; 735 + background: linear-gradient(transparent, rgba(0,0,0,0.85)); 736 + padding-bottom: 1rem; 737 + } 738 + .volume-overlay { 739 + opacity: 1; 740 + pointer-events: auto; 741 + } 742 + } 743 + 744 + @media (max-width: 768px) { 745 + html, body { 746 + height: auto; 747 + } 748 + body { 749 + min-height: 100vh; 750 + } 751 + .layout { 752 + flex-direction: column; 753 + overflow-y: visible; 754 + flex: none; 755 + } 756 + .main-col { 757 + overflow-y: visible; 758 + flex: none; 759 + } 760 + .chat-col { 761 + width: 100%; 762 + padding-left: 0; 763 + border-left: none; 764 + border-top: 1px solid var(--surface2); 765 + padding-top: 1rem; 766 + flex: none; 540 767 } 541 768 #video-player { 542 - max-height: 50vh !important; 769 + max-height: 40vh; 770 + } 771 + #video-container.idle { 772 + min-height: 120px; 773 + } 774 + .top-bar { 775 + flex-wrap: wrap; 776 + gap: 0.5rem; 777 + } 778 + .top-bar-center { 779 + order: 3; 780 + width: 100%; 781 + text-align: left; 543 782 } 544 783 } 545 784 </style> 546 785 </head> 547 786 <body> 548 787 <div class="top-bar"> 549 - <a href="/" class="back-link">&larr; Home</a> 550 - <span class="room-id">{{ room_id }}</span> 788 + <a href="/" class="top-bar-home">moqbox</a> 789 + <div class="top-bar-center"> 790 + <span class="room-name" id="room-name">{{ room_name }}</span> 791 + <span class="room-id">{{ room_id }}</span> 792 + </div> 793 + <div class="top-bar-actions"> 794 + <button 795 + class="btn-icon" 796 + id="copy-link" 797 + title="Copy room link" 798 + onclick="copyRoomLink()" 799 + > 800 + <span class="icon">&#x1F517;</span> 801 + </button> 802 + <span class="viewer-pill" id="viewer-pill">0 viewers</span> 803 + </div> 551 804 </div> 552 805 553 806 <div class="layout"> 554 807 <div class="main-col"> 555 - <div id="video-container"> 808 + <div id="video-container" class="idle"> 556 809 <div class="volume-overlay" id="vol-overlay"> 557 810 <span class="icon" id="vol-icon">&#x1F507;</span> 558 - <input type="range" id="vol-slider" min="0" max="1" step="0.05" value="0" /> 811 + <input 812 + type="range" 813 + id="vol-slider" 814 + min="0" 815 + max="1" 816 + step="0.05" 817 + value="0" 818 + /> 819 + </div> 820 + <div id="track-overlay"> 821 + <div class="to-row"> 822 + <img id="to-thumb" class="to-thumb" alt="" /> 823 + <div class="to-info"> 824 + <span id="to-title" class="to-title"></span> 825 + <span class="to-meta"> 826 + <span id="to-elapsed" class="to-elapsed">0:00</span> 827 + <span id="to-duration" class="to-duration"> / 0:00</span> 828 + <span id="to-viewers" class="to-viewers"></span> 829 + <a id="to-source" class="to-source"></a> 830 + </span> 831 + </div> 832 + </div> 833 + <div id="to-progress-wrap"><div id="to-progress-fill"></div></div> 834 + </div> 835 + <div class="idle-msg"> 836 + <span class="icon">&#x25D4;</span> 837 + Waiting for tracks &mdash; paste a link above to start. 559 838 </div> 560 839 </div> 561 - <div id="now-playing"></div> 562 840 563 - <div class="input-row"> 564 - <input 565 - type="text" 566 - id="url-input" 567 - placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 568 - /> 569 - <button class="btn-primary" onclick="ingest()">Add</button> 570 - <button 571 - class="btn-secondary" 572 - onclick="skip()" 573 - title="Skip current track" 574 - > 575 - Skip 576 - </button> 577 - </div> 841 + <div class="queue-group"> 842 + <div id="queue-section" style="min-height: 3rem"> 843 + {% if queue_empty %} 844 + <div class="empty-state"> 845 + <div class="icon">&#x1F3B5;</div> 846 + <div class="label">Queue is empty</div> 847 + <div class="hint"> 848 + Paste a link from YouTube, SoundCloud, or Bandcamp above to get 849 + started. 850 + </div> 851 + </div> 852 + {% else %} 853 + <ul class="queue"> 854 + {% for item in queue %} 855 + <li> 856 + {% if let Some(thumb) = item.thumbnail %} 857 + <img src="{{ thumb }}" class="thumb-img" alt="" /> 858 + {% else %} 859 + <div class="thumb"></div> 860 + {% endif %} 861 + <div class="title">{{ item.title }}</div> 862 + <div class="duration">{{ item.duration }}</div> 863 + <a 864 + href="{{ item.url }}" 865 + class="source-link" 866 + target="_blank" 867 + rel="noopener noreferrer" 868 + >source</a 869 + > 870 + </li> 871 + {% endfor %} 872 + </ul> 873 + {% endif %} 874 + </div> 578 875 579 - <div id="queue-section" style="min-height: 3rem"> 580 - {% if queue_empty %} 581 - <div class="empty-state"> 582 - <div class="icon">&#x1F3B5;</div> 583 - <div class="label">Queue is empty</div> 584 - <div class="hint">Paste a link from YouTube, SoundCloud, or Bandcamp above to get started.</div> 876 + <div class="input-row"> 877 + <input 878 + type="text" 879 + id="url-input" 880 + placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 881 + /> 882 + <button class="btn-primary" onclick="ingest()">Add</button> 883 + <button 884 + class="btn-secondary" 885 + onclick="skip()" 886 + title="Skip current track" 887 + > 888 + Skip 889 + </button> 585 890 </div> 586 - {% else %} 587 - <ul class="queue"> 588 - {% for item in queue %} 589 - <li> 590 - {% if let Some(thumb) = item.thumbnail %} 591 - <img src="{{ thumb }}" class="thumb-img" alt="" /> 592 - {% else %} 593 - <div class="thumb"></div> 594 - {% endif %} 595 - <div class="title">{{ item.title }}</div> 596 - <div class="duration">{{ item.duration }}</div> 597 - <a 598 - href="{{ item.url }}" 599 - class="source-link" 600 - target="_blank" 601 - rel="noopener noreferrer" 602 - >source</a 603 - > 604 - </li> 605 - {% endfor %} 606 - </ul> 607 - {% endif %} 608 891 </div> 609 892 610 893 <div id="history-section"> ··· 635 918 </div> 636 919 637 920 <div class="chat-col"> 638 - <div id="conn-status" class="conn-status disconnected">disconnected</div> 921 + <div id="conn-status" class="conn-status disconnected"> 922 + disconnected 923 + </div> 639 924 <div id="chat-messages" class="chat-messages"></div> 640 925 <div class="chat-input-row"> 641 926 <input ··· 716 1001 } 717 1002 718 1003 // ── Debug logging (opt-in via ?debug or localStorage) ──────────── 719 - const DEBUG = new URLSearchParams(location.search).has("debug") || 1004 + const DEBUG = 1005 + new URLSearchParams(location.search).has("debug") || 720 1006 localStorage.getItem("moqbox_debug"); 721 - function debug(...args) { if (DEBUG) console.log(...args); } 1007 + function debug(...args) { 1008 + if (DEBUG) console.log(...args); 1009 + } 722 1010 723 1011 // ── Chat WebSocket client ─────────────────────────────────────── 724 1012 const roomId = "{{ room_id }}"; ··· 762 1050 const v = parseFloat(volSlider.value); 763 1051 video.volume = v; 764 1052 video.muted = v === 0; 765 - volIcon.textContent = v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 1053 + volIcon.textContent = 1054 + v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 766 1055 }); 767 1056 volIcon.addEventListener("click", () => { 768 1057 if (video.muted) { ··· 862 1151 function updateConnStatus(connected) { 863 1152 const el = document.getElementById("conn-status"); 864 1153 if (!el) return; 865 - el.className = "conn-status " + (connected ? "connected" : "disconnected"); 1154 + el.className = 1155 + "conn-status " + (connected ? "connected" : "disconnected"); 866 1156 el.textContent = connected ? "connected" : "disconnected"; 867 1157 } 868 1158 ··· 978 1268 979 1269 connect(); 980 1270 1271 + function copyRoomLink() { 1272 + const url = window.location.href; 1273 + navigator.clipboard 1274 + .writeText(url) 1275 + .then(() => { 1276 + const btn = document.getElementById("copy-link"); 1277 + if (!btn) return; 1278 + const original = btn.innerHTML; 1279 + btn.innerHTML = '<span class="icon">&#x2713;</span>'; 1280 + setTimeout(() => { 1281 + btn.innerHTML = original; 1282 + }, 1500); 1283 + }) 1284 + .catch(() => { 1285 + const input = document.createElement("input"); 1286 + input.value = url; 1287 + document.body.appendChild(input); 1288 + input.select(); 1289 + document.execCommand("copy"); 1290 + document.body.removeChild(input); 1291 + }); 1292 + } 1293 + 981 1294 function appendChat(chat) { 982 1295 const container = document.getElementById("chat-messages"); 983 1296 const div = document.createElement("div"); ··· 1016 1329 1017 1330 let elapsedInterval = null; 1018 1331 1332 + function parseDuration(s) { 1333 + // Converts "M:SS" or "H:MM:SS" to seconds 1334 + const parts = s.split(":").map(Number); 1335 + if (parts.length === 2) return parts[0] * 60 + parts[1]; 1336 + if (parts.length === 3) 1337 + return parts[0] * 3600 + parts[1] * 60 + parts[2]; 1338 + return 0; 1339 + } 1340 + 1019 1341 function updateNowPlaying(state) { 1020 - const el = document.getElementById("now-playing"); 1021 - if (!el) return; 1342 + const container = document.getElementById("video-container"); 1343 + if (!container) return; 1344 + 1345 + // Update room name in top bar from state 1346 + if (state.room_name) { 1347 + const nameEl = document.getElementById("room-name"); 1348 + if (nameEl) nameEl.textContent = state.room_name; 1349 + } 1350 + 1351 + // Update viewer count pill 1352 + const pill = document.getElementById("viewer-pill"); 1353 + if (pill) { 1354 + const c = state.clients || 0; 1355 + pill.textContent = c === 1 ? "1 viewer" : c + " viewers"; 1356 + } 1022 1357 1023 1358 // Clear old interval when track changes. 1024 1359 if (elapsedInterval) { ··· 1041 1376 1042 1377 const startedAt = state.current_track.started_at; 1043 1378 const sourceUrl = state.current_track.url || ""; 1044 - const sourceHtml = sourceUrl 1045 - ? '<a href="' + 1046 - safeUrl(sourceUrl) + 1047 - '" target="_blank" class="source-link" style="font-size:0.85em">source</a>' 1379 + 1380 + // Show track overlay, hide idle message 1381 + container.classList.remove("idle"); 1382 + 1383 + const toThumb = document.getElementById("to-thumb"); 1384 + const toTitle = document.getElementById("to-title"); 1385 + const toElapsed = document.getElementById("to-elapsed"); 1386 + const toDuration = document.getElementById("to-duration"); 1387 + const toViewers = document.getElementById("to-viewers"); 1388 + const toSource = document.getElementById("to-source"); 1389 + const toFill = document.getElementById("to-progress-fill"); 1390 + const toOverlay = document.getElementById("track-overlay"); 1391 + 1392 + // Thumbnail 1393 + if (state.current_track.thumbnail) { 1394 + toThumb.src = state.current_track.thumbnail; 1395 + toThumb.style.display = "block"; 1396 + } else { 1397 + toThumb.style.display = "none"; 1398 + } 1399 + 1400 + // Title 1401 + toTitle.textContent = state.current_track.title; 1402 + 1403 + // Duration 1404 + toDuration.textContent = " / " + state.current_track.duration; 1405 + 1406 + // Source link 1407 + if (sourceUrl) { 1408 + toSource.href = sourceUrl; 1409 + toSource.textContent = "source"; 1410 + toSource.style.display = "inline"; 1411 + } else { 1412 + toSource.style.display = "none"; 1413 + } 1414 + 1415 + // Viewers 1416 + toViewers.textContent = state.clients 1417 + ? " · " + 1418 + state.clients + 1419 + " viewer" + 1420 + (state.clients !== 1 ? "s" : "") 1048 1421 : ""; 1049 1422 1050 - el.innerHTML = 1051 - "<strong>Playing:</strong> " + 1052 - esc(state.current_track.title) + 1053 - ' <span id="elapsed">' + 1054 - formatTime((Date.now() - startedAt) / 1000) + 1055 - "</span>" + 1056 - " / " + 1057 - esc(state.current_track.duration) + 1058 - " " + 1059 - sourceHtml + 1060 - ' <span style="color:#6c7086">(' + 1061 - state.clients + 1062 - " viewers)</span>"; 1423 + // Elapsed (initial) 1424 + toElapsed.textContent = formatTime((Date.now() - startedAt) / 1000); 1425 + 1426 + // Ensure overlay is visible on first play 1427 + toOverlay.classList.add("show"); 1428 + setTimeout(() => toOverlay.classList.remove("show"), 3000); 1063 1429 1064 1430 // Update elapsed every second from local clock. 1065 1431 elapsedInterval = setInterval(() => { 1066 - const elSpan = document.getElementById("elapsed"); 1067 - if (elSpan) { 1068 - const elapsed = (Date.now() - startedAt) / 1000; 1069 - elSpan.textContent = formatTime(elapsed); 1432 + const elapsed = (Date.now() - startedAt) / 1000; 1433 + const elSpan = document.getElementById("to-elapsed"); 1434 + if (elSpan) elSpan.textContent = formatTime(elapsed); 1435 + 1436 + const fill = document.getElementById("to-progress-fill"); 1437 + if (fill && state.current_track) { 1438 + const total = parseDuration(state.current_track.duration); 1439 + if (total > 0) { 1440 + fill.style.width = Math.min(100, (elapsed / total) * 100) + "%"; 1441 + } 1070 1442 } 1071 1443 }, 1000); 1072 1444 ··· 1084 1456 } 1085 1457 } else { 1086 1458 currentTrackId = null; 1087 - el.innerHTML = 1088 - '<div class="empty-state" style="border-style:none;background:transparent;padding:2rem 1rem"><div class="icon">&#x25D4;</div><div class="label">Waiting for tracks</div><div class="hint">Paste a link above or wait for the next track to start.</div></div>'; 1459 + container.classList.add("idle"); 1089 1460 } 1090 1461 1091 1462 // Update queue section from state.