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.

frontend: styling pass

karitham (May 20, 2026, 2:17 AM +0200) bace04f1 19ba0dfc

+1239 -1198
+1
src/room.rs
··· 231 231 } 232 232 233 233 /// Get the upcoming queue for a room from the store. 234 + #[allow(dead_code)] 234 235 pub(crate) async fn queue(&self, id: &RoomId) -> Vec<TrackMeta> { 235 236 let snapshot = match self.store.load(&id.as_str_buf()).await { 236 237 Ok(Some(s)) => s,
+8 -19
src/web.rs
··· 17 17 use rust_embed::RustEmbed; 18 18 19 19 use crate::room; 20 - use crate::types::{PlaylistEntryId, RoomId, TrackMeta}; 20 + use crate::types::{PlaylistEntryId, RoomId}; 21 21 22 22 #[derive(RustEmbed)] 23 23 #[folder = "static/"] ··· 101 101 #[derive(Template)] 102 102 #[template(path = "room.html")] 103 103 struct RoomTemplate { 104 - room_id: String, 105 104 #[allow(dead_code)] 106 105 room_name: String, 107 - queue: Vec<TrackMeta>, 108 - history: Vec<TrackMeta>, 109 - queue_empty: bool, 110 106 } 111 107 112 108 async fn index() -> Html<String> { ··· 124 120 Path(id): Path<String>, 125 121 ) -> Result<Html<String>, StatusCode> { 126 122 let room_id = RoomId::parse(&id).ok_or(StatusCode::BAD_REQUEST)?; 127 - let queue = state.rooms.queue(&room_id).await; 128 - let queue_empty = queue.is_empty(); 123 + let room_name = state 124 + .rooms 125 + .load_room_data(&room_id) 126 + .await 127 + .map(|(name, _)| name) 128 + .unwrap_or_default(); 129 129 130 - let (room_name, history) = match state.rooms.load_room_data(&room_id).await { 131 - Some((name, hist)) => (name, hist), 132 - None => (String::new(), Vec::new()), 133 - }; 134 - 135 - let tpl = RoomTemplate { 136 - room_id: id, 137 - room_name, 138 - queue, 139 - history, 140 - queue_empty, 141 - }; 130 + let tpl = RoomTemplate { room_name }; 142 131 Ok(Html( 143 132 tpl.render() 144 133 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
+78 -169
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 + <link rel="stylesheet" href="/static/moqbox/room.css"> 13 8 <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 - } 9 + body { 10 + display: flex; 11 + flex-direction: column; 12 + } 13 + 14 + .landing-wrap { 15 + flex: 1; 16 + display: flex; 17 + flex-direction: column; 18 + align-items: center; 19 + justify-content: center; 20 + padding: 1.5rem; 21 + } 22 + 23 + .landing { 24 + max-width: 420px; 25 + width: 100%; 26 + text-align: center; 27 + } 28 + 29 + .landing h1 { 30 + font-size: 2.5rem; 31 + font-weight: 700; 32 + letter-spacing: -0.02em; 33 + text-wrap: balance; 34 + color: var(--text); 35 + } 36 + 37 + .landing p { 38 + color: var(--subtext0); 39 + margin-top: 0.75rem; 40 + line-height: 1.6; 41 + font-size: 0.9375rem; 42 + } 43 + 44 + .actions { 45 + margin-top: 2rem; 46 + display: flex; 47 + flex-direction: column; 48 + gap: 0.75rem; 49 + } 50 + 51 + .footer { 52 + margin-top: 3rem; 53 + } 54 + 55 + .footer a { 56 + color: var(--overlay0); 57 + font-size: 0.8125rem; 58 + text-decoration: none; 59 + transition: color 150ms ease-out; 60 + } 61 + 62 + .footer a:hover { 63 + color: var(--text); 64 + } 156 65 </style> 157 66 </head> 158 67 <body> 159 - <main class="card"> 160 - <h1>moqbox</h1> 161 - <p> 162 - Ultra-low latency shared media rooms over MoQ.<br> 163 - Paste a link from YouTube, SoundCloud, or Bandcamp to start. 164 - </p> 68 + <header class="topbar"> 69 + <a href="/" class="topbar-home">moqbox</a> 70 + <div class="topbar-center"></div> 71 + <div class="topbar-actions"></div> 72 + </header> 165 73 166 - <div class="actions"> 167 - <button type="button" onclick="createRoom()">Create Room</button> 168 - <span class="divider">or</span> 169 - <input 170 - type="text" 171 - id="room-code" 172 - placeholder="Enter room code..." 173 - maxlength="64" 174 - onkeydown="if(event.key==='Enter'){window.location='/room/'+this.value}" 175 - > 176 - </div> 74 + <div class="landing-wrap"> 75 + <main class="landing"> 76 + <h1>moqbox</h1> 77 + <p> 78 + Ultra-low latency shared media rooms over MoQ.<br> 79 + Paste a link from YouTube, SoundCloud, or Bandcamp to start. 80 + </p> 177 81 178 - <div class="footer"> 179 - <a href="https://tangled.org/karitham.dev/moqbox">moqbox v{{ version }}</a> 180 - </div> 181 - </main> 82 + <div class="actions"> 83 + <button class="btn-primary" type="button" onclick="createRoom()">Create Room</button> 84 + </div> 85 + 86 + <div class="footer"> 87 + <a href="https://tangled.org/karitham.dev/moqbox">moqbox v{{ version }}</a> 88 + </div> 89 + </main> 90 + </div> 182 91 183 92 <script> 184 93 async function createRoom() {
+78 -113
templates/room.html
··· 4 4 <meta charset="UTF-8"> 5 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> 9 - <link 10 - href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 - rel="stylesheet" 12 - > 13 - <style> 14 - {%- include "partials/room_styles.html" -%} 15 - </style> 7 + 8 + <link rel="stylesheet" href="/static/moqbox/room.css"> 16 9 </head> 17 10 <body> 18 - <div class="top-bar"> 19 - <a href="/" class="top-bar-home">moqbox</a> 20 - <div class="top-bar-center"> 21 - <span class="room-name" id="room-name">{{ room_name }}</span> 22 - <span class="room-id">{{ room_id }}</span> 11 + <header class="topbar"> 12 + <a href="/" class="topbar-home">moqbox</a> 13 + <div class="topbar-center"> 14 + <span class="topbar-name" id="room-name">{{ room_name }}</span> 23 15 </div> 24 - <div class="top-bar-actions"> 25 - <button class="btn-icon" id="copy-link" title="Copy room link"> 26 - <span class="icon">&#x1F517;</span> 16 + <div class="topbar-actions"> 17 + <button class="btn-icon" id="copy-link" title="Copy room link" type="button"> 18 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3"/><line x1="8" y1="12" x2="16" y2="12"/></svg> 27 19 </button> 28 - <span class="viewer-pill" id="viewer-pill">0 viewers</span> 29 20 </div> 30 - </div> 21 + </header> 31 22 32 23 <div class="layout"> 33 24 <div class="main-col"> 34 - <div id="video-container" class="idle"> 35 - <div class="volume-overlay" id="vol-overlay"> 36 - <span class="icon" id="vol-icon">&#x1F507;</span> 37 - <input type="range" id="vol-slider" min="0" max="1" step="0.05" value="0"> 25 + <div class="player-block"> 26 + <div id="video-container" class="idle"> 27 + <div class="idle-msg"> 28 + Waiting for tracks &mdash; paste a link above to start. 29 + </div> 38 30 </div> 39 - <div id="track-overlay"> 40 - <div class="to-row"> 41 - <img id="to-thumb" class="to-thumb" alt=""> 42 - <div class="to-info"> 43 - <span id="to-title" class="to-title"></span> 44 - <span class="to-meta"> 45 - <span id="to-elapsed" class="to-elapsed">0:00</span> 46 - <span id="to-duration" class="to-duration"> / 0:00</span> 47 - <span id="to-viewers" class="to-viewers"></span> 48 - <a id="to-source" class="to-source"></a> 49 - <button id="to-rot" class="rot-add" title="Add to Rotation">+</button> 31 + 32 + <div class="player-controls" id="track-overlay"> 33 + <div class="pc-main"> 34 + <img id="to-thumb" class="pc-thumb" alt=""> 35 + <div class="pc-info"> 36 + <span id="to-title" class="pc-title"></span> 37 + <span class="pc-meta"> 38 + <span id="to-elapsed" class="pc-elapsed">0:00</span> 39 + <span id="to-duration" class="pc-duration"> / 0:00</span> 40 + <span id="to-viewers" class="pc-viewers"></span> 41 + <a id="to-source" class="pc-source"></a> 50 42 </span> 43 + </div> 44 + </div> 45 + <div class="pc-actions"> 46 + <button id="to-rot" class="btn-rot" title="Add to Rotation" type="button"> 47 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg> 48 + </button> 49 + <div class="pc-volume" id="vol-overlay"> 50 + <button class="vol-btn" id="vol-icon" type="button" title="Toggle mute"> 51 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg> 52 + </button> 53 + <input type="range" id="vol-slider" min="0" max="1" step="0.05" value="0" class="vol-slider"> 51 54 </div> 52 55 </div> 53 56 <div id="to-progress-wrap"><div id="to-progress-fill"></div></div> 54 57 </div> 55 - <div class="idle-msg"> 56 - <span class="icon">&#x25D4;</span> 57 - Waiting for tracks &mdash; paste a link above to start. 58 - </div> 59 58 </div> 60 59 61 - <div class="queue-group"> 62 - <div id="queue-section" style="min-height: 3rem"> 63 - {% if queue_empty %} 64 - <div class="empty-state"> 65 - <div class="icon">&#x1F3B5;</div> 66 - <div class="label">Queue is empty</div> 67 - <div class="hint"> 68 - Paste a link from YouTube, SoundCloud, or Bandcamp above to get started. 69 - </div> 70 - </div> 71 - {% else %} 72 - <ul class="queue"> 73 - {% for item in queue %} 74 - <li> 75 - {% if let Some(thumb) = item.thumbnail %} 76 - <img src="{{ thumb }}" class="thumb-img" alt=""> 77 - {% else %} 78 - <div class="thumb"></div> 79 - {% endif %} 80 - <div class="title">{{ item.title }}</div> 81 - <div class="duration">{{ item.duration }}</div> 82 - <a 83 - href="{{ item.url }}" 84 - class="source-link" 85 - target="_blank" 86 - rel="noopener noreferrer" 87 - >source</a 88 - > 89 - </li> 90 - {% endfor %} 91 - </ul> 92 - {% endif %} 93 - </div> 60 + <div class="input-row"> 61 + <input 62 + type="text" 63 + id="url-input" 64 + placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 65 + > 66 + <button id="queue-add-btn" class="btn-primary" type="button">Add</button> 67 + <button id="skip-btn" class="btn-secondary" title="Skip current track" type="button">Skip</button> 68 + </div> 94 69 70 + <section class="section section-queue"> 71 + <div id="queue-section"></div> 72 + </section> 73 + 74 + <section class="section section-collapse" data-section="history"> 75 + <button class="collapse-header" type="button" aria-expanded="false"> 76 + <span>Played</span> 77 + <span class="collapse-count" id="history-count">0</span> 78 + <svg class="collapse-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> 79 + </button> 80 + <div class="collapse-body" id="history-section"></div> 81 + </section> 82 + 83 + <section class="section section-collapse" data-section="playlist"> 84 + <button class="collapse-header" type="button" aria-expanded="false"> 85 + <span>Rotation</span> 86 + <span class="collapse-count" id="playlist-count">0</span> 87 + <svg class="collapse-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> 88 + </button> 89 + <div class="collapse-body" id="playlist-items"> 90 + <p class="section-empty">Rotation is empty</p> 91 + </div> 95 92 <div class="input-row"> 96 - <input 97 - type="text" 98 - id="url-input" 99 - placeholder="Paste YouTube / SoundCloud / Bandcamp URL..." 100 - > 101 - <button id="queue-add-btn" class="btn-primary">Add</button> 102 - <button id="skip-btn" class="btn-secondary" title="Skip current track">Skip</button> 103 - </div> 104 - </div> 105 - 106 - <div id="history-section"> 107 - {% if !history.is_empty() %} 108 - <div class="history-header">Played</div> 109 - <ul class="queue"> 110 - {% for item in history %} 111 - <li class="history-item"> 112 - {% if let Some(thumb) = item.thumbnail %} 113 - <img src="{{ thumb }}" class="thumb-img" alt=""> 114 - {% else %} 115 - <div class="thumb"></div> 116 - {% endif %} 117 - <div class="title">{{ item.title }}</div> 118 - <div class="duration">{{ item.duration }}</div> 119 - <a href="{{ item.url }}" class="source-link" target="_blank" rel="noopener noreferrer" 120 - >source</a 121 - > 122 - </li> 123 - {% endfor %} 124 - </ul> 125 - {% endif %} 126 - </div> 127 - 128 - <div class="playlist-section" id="playlist-section"> 129 - <div class="playlist-header">Rotation</div> 130 - <div id="playlist-items"></div> 131 - <div class="input-row" style="margin-top:0.25rem"> 132 93 <input 133 94 type="text" 134 95 id="playlist-input" 135 96 placeholder="Add URL to Rotation..." 136 97 > 137 - <button id="playlist-add-btn" class="btn-primary">Add</button> 98 + <button id="playlist-add-btn" class="btn-primary" type="button">Add</button> 138 99 </div> 139 - </div> 100 + </section> 140 101 </div> 141 102 142 - <div class="chat-col"> 143 - <div id="conn-status" class="conn-status disconnected">disconnected</div> 103 + <aside class="chat-col"> 104 + <div class="chat-top"> 105 + <span class="chat-label">Chat</span> 106 + <span class="viewer-pill" id="viewer-pill">0 viewers</span> 107 + <div id="conn-status" class="conn-status disconnected">disconnected</div> 108 + </div> 144 109 <div id="chat-messages" class="chat-messages"></div> 145 110 <div class="chat-input-row"> 146 111 <input type="text" id="chat-input" placeholder="Type a message..." maxlength="2000"> 147 - <button id="chat-send" class="btn-accent">Send</button> 112 + <button id="chat-send" class="btn-accent" type="button">Send</button> 148 113 </div> 149 - </div> 114 + </aside> 150 115 </div> 151 116 152 117 <script type="module" src="/static/moqbox/main.js"></script>
static/fonts/SpaceGrotesk.woff2

This is a binary file and will not be displayed.

+1
static/icons/chevron-right.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
+1
static/icons/music.svg
··· 1 + <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
+1
static/icons/play.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
+1
static/icons/plus-square.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
+1
static/icons/skip-forward.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 4 15 12 5 20 5 4"/><line x1="19" y1="5" x2="19" y2="19"/></svg>
+1
static/icons/volume-2.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>
+1
static/icons/volume-x.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>
+25 -17
static/moqbox/main.js
··· 36 36 let state = initialState(); 37 37 let pipeline = null; 38 38 let elapsedInterval = null; 39 - let trackOverlayTimeout = null; 40 39 41 40 const containerEl = 42 41 document.getElementById("video-container") || ··· 48 47 49 48 const volSlider = document.getElementById("vol-slider"); 50 49 const volIcon = document.getElementById("vol-icon"); 50 + 51 + function updateVolIcon(v) { 52 + volIcon.innerHTML = 53 + v === 0 54 + ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>' 55 + : '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>'; 56 + } 57 + 51 58 volSlider.addEventListener("input", () => { 52 59 const v = parseFloat(volSlider.value); 53 60 pipeline.videoEl.volume = v; 54 61 pipeline.videoEl.muted = v === 0; 55 - volIcon.textContent = 56 - v === 0 ? "\u{1F507}" : v < 0.5 ? "\u{1F509}" : "\u{1F50A}"; 62 + updateVolIcon(v); 57 63 }); 64 + // Set initial icon to match slider (starts muted) 65 + updateVolIcon(0); 66 + 58 67 volIcon.addEventListener("click", () => { 59 68 if (pipeline.videoEl.muted) { 60 69 pipeline.videoEl.muted = false; 61 70 pipeline.videoEl.volume = 1; 62 71 volSlider.value = "1"; 63 - volIcon.textContent = "\u{1F50A}"; 72 + updateVolIcon(1); 64 73 } else { 65 74 pipeline.videoEl.muted = true; 66 75 volSlider.value = "0"; 67 - volIcon.textContent = "\u{1F507}"; 76 + updateVolIcon(0); 68 77 } 69 78 }); 70 79 } ··· 151 160 try { 152 161 const payload = JSON.parse(new TextDecoder().decode(msg.payload)); 153 162 applyTransition({ type: "state-snapshot", payload }); 154 - // Show overlay briefly on track change 155 - const toOverlay = document.getElementById("track-overlay"); 156 - if (toOverlay && state.videoPhase === "awaiting-init") { 157 - toOverlay.classList.add("show"); 158 - if (trackOverlayTimeout) clearTimeout(trackOverlayTimeout); 159 - trackOverlayTimeout = setTimeout( 160 - () => toOverlay.classList.remove("show"), 161 - 3000, 162 - ); 163 - } 164 163 } catch (_) {} 165 164 } else if (msg.trackId === TRACK_VIDEO) { 166 165 const data = new Uint8Array(msg.payload); ··· 197 196 input.value = ""; 198 197 } 199 198 199 + // Collapse toggle 200 + document.querySelectorAll(".collapse-header").forEach((header) => { 201 + header.addEventListener("click", () => { 202 + const expanded = header.getAttribute("aria-expanded") === "true"; 203 + header.setAttribute("aria-expanded", String(!expanded)); 204 + }); 205 + }); 206 + 200 207 document.addEventListener("click", (e) => { 201 - const btn = e.target.closest(".rot-add, .playlist-remove"); 208 + const btn = e.target.closest(".rot-add, .playlist-remove, .collapse-header"); 202 209 if (!btn) return; 210 + if (btn.classList.contains("collapse-header")) return; // already handled above 203 211 const url = btn.dataset.trackUrl; 204 212 const id = btn.dataset.trackId; 205 213 if (btn.classList.contains("rot-add") && url) { ··· 255 263 256 264 async function addToPlaylistUI() { 257 265 const input = document.getElementById("playlist-input"); 258 - const btn = document.querySelector("#playlist-section .btn-primary"); 266 + const btn = document.getElementById("playlist-add-btn"); 259 267 if (!input.value.trim()) return; 260 268 btn.disabled = true; 261 269 btn.textContent = "Adding…";
+1020
static/moqbox/room.css
··· 1 + @font-face { 2 + font-family: "Space Grotesk"; 3 + font-style: normal; 4 + font-weight: 300 700; 5 + font-display: swap; 6 + src: url("/static/fonts/SpaceGrotesk.woff2") format("woff2"); 7 + } 8 + 9 + :root { 10 + --base: #24273a; 11 + --mantle: #1e2030; 12 + --crust: #181926; 13 + --surface0: #363a4f; 14 + --surface1: #494d64; 15 + --surface2: #5b6078; 16 + --overlay0: #6c7086; 17 + --overlay1: #7f849c; 18 + --overlay2: #939ab7; 19 + --subtext0: #a5adcb; 20 + --subtext1: #b8c0e0; 21 + --text: #cad3f5; 22 + --lavender: #b7bdf8; 23 + --blue: #8aadf4; 24 + --sapphire: #7dc4e4; 25 + --sky: #91d7e3; 26 + --teal: #8bd5ca; 27 + --green: #a6da95; 28 + --yellow: #eed49f; 29 + --peach: #f5a97f; 30 + --maroon: #ee99a0; 31 + --red: #ed8796; 32 + --mauve: #c6a0f6; 33 + --rosewater: #f4dbd6; 34 + --radius-sm: 6px; 35 + --radius-md: 10px; 36 + --radius-lg: 14px; 37 + --font-sans: "Space Grotesk", "Inter", system-ui, -apple-system, sans-serif; 38 + --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 39 + --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 40 + --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 41 + --shadow-inset: inset 0 2px 4px rgba(0, 0, 0, 0.2); 42 + --accent: var(--mauve); 43 + --accent-soft: color-mix(in srgb, var(--mauve) 15%, transparent); 44 + --warm: var(--peach); 45 + --warm-soft: color-mix(in srgb, var(--peach) 12%, transparent); 46 + } 47 + 48 + *, 49 + *::before, 50 + *::after { 51 + box-sizing: border-box; 52 + margin: 0; 53 + padding: 0; 54 + } 55 + 56 + html { 57 + -webkit-font-smoothing: antialiased; 58 + -moz-osx-font-smoothing: grayscale; 59 + } 60 + 61 + html, 62 + body { 63 + height: 100%; 64 + } 65 + 66 + body { 67 + font-family: var(--font-sans); 68 + background: var(--base); 69 + color: var(--text); 70 + padding: 0; 71 + display: flex; 72 + flex-direction: column; 73 + overflow: hidden; 74 + } 75 + 76 + /* ─── Topbar ─── */ 77 + 78 + .topbar { 79 + display: flex; 80 + align-items: center; 81 + gap: 1rem; 82 + padding: 0.5rem 1.25rem; 83 + background: var(--mantle); 84 + border-bottom: 1px solid var(--surface0); 85 + flex-shrink: 0; 86 + z-index: 20; 87 + } 88 + 89 + .topbar-home { 90 + color: var(--overlay0); 91 + text-decoration: none; 92 + font-size: 0.75rem; 93 + font-weight: 500; 94 + letter-spacing: 0.06em; 95 + text-transform: uppercase; 96 + flex-shrink: 0; 97 + transition: color 150ms ease-out; 98 + } 99 + 100 + .topbar-home:hover { 101 + color: var(--subtext0); 102 + } 103 + 104 + .topbar-center { 105 + flex: 1; 106 + min-width: 0; 107 + text-align: center; 108 + } 109 + 110 + .topbar-name { 111 + font-weight: 700; 112 + font-size: 1.125rem; 113 + color: var(--text); 114 + line-height: 1.3; 115 + letter-spacing: -0.01em; 116 + max-width: 100%; 117 + overflow: hidden; 118 + text-overflow: ellipsis; 119 + white-space: nowrap; 120 + } 121 + 122 + .topbar-actions { 123 + display: flex; 124 + align-items: center; 125 + gap: 0.5rem; 126 + flex-shrink: 0; 127 + } 128 + 129 + /* ─── Action Button Base ─── */ 130 + .btn-icon, 131 + .btn-rot, 132 + .vol-btn, 133 + .rot-add, 134 + .playlist-remove { 135 + display: inline-flex; 136 + align-items: center; 137 + justify-content: center; 138 + border: none; 139 + border-radius: var(--radius-sm); 140 + cursor: pointer; 141 + flex-shrink: 0; 142 + transition: background 150ms ease-out, color 150ms ease-out; 143 + } 144 + 145 + /* ─── Topbar Icon (copy link) ─── */ 146 + .btn-icon { 147 + width: 34px; 148 + height: 34px; 149 + background: var(--surface0); 150 + color: var(--subtext0); 151 + } 152 + .btn-icon:hover { background: var(--surface1); color: var(--text); } 153 + .btn-icon:active { transform: scale(0.92); } 154 + .btn-icon svg { 155 + width: 18px; 156 + height: 18px; 157 + flex-shrink: 0; 158 + } 159 + .btn-icon svg * { stroke: var(--subtext0); } 160 + .btn-icon:hover svg * { stroke: var(--text); } 161 + 162 + /* ─── Player Controls Bar ─── */ 163 + .btn-rot, 164 + .vol-btn { 165 + width: 32px; 166 + height: 32px; 167 + background: none; 168 + color: var(--overlay1); 169 + } 170 + .btn-rot:hover, 171 + .vol-btn:hover { color: var(--text); } 172 + .btn-rot svg, 173 + .vol-btn svg { 174 + width: 18px; 175 + height: 18px; 176 + flex-shrink: 0; 177 + } 178 + .btn-rot svg *, 179 + .vol-btn svg * { stroke: var(--overlay1); } 180 + .btn-rot:hover svg *, 181 + .vol-btn:hover svg * { stroke: var(--text); } 182 + 183 + /* ─── Inline Item Buttons (queue/history/playlist rows) ─── */ 184 + .rot-add, 185 + .playlist-remove { 186 + width: 28px; 187 + height: 28px; 188 + background: var(--surface1); 189 + font-size: 0.875rem; 190 + line-height: 1; 191 + } 192 + .rot-add:hover, 193 + .playlist-remove:hover { background: var(--accent); color: var(--crust); } 194 + .rot-add { color: var(--subtext0); } 195 + .playlist-remove { color: var(--overlay0); } 196 + 197 + .viewer-pill { 198 + display: inline-flex; 199 + align-items: center; 200 + gap: 0.25rem; 201 + padding: 0.25rem 0.625rem; 202 + border-radius: 999px; 203 + background: var(--surface0); 204 + color: var(--overlay1); 205 + font-size: 0.75rem; 206 + font-weight: 500; 207 + white-space: nowrap; 208 + font-variant-numeric: tabular-nums; 209 + } 210 + 211 + /* ─── Layout ─── */ 212 + 213 + .layout { 214 + display: flex; 215 + flex: 1; 216 + min-height: 0; 217 + gap: 0; 218 + } 219 + 220 + .main-col { 221 + flex: 1; 222 + min-width: 0; 223 + display: flex; 224 + flex-direction: column; 225 + overflow-y: auto; 226 + padding: 1rem 1.25rem 1.5rem; 227 + } 228 + 229 + .chat-col { 230 + width: 340px; 231 + flex-shrink: 0; 232 + display: flex; 233 + flex-direction: column; 234 + min-height: 0; 235 + background: var(--mantle); 236 + border-left: 1px solid var(--surface0); 237 + } 238 + 239 + /* ─── Player Block ─── */ 240 + 241 + .player-block { 242 + background: var(--crust); 243 + border-radius: var(--radius-lg); 244 + overflow: hidden; 245 + box-shadow: var(--shadow-card); 246 + margin-bottom: 1.25rem; 247 + } 248 + 249 + #video-container { 250 + position: relative; 251 + width: 100%; 252 + min-height: 200px; 253 + background: var(--crust); 254 + } 255 + 256 + /* Hide controls when no media is playing */ 257 + #video-container.idle ~ .player-controls { 258 + display: none; 259 + } 260 + 261 + #video-player { 262 + width: 100%; 263 + aspect-ratio: 16 / 9; 264 + max-height: 55vh; 265 + object-fit: contain; 266 + display: block; 267 + background: var(--crust); 268 + } 269 + 270 + /* Idle state */ 271 + #video-container.idle { 272 + display: flex; 273 + align-items: center; 274 + justify-content: center; 275 + min-height: 160px; 276 + } 277 + 278 + #video-container.idle #video-player { 279 + display: none; 280 + } 281 + 282 + #video-container.idle .idle-msg { 283 + display: block; 284 + } 285 + 286 + .idle-msg { 287 + display: none; 288 + color: var(--overlay0); 289 + font-size: 0.9375rem; 290 + text-align: center; 291 + padding: 3rem 1.5rem; 292 + line-height: 1.5; 293 + } 294 + 295 + /* ─── Track Bar ─── */ 296 + 297 + .player-controls { 298 + background: rgba(0, 0, 0, 0.15); 299 + padding: 0.375rem 0.75rem; 300 + display: flex; 301 + flex-wrap: wrap; 302 + align-items: center; 303 + gap: 0.5rem; 304 + } 305 + 306 + .pc-main { 307 + display: flex; 308 + align-items: center; 309 + gap: 0.75rem; 310 + flex: 1; 311 + min-width: 0; 312 + } 313 + 314 + .pc-thumb { 315 + width: 36px; 316 + height: 36px; 317 + border-radius: var(--radius-sm); 318 + object-fit: cover; 319 + flex-shrink: 0; 320 + outline: 1px solid rgba(0, 0, 0, 0.3); 321 + outline-offset: -1px; 322 + background: var(--mantle); 323 + } 324 + 325 + .pc-info { 326 + flex: 1; 327 + min-width: 0; 328 + } 329 + 330 + .pc-title { 331 + display: block; 332 + font-weight: 700; 333 + font-size: 0.9375rem; 334 + line-height: 1.3; 335 + letter-spacing: -0.01em; 336 + overflow: hidden; 337 + text-overflow: ellipsis; 338 + white-space: nowrap; 339 + } 340 + 341 + .pc-meta { 342 + display: block; 343 + color: var(--subtext0); 344 + font-size: 0.75rem; 345 + line-height: 1.4; 346 + margin-top: 1px; 347 + } 348 + 349 + .pc-meta a { 350 + color: var(--blue); 351 + text-decoration: none; 352 + } 353 + 354 + .pc-meta a:hover { 355 + text-decoration: underline; 356 + } 357 + 358 + .pc-elapsed, 359 + .pc-duration, 360 + .pc-viewers { 361 + font-variant-numeric: tabular-nums; 362 + } 363 + 364 + .pc-actions { 365 + display: flex; 366 + align-items: center; 367 + gap: 0.5rem; 368 + flex-shrink: 0; 369 + } 370 + 371 + /* ─── Volume ─── */ 372 + 373 + .pc-volume { 374 + display: flex; 375 + align-items: center; 376 + gap: 0.125rem; 377 + } 378 + 379 + .vol-slider { 380 + flex: 1; 381 + max-width: 200px; 382 + height: 4px; 383 + accent-color: var(--accent); 384 + cursor: pointer; 385 + background: transparent; 386 + margin: 0; 387 + padding: 0; 388 + display: block; 389 + } 390 + 391 + /* ─── Progress Bar ─── */ 392 + 393 + #to-progress-wrap { 394 + width: 100%; 395 + height: 2px; 396 + background: var(--surface2); 397 + border-radius: 1px; 398 + overflow: hidden; 399 + margin-top: 0.5rem; 400 + } 401 + 402 + #to-progress-fill { 403 + height: 100%; 404 + width: 0%; 405 + background: var(--accent); 406 + border-radius: 1px; 407 + transition: width 1s linear; 408 + } 409 + 410 + /* ─── Input Row ─── */ 411 + 412 + .input-row { 413 + display: flex; 414 + gap: 0.5rem; 415 + margin-bottom: 0.75rem; 416 + } 417 + 418 + .section-collapse .input-row { 419 + margin-top: 0.75rem; 420 + } 421 + 422 + .input-row input { 423 + flex: 1; 424 + font: inherit; 425 + padding: 0.6875rem 1rem; 426 + border-radius: var(--radius-md); 427 + border: none; 428 + background: var(--mantle); 429 + color: var(--text); 430 + font-size: 0.9375rem; 431 + box-shadow: var(--shadow-inset); 432 + transition: box-shadow 150ms ease-out; 433 + min-width: 0; 434 + } 435 + 436 + .input-row input:focus { 437 + outline: none; 438 + box-shadow: 439 + var(--shadow-inset), 440 + 0 0 0 2px var(--accent); 441 + } 442 + 443 + .input-row input::placeholder { 444 + color: var(--overlay0); 445 + } 446 + 447 + /* ─── Buttons ─── */ 448 + 449 + button { 450 + font: inherit; 451 + padding: 0.625rem 1.125rem; 452 + border-radius: var(--radius-md); 453 + border: none; 454 + cursor: pointer; 455 + font-weight: 600; 456 + font-size: 0.9375rem; 457 + transition: 458 + transform 150ms ease-out, 459 + box-shadow 150ms ease-out, 460 + filter 150ms ease-out; 461 + min-height: 42px; 462 + display: inline-flex; 463 + align-items: center; 464 + justify-content: center; 465 + white-space: nowrap; 466 + } 467 + 468 + button:active { 469 + transform: scale(0.96); 470 + } 471 + 472 + button:disabled { 473 + opacity: 0.5; 474 + cursor: not-allowed; 475 + transform: none; 476 + filter: none; 477 + } 478 + 479 + .btn-primary { 480 + background: var(--accent); 481 + color: var(--crust); 482 + box-shadow: var(--shadow-card); 483 + } 484 + 485 + .btn-primary:hover { 486 + filter: brightness(1.12); 487 + box-shadow: var(--shadow-card-hover); 488 + } 489 + 490 + .btn-secondary { 491 + background: var(--surface1); 492 + color: var(--text); 493 + box-shadow: var(--shadow-card); 494 + } 495 + 496 + .btn-secondary:hover { 497 + background: var(--surface2); 498 + filter: brightness(1.08); 499 + box-shadow: var(--shadow-card-hover); 500 + } 501 + 502 + .btn-accent { 503 + background: var(--accent); 504 + color: var(--crust); 505 + box-shadow: var(--shadow-card); 506 + } 507 + 508 + .btn-accent:hover { 509 + filter: brightness(1.12); 510 + box-shadow: var(--shadow-card-hover); 511 + } 512 + 513 + /* ─── Sections ─── */ 514 + 515 + .section { 516 + margin-bottom: 0.5rem; 517 + } 518 + 519 + .section-queue { 520 + flex: 1; 521 + min-height: 3rem; 522 + } 523 + 524 + /* ─── Collapsible Sections ─── */ 525 + 526 + .section-collapse { 527 + border-top: 1px solid var(--surface0); 528 + padding-top: 0.5rem; 529 + margin-top: 0.5rem; 530 + } 531 + 532 + .section-collapse:last-child { 533 + margin-bottom: 0; 534 + } 535 + 536 + .collapse-header { 537 + display: flex; 538 + align-items: center; 539 + gap: 0.375rem; 540 + width: 100%; 541 + padding: 0.375rem 0.5rem; 542 + background: none; 543 + border: none; 544 + border-radius: var(--radius-sm); 545 + color: var(--overlay1); 546 + font-size: 0.8125rem; 547 + font-weight: 500; 548 + text-transform: uppercase; 549 + letter-spacing: 0.06em; 550 + cursor: pointer; 551 + min-height: 0; 552 + transition: color 150ms ease-out, background 150ms ease-out; 553 + } 554 + 555 + .collapse-header:hover { 556 + color: var(--subtext0); 557 + background: var(--surface0); 558 + } 559 + 560 + .collapse-header:active { 561 + transform: none; 562 + } 563 + 564 + .collapse-count { 565 + color: var(--overlay0); 566 + font-weight: 400; 567 + font-variant-numeric: tabular-nums; 568 + } 569 + 570 + .collapse-chevron { 571 + margin-left: auto; 572 + flex-shrink: 0; 573 + width: 14px; 574 + height: 14px; 575 + transition: transform 200ms ease-out; 576 + color: var(--overlay0); 577 + } 578 + 579 + .collapse-chevron * { 580 + stroke: var(--overlay0); 581 + } 582 + 583 + .collapse-header[aria-expanded="true"] .collapse-chevron { 584 + transform: rotate(90deg); 585 + } 586 + 587 + .collapse-header[aria-expanded="false"] + .collapse-body { 588 + display: none; 589 + } 590 + 591 + .collapse-header[aria-expanded="true"] + .collapse-body { 592 + display: block; 593 + } 594 + 595 + /* ─── Queue List ─── */ 596 + 597 + .queue { 598 + list-style: none; 599 + } 600 + 601 + .queue li { 602 + background: var(--surface0); 603 + border-radius: var(--radius-md); 604 + box-shadow: var(--shadow-card); 605 + padding: 0.625rem 0.75rem; 606 + display: flex; 607 + align-items: center; 608 + gap: 0.75rem; 609 + margin-bottom: 0.375rem; 610 + transition: 611 + background 150ms ease-out, 612 + box-shadow 150ms ease-out; 613 + } 614 + 615 + .queue li:hover { 616 + background: var(--surface1); 617 + box-shadow: var(--shadow-card-hover); 618 + } 619 + 620 + .queue li .thumb, 621 + .queue li .thumb-img { 622 + width: 36px; 623 + height: 36px; 624 + border-radius: var(--radius-sm); 625 + background: var(--mantle); 626 + flex-shrink: 0; 627 + object-fit: cover; 628 + outline: 1px solid var(--surface2); 629 + outline-offset: -1px; 630 + } 631 + 632 + .queue li .title { 633 + flex: 1; 634 + min-width: 0; 635 + overflow: hidden; 636 + text-overflow: ellipsis; 637 + white-space: nowrap; 638 + font-size: 0.9375rem; 639 + font-weight: 500; 640 + } 641 + 642 + .queue li .duration { 643 + color: var(--subtext0); 644 + font-size: 0.8125rem; 645 + flex-shrink: 0; 646 + font-variant-numeric: tabular-nums; 647 + } 648 + 649 + /* ─── Source Link ─── */ 650 + 651 + .source-link { 652 + color: var(--blue); 653 + text-decoration: none; 654 + font-size: 0.75rem; 655 + padding: 4px 8px; 656 + border-radius: var(--radius-sm); 657 + background: color-mix(in srgb, var(--blue) 12%, transparent); 658 + transition: background 150ms ease-out; 659 + flex-shrink: 0; 660 + display: inline-flex; 661 + align-items: center; 662 + } 663 + 664 + .source-link:hover { 665 + background: color-mix(in srgb, var(--blue) 22%, transparent); 666 + } 667 + 668 + /* ─── History Items ─── */ 669 + 670 + .queue li.history-item { 671 + background: var(--surface0); 672 + box-shadow: none; 673 + padding: 0.5rem 0.75rem; 674 + margin-bottom: 0.375rem; 675 + border-radius: var(--radius-md); 676 + } 677 + 678 + .queue li.history-item:hover { 679 + background: var(--surface0); 680 + box-shadow: none; 681 + } 682 + 683 + .queue li.history-item .thumb, 684 + .queue li.history-item .thumb-img { 685 + width: 28px; 686 + height: 28px; 687 + } 688 + 689 + .queue li.history-item .title { 690 + color: var(--overlay1); 691 + font-size: 0.8125rem; 692 + } 693 + 694 + .queue li.history-item .duration { 695 + color: var(--overlay0); 696 + font-size: 0.75rem; 697 + } 698 + 699 + /* ─── Empty State ─── */ 700 + 701 + .empty-state { 702 + display: flex; 703 + flex-direction: column; 704 + align-items: center; 705 + justify-content: center; 706 + gap: 0.25rem; 707 + padding: 2rem 1.5rem; 708 + text-align: center; 709 + color: var(--overlay0); 710 + font-size: 0.8125rem; 711 + line-height: 1.5; 712 + } 713 + 714 + .empty-state .icon { 715 + font-size: 1.25rem; 716 + line-height: 1; 717 + opacity: 0.35; 718 + } 719 + 720 + .empty-state .label { 721 + color: var(--subtext0); 722 + font-weight: 500; 723 + } 724 + 725 + .empty-state .hint { 726 + color: var(--overlay0); 727 + font-size: 0.75rem; 728 + max-width: 24em; 729 + } 730 + 731 + .section-empty { 732 + color: var(--overlay0); 733 + font-size: 0.8125rem; 734 + text-align: center; 735 + padding: 1rem; 736 + font-style: italic; 737 + } 738 + 739 + /* ─── Chat ─── */ 740 + 741 + .chat-top { 742 + display: flex; 743 + align-items: center; 744 + gap: 0.5rem; 745 + padding: 0.75rem 1rem 0.5rem; 746 + flex-shrink: 0; 747 + } 748 + 749 + .chat-label { 750 + font-size: 0.8125rem; 751 + font-weight: 500; 752 + text-transform: uppercase; 753 + letter-spacing: 0.06em; 754 + color: var(--overlay1); 755 + } 756 + 757 + .conn-status { 758 + font-size: 0.6875rem; 759 + color: var(--overlay0); 760 + margin-left: auto; 761 + transition: color 300ms ease-out; 762 + } 763 + 764 + .conn-status.connected { 765 + color: var(--green); 766 + } 767 + 768 + .conn-status.disconnected { 769 + color: var(--red); 770 + } 771 + 772 + .chat-messages { 773 + flex: 1; 774 + overflow-y: auto; 775 + min-height: 0; 776 + padding: 0.375rem 0.75rem; 777 + display: flex; 778 + flex-direction: column; 779 + gap: 3px; 780 + } 781 + 782 + .chat-messages:empty::after { 783 + content: "No messages yet"; 784 + display: flex; 785 + align-items: center; 786 + justify-content: center; 787 + height: 100%; 788 + min-height: 60px; 789 + color: var(--overlay0); 790 + font-size: 0.8125rem; 791 + font-style: italic; 792 + } 793 + 794 + .chat-msg { 795 + padding: 5px 8px; 796 + font-size: 0.875rem; 797 + line-height: 1.5; 798 + overflow-wrap: break-word; 799 + word-break: break-word; 800 + hyphens: auto; 801 + } 802 + 803 + .chat-msg .user { 804 + color: var(--accent); 805 + font-weight: 600; 806 + font-size: 0.8rem; 807 + } 808 + 809 + .chat-msg .time { 810 + color: var(--overlay0); 811 + font-size: 0.675rem; 812 + float: right; 813 + margin-left: 8px; 814 + opacity: 0.7; 815 + } 816 + 817 + .chat-msg .text { 818 + color: var(--text); 819 + display: block; 820 + margin-top: 1px; 821 + } 822 + 823 + .chat-msg.system { 824 + padding: 3px 8px; 825 + } 826 + 827 + .chat-msg.system .user { 828 + color: var(--overlay0); 829 + } 830 + 831 + .chat-msg.system .text { 832 + color: var(--subtext0); 833 + font-style: italic; 834 + font-size: 0.8125rem; 835 + } 836 + 837 + /* ─── Chat Input ─── */ 838 + 839 + .chat-input-row { 840 + display: flex; 841 + gap: 0.5rem; 842 + padding: 0.5rem 0.75rem 0.75rem; 843 + flex-shrink: 0; 844 + } 845 + 846 + .chat-input-row input { 847 + flex: 1; 848 + font: inherit; 849 + padding: 0.5625rem 0.75rem; 850 + border-radius: var(--radius-md); 851 + border: none; 852 + background: var(--surface0); 853 + color: var(--text); 854 + font-size: 0.875rem; 855 + box-shadow: var(--shadow-inset); 856 + transition: box-shadow 150ms ease-out; 857 + min-width: 0; 858 + } 859 + 860 + .chat-input-row input:focus { 861 + outline: none; 862 + box-shadow: 863 + var(--shadow-inset), 864 + 0 0 0 2px var(--accent); 865 + } 866 + 867 + .chat-input-row input::placeholder { 868 + color: var(--overlay0); 869 + } 870 + 871 + .chat-input-row button { 872 + padding: 0.5625rem 0.875rem; 873 + min-height: 38px; 874 + } 875 + 876 + /* ─── Skeleton Loading ─── */ 877 + 878 + @keyframes shimmer { 879 + 0% { background-position: -200% 0; } 880 + 100% { background-position: 200% 0; } 881 + } 882 + 883 + .queue li.pending { 884 + position: relative; 885 + } 886 + 887 + .queue li.pending .title, 888 + .queue li.pending .duration { 889 + display: inline-block; 890 + border-radius: var(--radius-sm); 891 + background: linear-gradient( 892 + 90deg, 893 + var(--surface1) 25%, 894 + var(--surface2) 37%, 895 + var(--surface1) 63% 896 + ); 897 + background-size: 200% 100%; 898 + animation: shimmer 1.2s ease-in-out infinite; 899 + color: transparent !important; 900 + user-select: none; 901 + } 902 + 903 + .queue li.pending .title { 904 + min-width: 14ch; 905 + max-width: 20ch; 906 + } 907 + 908 + .queue li.pending .duration { 909 + min-width: 5ch; 910 + } 911 + 912 + .queue li.pending .source-link { 913 + visibility: hidden; 914 + } 915 + 916 + /* ─── Queue Fade In ─── */ 917 + 918 + @keyframes queueFadeIn { 919 + from { 920 + opacity: 0.2; 921 + } 922 + to { 923 + opacity: 1; 924 + } 925 + } 926 + 927 + .queue li { 928 + animation: queueFadeIn 0.15s ease-out both; 929 + } 930 + 931 + .queue li:nth-child(1) { animation-delay: 0s; } 932 + .queue li:nth-child(2) { animation-delay: 0.02s; } 933 + .queue li:nth-child(3) { animation-delay: 0.04s; } 934 + .queue li:nth-child(4) { animation-delay: 0.06s; } 935 + .queue li:nth-child(5) { animation-delay: 0.08s; } 936 + .queue li:nth-child(n + 6) { animation-delay: 0.1s; } 937 + 938 + /* ─── Touch / Mobile ─── */ 939 + 940 + @media (max-width: 768px) { 941 + html, body { 942 + height: auto; 943 + overflow: visible; 944 + } 945 + 946 + body { 947 + min-height: 100vh; 948 + } 949 + 950 + .layout { 951 + flex-direction: column; 952 + overflow-y: visible; 953 + flex: none; 954 + } 955 + 956 + .main-col { 957 + overflow-y: visible; 958 + flex: none; 959 + padding: 0.75rem 1rem; 960 + } 961 + 962 + .chat-col { 963 + width: 100%; 964 + border-left: none; 965 + border-top: 1px solid var(--surface0); 966 + min-height: 40vh; 967 + } 968 + 969 + #video-player { 970 + max-height: 35vh; 971 + } 972 + 973 + #video-container.idle { 974 + min-height: 120px; 975 + } 976 + 977 + .topbar { 978 + flex-wrap: wrap; 979 + gap: 0.5rem; 980 + padding: 0.5rem 1rem; 981 + } 982 + 983 + .topbar-center { 984 + order: 3; 985 + width: 100%; 986 + align-items: flex-start; 987 + } 988 + 989 + .topbar-name { 990 + font-size: 0.9375rem; 991 + } 992 + 993 + /* Touch: larger chat input/buttons */ 994 + .pc-volume { 995 + gap: 0.5rem; 996 + } 997 + 998 + .vol-slider { 999 + max-width: none; 1000 + } 1001 + } 1002 + 1003 + /* ─── Progress Bar ─── */ 1004 + 1005 + #to-progress-wrap { 1006 + width: 100%; 1007 + height: 2px; 1008 + background: var(--surface2); 1009 + border-radius: 1px; 1010 + overflow: hidden; 1011 + margin-top: 0.5rem; 1012 + } 1013 + 1014 + #to-progress-fill { 1015 + height: 100%; 1016 + width: 0%; 1017 + background: var(--accent); 1018 + border-radius: 1px; 1019 + transition: width 1s linear; 1020 + }
+21 -6
static/moqbox/ui.js
··· 90 90 renderNowPlaying(state.currentTrack, state.viewerCount); 91 91 } else { 92 92 container.classList.add("idle"); 93 + container.style.backgroundImage = ""; 93 94 } 94 95 95 96 renderQueue(state.queue, state.currentTrack !== null); ··· 101 102 const container = document.getElementById("video-container"); 102 103 if (!container) return; 103 104 container.classList.remove("idle"); 105 + 106 + // Show thumbnail as background for audio-only tracks 107 + if (track.thumbnail) { 108 + container.style.backgroundImage = 'url("' + safeUrl(track.thumbnail) + '")'; 109 + container.style.backgroundSize = "cover"; 110 + container.style.backgroundPosition = "center"; 111 + } else { 112 + container.style.backgroundImage = ""; 113 + } 104 114 105 115 const toThumb = document.getElementById("to-thumb"); 106 116 const toTitle = document.getElementById("to-title"); ··· 223 233 html += "</ul>"; 224 234 qSection.innerHTML = html; 225 235 } else if (hasCurrentTrack) { 226 - qSection.innerHTML = 227 - '<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>'; 236 + qSection.innerHTML = ""; 228 237 } else { 229 238 qSection.innerHTML = 230 239 '<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>'; ··· 234 243 function renderHistory(history) { 235 244 const hSection = document.getElementById("history-section"); 236 245 if (!hSection) return; 246 + const count = document.getElementById("history-count"); 247 + const len = history ? history.length : 0; 248 + if (count) count.textContent = len; 237 249 238 250 const linkAction = { 239 251 cls: "source-link", ··· 242 254 }; 243 255 const rotAction = { cls: "rot-add", label: "+", title: "Add to Rotation" }; 244 256 245 - if (history && history.length > 0) { 246 - let html = '<div class="history-header">Played</div><ul class="queue">'; 257 + if (len > 0) { 258 + let html = '<ul class="queue">'; 247 259 for (const item of history) { 248 260 html += renderTrackItem(item, [linkAction, rotAction], "history-item"); 249 261 } ··· 257 269 function renderPlaylist(playlist) { 258 270 const container = document.getElementById("playlist-items"); 259 271 if (!container) return; 272 + const count = document.getElementById("playlist-count"); 273 + const len = playlist ? playlist.length : 0; 274 + if (count) count.textContent = len; 260 275 261 - if (!playlist || playlist.length === 0) { 262 - container.innerHTML = '<div class="playlist-empty">Rotation is empty</div>'; 276 + if (len === 0) { 277 + container.innerHTML = '<p class="section-empty">Rotation is empty</p>'; 263 278 return; 264 279 } 265 280
-874
templates/partials/room_styles.html
··· 1 - :root { 2 - --base: #24273a; 3 - --mantle: #1e2030; 4 - --crust: #181926; 5 - --surface0: #363a4f; 6 - --surface1: #494d64; 7 - --surface2: #5b6078; 8 - --overlay0: #6c7086; 9 - --overlay1: #7f849c; 10 - --overlay2: #939ab7; 11 - --subtext0: #a5adcb; 12 - --subtext1: #b8c0e0; 13 - --text: #cad3f5; 14 - --lavender: #b7bdf8; 15 - --blue: #8aadf4; 16 - --sapphire: #7dc4e4; 17 - --sky: #91d7e3; 18 - --teal: #8bd5ca; 19 - --green: #a6da95; 20 - --yellow: #eed49f; 21 - --peach: #f5a97f; 22 - --maroon: #ee99a0; 23 - --red: #ed8796; 24 - --mauve: #c6a0f6; 25 - --pink: #f5bde6; 26 - --flamingo: #f0c6c6; 27 - --rosewater: #f4dbd6; 28 - --radius-sm: 6px; 29 - --radius-md: 10px; 30 - --radius-lg: 14px; 31 - --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; 32 - --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(198, 160, 246, 0.04); 33 - --shadow-card-hover: 0 4px 16px rgba(0, 0, 0, 0.35), 0 1px 4px rgba(198, 160, 246, 0.06); 34 - --shadow-elevated: 0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); 35 - --shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.3); 36 - } 37 - *, 38 - *::before, 39 - *::after { 40 - box-sizing: border-box; 41 - margin: 0; 42 - padding: 0; 43 - } 44 - html { 45 - -webkit-font-smoothing: antialiased; 46 - -moz-osx-font-smoothing: grayscale; 47 - } 48 - html, 49 - body { 50 - height: 100%; 51 - } 52 - body { 53 - font-family: var(--font-sans); 54 - background: var(--base); 55 - color: var(--text); 56 - padding: 1rem 1.5rem; 57 - display: flex; 58 - flex-direction: column; 59 - } 60 - 61 - /* Top bar */ 62 - .top-bar { 63 - display: flex; 64 - align-items: center; 65 - justify-content: space-between; 66 - margin-bottom: 1rem; 67 - gap: 1rem; 68 - } 69 - .top-bar-home { 70 - color: var(--overlay1); 71 - text-decoration: none; 72 - font-size: 0.875rem; 73 - font-weight: 600; 74 - letter-spacing: 0.02em; 75 - text-transform: uppercase; 76 - flex-shrink: 0; 77 - transition: color 150ms ease-out; 78 - } 79 - .top-bar-home:hover { 80 - color: var(--text); 81 - } 82 - .top-bar-center { 83 - flex: 1; 84 - text-align: center; 85 - min-width: 0; 86 - } 87 - .room-name { 88 - display: block; 89 - font-weight: 700; 90 - font-size: 1.125rem; 91 - color: var(--text); 92 - line-height: 1.3; 93 - text-wrap: balance; 94 - } 95 - .room-id { 96 - display: block; 97 - font-size: 0.75rem; 98 - color: var(--overlay0); 99 - font-variant-numeric: tabular-nums; 100 - letter-spacing: 0.03em; 101 - } 102 - .top-bar-actions { 103 - display: flex; 104 - align-items: center; 105 - gap: 0.5rem; 106 - flex-shrink: 0; 107 - } 108 - .btn-icon { 109 - display: inline-flex; 110 - align-items: center; 111 - justify-content: center; 112 - width: 36px; 113 - height: 36px; 114 - border-radius: var(--radius-sm); 115 - border: none; 116 - background: var(--surface0); 117 - color: var(--subtext0); 118 - cursor: pointer; 119 - font-size: 1rem; 120 - line-height: 1; 121 - transition: 122 - background 150ms ease-out, 123 - color 150ms ease-out; 124 - } 125 - .btn-icon:hover { 126 - background: var(--surface1); 127 - color: var(--text); 128 - } 129 - .btn-icon:active { 130 - transform: scale(0.92); 131 - } 132 - .viewer-pill { 133 - display: inline-flex; 134 - align-items: center; 135 - gap: 0.25rem; 136 - padding: 0.25rem 0.625rem; 137 - border-radius: 999px; 138 - background: var(--surface0); 139 - color: var(--overlay1); 140 - font-size: 0.75rem; 141 - font-weight: 500; 142 - white-space: nowrap; 143 - font-variant-numeric: tabular-nums; 144 - } 145 - 146 - /* Layout */ 147 - .layout { 148 - display: flex; 149 - gap: 1rem; 150 - flex: 1; 151 - min-height: 0; 152 - } 153 - .main-col { 154 - flex: 1; 155 - min-width: 0; 156 - display: flex; 157 - flex-direction: column; 158 - overflow-y: auto; 159 - } 160 - .chat-col { 161 - width: 320px; 162 - flex-shrink: 0; 163 - display: flex; 164 - flex-direction: column; 165 - min-height: 0; 166 - border-left: 1px solid var(--surface2); 167 - padding-left: 1rem; 168 - } 169 - 170 - /* Video */ 171 - #video-container { 172 - position: relative; 173 - width: 100%; 174 - min-height: 200px; 175 - margin-bottom: 1rem; 176 - border-radius: var(--radius-lg); 177 - overflow: hidden; 178 - background: var(--crust); 179 - box-shadow: var(--shadow-card); 180 - } 181 - #video-player { 182 - width: 100%; 183 - aspect-ratio: 16 / 9; 184 - max-height: 60vh; 185 - object-fit: contain; 186 - display: block; 187 - background: var(--crust); 188 - } 189 - /* Video idle state (no track playing) */ 190 - #video-container.idle { 191 - display: flex; 192 - align-items: center; 193 - justify-content: center; 194 - min-height: 200px; 195 - } 196 - #video-container.idle #video-player { 197 - display: none; 198 - } 199 - #video-container.idle .idle-msg { 200 - display: block; 201 - } 202 - .idle-msg { 203 - display: none; 204 - color: var(--overlay0); 205 - font-size: 0.9375rem; 206 - text-align: center; 207 - padding: 3rem 1.5rem; 208 - } 209 - .idle-msg .icon { 210 - font-size: 2rem; 211 - display: block; 212 - margin-bottom: 0.5rem; 213 - opacity: 0.5; 214 - } 215 - 216 - /* Track info overlay on video */ 217 - #track-overlay { 218 - position: absolute; 219 - bottom: 0; 220 - left: 0; 221 - right: 0; 222 - background: linear-gradient(transparent, rgba(0, 0, 0, 0.75)); 223 - padding: 2rem 1rem 0.75rem; 224 - opacity: 0; 225 - transition: opacity 200ms ease-out; 226 - pointer-events: none; 227 - z-index: 5; 228 - } 229 - #video-container:hover #track-overlay, 230 - #track-overlay.show { 231 - opacity: 1; 232 - pointer-events: auto; 233 - } 234 - .to-row { 235 - display: flex; 236 - align-items: center; 237 - gap: 0.75rem; 238 - } 239 - .to-thumb { 240 - width: 36px; 241 - height: 36px; 242 - border-radius: var(--radius-sm); 243 - object-fit: cover; 244 - flex-shrink: 0; 245 - outline: 1px solid rgba(0, 0, 0, 0.3); 246 - outline-offset: -1px; 247 - } 248 - .to-info { 249 - flex: 1; 250 - min-width: 0; 251 - } 252 - .to-title { 253 - display: block; 254 - color: #fff; 255 - font-weight: 600; 256 - font-size: 0.9375rem; 257 - line-height: 1.3; 258 - overflow: hidden; 259 - text-overflow: ellipsis; 260 - white-space: nowrap; 261 - } 262 - .to-meta { 263 - display: block; 264 - color: rgba(255, 255, 255, 0.6); 265 - font-size: 0.75rem; 266 - line-height: 1.4; 267 - } 268 - .to-meta a { 269 - color: inherit; 270 - text-decoration: underline; 271 - text-underline-offset: 2px; 272 - } 273 - .to-viewers { 274 - font-variant-numeric: tabular-nums; 275 - } 276 - #to-progress-wrap { 277 - width: 100%; 278 - height: 2px; 279 - background: rgba(255, 255, 255, 0.15); 280 - border-radius: 1px; 281 - overflow: hidden; 282 - margin-top: 0.5rem; 283 - } 284 - #to-progress-fill { 285 - height: 100%; 286 - width: 0%; 287 - background: var(--mauve); 288 - border-radius: 1px; 289 - transition: width 1s linear; 290 - } 291 - 292 - .volume-overlay { 293 - position: absolute; 294 - bottom: 0.75rem; 295 - right: 0.75rem; 296 - display: flex; 297 - align-items: center; 298 - gap: 0.5rem; 299 - background: rgba(0, 0, 0, 0.6); 300 - border-radius: var(--radius-md); 301 - padding: 0.375rem 0.625rem; 302 - opacity: 0; 303 - transition: opacity 150ms ease-out; 304 - pointer-events: none; 305 - z-index: 10; 306 - } 307 - #video-container:hover .volume-overlay { 308 - opacity: 1; 309 - pointer-events: auto; 310 - } 311 - .volume-overlay .icon { 312 - font-size: 1.125rem; 313 - line-height: 1; 314 - color: var(--text); 315 - cursor: pointer; 316 - user-select: none; 317 - } 318 - .volume-overlay input[type="range"] { 319 - width: 80px; 320 - height: 4px; 321 - accent-color: var(--blue); 322 - cursor: pointer; 323 - background: transparent; 324 - } 325 - 326 - /* Input Row */ 327 - .queue-group { 328 - margin-bottom: 0.5rem; 329 - } 330 - .input-row { 331 - display: flex; 332 - gap: 0.5rem; 333 - margin-top: 0.75rem; 334 - } 335 - .input-row input { 336 - flex: 1; 337 - font: inherit; 338 - padding: 0.75rem 1rem; 339 - border-radius: var(--radius-md); 340 - border: none; 341 - background: var(--mantle); 342 - color: var(--text); 343 - font-size: 0.9375rem; 344 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 345 - transition: box-shadow 150ms ease-out; 346 - } 347 - .input-row input:focus { 348 - outline: none; 349 - box-shadow: 350 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 351 - 0 0 0 2px var(--blue); 352 - } 353 - .input-row input::placeholder { 354 - color: var(--overlay0); 355 - } 356 - 357 - /* Buttons */ 358 - button { 359 - font: inherit; 360 - padding: 0.75rem 1.25rem; 361 - border-radius: var(--radius-md); 362 - border: none; 363 - cursor: pointer; 364 - font-weight: 600; 365 - font-size: 0.9375rem; 366 - transition: 367 - transform 150ms ease-out, 368 - box-shadow 150ms ease-out, 369 - filter 150ms ease-out; 370 - min-height: 44px; 371 - display: inline-flex; 372 - align-items: center; 373 - justify-content: center; 374 - } 375 - button:active { 376 - transform: scale(0.96); 377 - } 378 - button:disabled { 379 - opacity: 0.5; 380 - cursor: not-allowed; 381 - transform: none; 382 - filter: none; 383 - } 384 - button.btn-primary { 385 - background: var(--green); 386 - color: var(--crust); 387 - box-shadow: var(--shadow-card); 388 - } 389 - button.btn-primary:hover { 390 - filter: brightness(1.12); 391 - box-shadow: var(--shadow-card-hover); 392 - } 393 - button.btn-secondary { 394 - background: var(--surface1); 395 - color: var(--text); 396 - box-shadow: var(--shadow-card); 397 - } 398 - button.btn-secondary:hover { 399 - background: var(--surface2); 400 - filter: brightness(1.08); 401 - box-shadow: var(--shadow-card-hover); 402 - } 403 - button.btn-accent { 404 - background: var(--mauve); 405 - color: var(--crust); 406 - box-shadow: var(--shadow-card); 407 - } 408 - button.btn-accent:hover { 409 - filter: brightness(1.12); 410 - box-shadow: var(--shadow-card-hover); 411 - } 412 - 413 - /* Queue */ 414 - .queue { 415 - list-style: none; 416 - } 417 - .queue li { 418 - background: var(--surface0); 419 - border-radius: var(--radius-md); 420 - box-shadow: var(--shadow-card); 421 - padding: 0.75rem; 422 - display: flex; 423 - align-items: center; 424 - gap: 0.75rem; 425 - margin-bottom: 0.5rem; 426 - transition: 427 - background 150ms ease-out, 428 - box-shadow 150ms ease-out; 429 - animation: queueFadeIn 0.12s ease-out both; 430 - } 431 - .queue li:hover { 432 - background: var(--surface1); 433 - box-shadow: var(--shadow-card-hover); 434 - } 435 - .queue li .thumb, 436 - .queue li .thumb-img { 437 - width: 40px; 438 - height: 40px; 439 - border-radius: var(--radius-sm); 440 - background: var(--mantle); 441 - flex-shrink: 0; 442 - object-fit: cover; 443 - outline: 1px solid var(--surface2); 444 - outline-offset: -1px; 445 - } 446 - .queue li .title { 447 - flex: 1; 448 - min-width: 0; 449 - overflow: hidden; 450 - text-overflow: ellipsis; 451 - white-space: nowrap; 452 - text-wrap: pretty; 453 - } 454 - .queue li .duration { 455 - color: var(--subtext0); 456 - font-size: 0.875rem; 457 - flex-shrink: 0; 458 - font-variant-numeric: tabular-nums; 459 - } 460 - 461 - /* Source link */ 462 - .source-link { 463 - color: var(--blue); 464 - text-decoration: none; 465 - font-size: 0.8rem; 466 - padding: 6px 10px; 467 - border-radius: var(--radius-sm); 468 - background: color-mix(in srgb, var(--blue) 15%, transparent); 469 - transition: background 150ms ease-out; 470 - flex-shrink: 0; 471 - min-height: 40px; 472 - display: inline-flex; 473 - align-items: center; 474 - } 475 - .source-link:hover { 476 - background: color-mix(in srgb, var(--blue) 25%, transparent); 477 - } 478 - 479 - .history-item { 480 - opacity: 1; 481 - border-radius: var(--radius-sm); 482 - padding: 0.5rem 0.75rem; 483 - margin-bottom: 0.25rem; 484 - background: transparent; 485 - box-shadow: none; 486 - } 487 - .history-item:hover { 488 - background: transparent; 489 - box-shadow: none; 490 - } 491 - .history-item .thumb, 492 - .history-item .thumb-img { 493 - width: 28px; 494 - height: 28px; 495 - } 496 - .history-item .title { 497 - color: var(--overlay1); 498 - font-size: 0.8125rem; 499 - } 500 - .history-item .duration { 501 - color: var(--overlay0); 502 - font-size: 0.75rem; 503 - } 504 - 505 - /* Connection status */ 506 - .conn-status { 507 - font-size: 0.75rem; 508 - color: var(--overlay0); 509 - margin-bottom: 0.5rem; 510 - text-align: right; 511 - transition: color 300ms ease-out; 512 - } 513 - .conn-status.connected { 514 - color: var(--green); 515 - } 516 - .conn-status.disconnected { 517 - color: var(--red); 518 - } 519 - 520 - /* History header */ 521 - .history-header { 522 - margin-top: 1.5rem; 523 - margin-bottom: 0.5rem; 524 - padding-top: 1rem; 525 - border-top: 1px solid var(--surface2); 526 - font-size: 0.8125rem; 527 - color: var(--overlay0); 528 - font-weight: 500; 529 - text-transform: uppercase; 530 - letter-spacing: 0.04em; 531 - } 532 - 533 - /* Empty state */ 534 - .empty-state { 535 - display: flex; 536 - flex-direction: column; 537 - align-items: center; 538 - justify-content: center; 539 - gap: 0.75rem; 540 - padding: 3rem 1.5rem; 541 - text-align: center; 542 - color: var(--overlay1); 543 - font-size: 0.875rem; 544 - line-height: 1.5; 545 - background: var(--surface0); 546 - border-radius: var(--radius-lg); 547 - border: 1px dashed var(--surface2); 548 - margin-bottom: 0.5rem; 549 - } 550 - .empty-state .icon { 551 - font-size: 1.75rem; 552 - line-height: 1; 553 - opacity: 0.5; 554 - } 555 - .empty-state .label { 556 - color: var(--subtext0); 557 - font-weight: 500; 558 - } 559 - .empty-state .hint { 560 - color: var(--overlay0); 561 - font-size: 0.8125rem; 562 - max-width: 24em; 563 - } 564 - 565 - /* Chat */ 566 - .chat-messages { 567 - flex: 1; 568 - overflow-y: auto; 569 - min-height: 0; 570 - background: var(--surface0); 571 - border-radius: var(--radius-lg); 572 - box-shadow: var(--shadow-card); 573 - padding: 0.5rem; 574 - display: flex; 575 - flex-direction: column; 576 - gap: 4px; 577 - margin-bottom: 0.5rem; 578 - } 579 - .chat-messages:empty::after { 580 - content: "No messages yet"; 581 - display: flex; 582 - align-items: center; 583 - justify-content: center; 584 - height: 100%; 585 - min-height: 80px; 586 - color: var(--overlay0); 587 - font-size: 0.875rem; 588 - font-style: italic; 589 - } 590 - .chat-msg { 591 - padding: 6px 10px; 592 - border-left: 3px solid var(--blue); 593 - padding-left: 10px; 594 - font-size: 0.875rem; 595 - line-height: 1.4; 596 - } 597 - .chat-msg .user { 598 - color: var(--blue); 599 - font-weight: 600; 600 - font-size: 0.8rem; 601 - } 602 - .chat-msg .time { 603 - color: var(--overlay0); 604 - font-size: 0.7rem; 605 - float: right; 606 - margin-left: 8px; 607 - } 608 - .chat-msg .text { 609 - color: var(--text); 610 - } 611 - .chat-msg.system { 612 - border-left-color: var(--overlay0); 613 - } 614 - .chat-msg.system .user { 615 - color: var(--overlay0); 616 - } 617 - .chat-msg.system .text { 618 - color: var(--subtext0); 619 - font-style: italic; 620 - } 621 - 622 - /* Chat input */ 623 - .chat-input-row { 624 - display: flex; 625 - gap: 0.5rem; 626 - } 627 - .chat-input-row input { 628 - flex: 1; 629 - font: inherit; 630 - padding: 0.625rem 0.875rem; 631 - border-radius: var(--radius-md); 632 - border: none; 633 - background: var(--mantle); 634 - color: var(--text); 635 - font-size: 0.875rem; 636 - box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2); 637 - transition: box-shadow 150ms ease-out; 638 - } 639 - .chat-input-row input:focus { 640 - outline: none; 641 - box-shadow: 642 - inset 0 2px 4px rgba(0, 0, 0, 0.2), 643 - 0 0 0 2px var(--blue); 644 - } 645 - .chat-input-row input::placeholder { 646 - color: var(--overlay0); 647 - } 648 - .chat-input-row button { 649 - padding: 0.625rem 1rem; 650 - min-height: 40px; 651 - background: var(--mauve); 652 - color: var(--crust); 653 - box-shadow: var(--shadow-card); 654 - } 655 - .chat-input-row button:hover { 656 - filter: brightness(1.12); 657 - box-shadow: var(--shadow-card-hover); 658 - } 659 - 660 - /* Skeleton loading state for pending queue items */ 661 - @keyframes shimmer { 662 - 0% { 663 - background-position: -200% 0; 664 - } 665 - 100% { 666 - background-position: 200% 0; 667 - } 668 - } 669 - .queue li.pending { 670 - position: relative; 671 - } 672 - .queue li.pending .title, 673 - .queue li.pending .duration { 674 - display: inline-block; 675 - border-radius: var(--radius-sm); 676 - background: linear-gradient( 677 - 90deg, 678 - var(--surface1) 25%, 679 - var(--surface2) 37%, 680 - var(--surface1) 63% 681 - ); 682 - background-size: 200% 100%; 683 - animation: shimmer 1.5s ease-in-out infinite; 684 - color: transparent !important; 685 - user-select: none; 686 - } 687 - .queue li.pending .title { 688 - min-width: 16ch; 689 - max-width: 24ch; 690 - } 691 - .queue li.pending .duration { 692 - min-width: 5ch; 693 - } 694 - .queue li.pending .source-link { 695 - visibility: hidden; 696 - } 697 - 698 - /* Stagger animation */ 699 - @keyframes queueFadeIn { 700 - from { 701 - opacity: 0.3; 702 - transform: translateY(4px); 703 - } 704 - to { 705 - opacity: 1; 706 - transform: translateY(0); 707 - } 708 - } 709 - .queue li:nth-child(1) { 710 - animation-delay: 0s; 711 - } 712 - .queue li:nth-child(2) { 713 - animation-delay: 0.01s; 714 - } 715 - .queue li:nth-child(3) { 716 - animation-delay: 0.02s; 717 - } 718 - .queue li:nth-child(4) { 719 - animation-delay: 0.03s; 720 - } 721 - .queue li:nth-child(5) { 722 - animation-delay: 0.04s; 723 - } 724 - .queue li:nth-child(6) { 725 - animation-delay: 0.05s; 726 - } 727 - .queue li:nth-child(n + 7) { 728 - animation-delay: 0.05s; 729 - } 730 - 731 - /* Playlist */ 732 - .playlist-section { 733 - margin-bottom: 0.5rem; 734 - } 735 - .playlist-section .playlist-header { 736 - margin-top: 1.5rem; 737 - margin-bottom: 0.5rem; 738 - padding-top: 1rem; 739 - border-top: 1px solid var(--surface2); 740 - font-size: 0.8125rem; 741 - color: var(--overlay0); 742 - font-weight: 500; 743 - text-transform: uppercase; 744 - letter-spacing: 0.04em; 745 - } 746 - .playlist-section .playlist-empty { 747 - color: var(--overlay0); 748 - font-size: 0.8125rem; 749 - text-align: center; 750 - padding: 1rem; 751 - font-style: italic; 752 - } 753 - .playlist-item { 754 - display: flex; 755 - align-items: center; 756 - gap: 0.75rem; 757 - padding: 0.5rem 0.75rem; 758 - border-radius: var(--radius-sm); 759 - margin-bottom: 0.175rem; 760 - transition: background 150ms ease-out; 761 - font-size: 0.875rem; 762 - } 763 - .playlist-item:hover { 764 - background: var(--surface0); 765 - } 766 - .playlist-item .title { 767 - flex: 1; 768 - min-width: 0; 769 - overflow: hidden; 770 - text-overflow: ellipsis; 771 - white-space: nowrap; 772 - color: var(--subtext1); 773 - } 774 - .playlist-item .duration { 775 - color: var(--overlay0); 776 - font-size: 0.75rem; 777 - font-variant-numeric: tabular-nums; 778 - } 779 - .playlist-remove { 780 - display: inline-flex; 781 - align-items: center; 782 - justify-content: center; 783 - width: 28px; 784 - height: 28px; 785 - border: none; 786 - border-radius: var(--radius-sm); 787 - background: transparent; 788 - color: var(--overlay0); 789 - cursor: pointer; 790 - font-size: 0.875rem; 791 - line-height: 1; 792 - flex-shrink: 0; 793 - transition: color 150ms ease-out, background 150ms ease-out; 794 - } 795 - .playlist-remove:hover { 796 - color: var(--red); 797 - background: color-mix(in srgb, var(--red) 15%, transparent); 798 - } 799 - .rot-add { 800 - display: inline-flex; 801 - align-items: center; 802 - justify-content: center; 803 - width: 28px; 804 - height: 28px; 805 - border: none; 806 - border-radius: var(--radius-sm); 807 - background: transparent; 808 - color: var(--overlay0); 809 - cursor: pointer; 810 - font-size: 0.875rem; 811 - line-height: 1; 812 - flex-shrink: 0; 813 - transition: color 150ms ease-out, background 150ms ease-out; 814 - } 815 - .rot-add:hover { 816 - color: var(--teal); 817 - background: color-mix(in srgb, var(--teal) 15%, transparent); 818 - } 819 - 820 - /* Mobile responsive */ 821 - @media (max-width: 768px) { 822 - html, 823 - body { 824 - height: auto; 825 - } 826 - body { 827 - min-height: 100vh; 828 - } 829 - .layout { 830 - flex-direction: column; 831 - overflow-y: visible; 832 - flex: none; 833 - } 834 - .main-col { 835 - overflow-y: visible; 836 - flex: none; 837 - } 838 - .chat-col { 839 - width: 100%; 840 - padding-left: 0; 841 - border-left: none; 842 - border-top: 1px solid var(--surface2); 843 - padding-top: 1rem; 844 - flex: none; 845 - } 846 - #video-player { 847 - max-height: 40vh; 848 - } 849 - #video-container.idle { 850 - min-height: 120px; 851 - } 852 - .top-bar { 853 - flex-wrap: wrap; 854 - gap: 0.5rem; 855 - } 856 - .top-bar-center { 857 - order: 3; 858 - width: 100%; 859 - text-align: left; 860 - } 861 - /* Touch devices: always show overlays (no hover dependency) */ 862 - @media (hover: none) { 863 - #track-overlay { 864 - opacity: 1; 865 - pointer-events: auto; 866 - background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); 867 - padding-bottom: 1rem; 868 - } 869 - .volume-overlay { 870 - opacity: 1; 871 - pointer-events: auto; 872 - } 873 - } 874 - }