Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

tapes: theatre-mode playback UI + bigger video preview

When a tape is loaded, the browser transforms into a cinema-style
player: the video fills most of the screen (largest 16:9 rect that
fits), wrapped in a black letterbox background with a film-border
frame. The title + progress bar + time labels sit in a chrome band
at the bottom; list view is hidden while playback is active.

Controls in theatre mode:
- Space / Enter: pause / resume
- Escape: stop + unload (returns to list)
- Escape again (from list): exit to prompt

prepareVideo now requests 480x270 @ 24fps instead of the previous
120x68 @ 12fps thumbnail, so playback actually looks like playback
rather than a postage stamp.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+104 -39
+104 -39
fedac/native/pieces/tapes.mjs
··· 144 144 if (h) fetchCloudTapes(system, h); 145 145 return; 146 146 } 147 + // If a tape is already playing, space/enter toggles pause. 148 + if ((key === "enter" || key === " " || key === "space") && 149 + sound?.deck?.decks?.[0]?.loaded) { 150 + const d0 = sound.deck.decks[0]; 151 + if (d0.playing) { 152 + sound.deck.pause?.(0); 153 + msg("paused"); 154 + } else { 155 + sound.deck.play(0); 156 + msg("resumed"); 157 + } 158 + return; 159 + } 147 160 if (key === "enter" || key === " " || key === "space") { 148 161 if (tapes.length > 0) { 149 162 const t = tapes[selection]; 150 163 if (t.source === "local") { 151 164 // Play via deck 0 — same mechanism the dj piece uses. 165 + // Theatre-mode: ask for a larger video preview (480x270, 24fps) 166 + // so playback fills the screen rather than being a thumbnail. 152 167 const ok = sound?.deck?.load?.(0, t.path); 153 168 if (ok) { 154 - const videoOk = sound?.deck?.prepareVideo?.(0, 120, 68, 12); 169 + const videoOk = sound?.deck?.prepareVideo?.(0, 480, 270, 24); 155 170 sound.deck.play(0); 156 171 nowPlaying = t.name; 157 172 msg((videoOk ? "playing local " : "playing audio-only local ") + t.name); ··· 165 180 // URL loading, otherwise instruct the user. 166 181 const ok = sound?.deck?.load?.(0, t.mp4Url); 167 182 if (ok) { 168 - const videoOk = sound?.deck?.prepareVideo?.(0, 120, 68, 12); 183 + const videoOk = sound?.deck?.prepareVideo?.(0, 480, 270, 24); 169 184 sound.deck.play(0); 170 185 nowPlaying = t.slug + ".mp4"; 171 186 msg((videoOk ? "streaming " : "streaming audio-only ") + t.slug); ··· 215 230 return; 216 231 } 217 232 if (key === "escape") { 233 + // If a tape is playing, Escape stops playback and returns to list. 234 + // Press Escape again from the list to return to the prompt. 235 + const d0 = sound?.deck?.decks?.[0]; 236 + if (d0?.loaded) { 237 + sound?.deck?.stop?.(0); 238 + sound?.deck?.unload?.(0); 239 + nowPlaying = ""; 240 + msg("stopped"); 241 + return; 242 + } 218 243 system?.jump?.("prompt"); 219 244 return; 220 245 } ··· 224 249 function paint({ wipe, ink, box, line, write, screen, sound }) { 225 250 const w = screen.width; 226 251 const h = screen.height; 252 + const deck0 = sound?.deck?.decks?.[0] || null; 253 + const playing = !!deck0?.loaded; 254 + 255 + if (playing) { 256 + // === THEATRE MODE === 257 + // Big centered video fills most of the screen. Letterbox top/bottom 258 + // in pure black for cinema feel. Title + progress ride at the bottom. 259 + wipe(0, 0, 0); 260 + // Compute largest 16:9 rectangle that fits with modest chrome 261 + const chromeH = 22; // title + progress band at bottom 262 + const maxH = h - chromeH - 4; 263 + const maxW = w - 8; 264 + let vidW = maxW; 265 + let vidH = Math.floor(vidW * 9 / 16); 266 + if (vidH > maxH) { vidH = maxH; vidW = Math.floor(vidH * 16 / 9); } 267 + const vx = Math.floor((w - vidW) / 2); 268 + const vy = Math.floor((maxH - vidH) / 2) + 2; 269 + if (deck0?.videoReady) { 270 + sound?.deck?.videoBlit?.(0, vx, vy, vidW, vidH); 271 + } else { 272 + ink(140, 140, 160); 273 + write("loading video...", { x: vx + 8, y: vy + Math.floor(vidH / 2), size: 1, font: "font_1" }); 274 + } 275 + // Film border frame 276 + ink(60, 60, 80); 277 + box(vx - 1, vy - 1, vidW + 2, vidH + 2, "outline"); 278 + // Title 279 + const title = (nowPlaying || deck0?.title || "deck 0").replace(/\.mp4$/, ""); 280 + ink(220, 230, 250); 281 + write(title, { x: 8, y: h - chromeH + 2, size: 1, font: "font_1" }); 282 + // Paused indicator 283 + if (!deck0?.playing) { 284 + ink(255, 180, 120); 285 + write("[paused]", { x: 8 + title.length * 6 + 8, y: h - chromeH + 2, size: 1, font: "font_1" }); 286 + } 287 + // Progress bar — full width 288 + if (deck0?.duration > 0) { 289 + const prog = Math.max(0, Math.min(1, (deck0?.position || 0) / deck0.duration)); 290 + const barY = h - 8; 291 + const barX = 8; 292 + const barW = w - 16; 293 + ink(40, 48, 60); 294 + box(barX, barY, barW, 4, true); 295 + ink(140, 230, 200); 296 + box(barX, barY, Math.max(1, Math.floor(barW * prog)), 4, true); 297 + // Time labels 298 + const cur = formatTime(deck0.position || 0); 299 + const tot = formatTime(deck0.duration || 0); 300 + ink(150, 180, 210); 301 + write(cur, { x: barX, y: barY - 10, size: 1, font: "font_1" }); 302 + const totStr = "/ " + tot; 303 + write(totStr, { x: w - totStr.length * 6 - 8, y: barY - 10, size: 1, font: "font_1" }); 304 + } 305 + // Hint 306 + ink(90, 100, 130); 307 + write("space pause · esc stop", { x: w - 150, y: h - chromeH + 2, size: 1, font: "font_1" }); 308 + // Message 309 + if (message && frame - messageFrame < 180) { 310 + const age = frame - messageFrame; 311 + const a = Math.max(80, 255 - Math.floor(age * 0.8)); 312 + ink(255, 220, 140, a); 313 + write(message, { x: Math.floor(w / 2) - message.length * 3, y: h - chromeH - 12, size: 1, font: "font_1" }); 314 + } 315 + return; 316 + } 317 + 318 + // === BROWSER MODE === 227 319 wipe(16, 14, 20); 228 - const deck0 = sound?.deck?.decks?.[0] || null; 229 - const showPreview = !!deck0?.loaded; 230 - const previewW = showPreview ? Math.min(132, Math.max(92, Math.floor(w * 0.36))) : 0; 231 - const listRight = showPreview ? (w - previewW - 12) : (w - 8); 232 320 233 321 // Title 234 322 ink(200, 220, 255); 235 323 write("tapes", { x: 8, y: 8, size: 2, font: "matrix" }); 236 324 ink(120, 140, 170); 237 - write("/mnt/tapes — ↑↓ nav enter play r rescan del delete esc prompt", 325 + write("↑↓ nav · enter play · r rescan · del delete · esc prompt", 238 326 { x: 80, y: 14, size: 1, font: "font_1" }); 239 327 240 - // Tape list 241 328 const listY = 40; 242 329 const rowH = 14; 243 330 const visibleRows = Math.max(1, Math.floor((h - listY - 20) / rowH)); 244 331 const scrollStart = Math.max(0, selection - Math.floor(visibleRows / 2)); 245 332 const scrollEnd = Math.min(tapes.length, scrollStart + visibleRows); 246 - 247 - if (showPreview) { 248 - const px = listRight + 4; 249 - const py = 40; 250 - const ph = Math.min(h - 60, Math.floor(previewW * 0.62)); 251 - ink(36, 44, 58, 220); 252 - box(px - 2, py - 2, previewW + 4, ph + 18, true); 253 - if (deck0?.videoReady) { 254 - sound?.deck?.videoBlit?.(0, px, py, previewW, ph); 255 - } else { 256 - ink(120, 140, 170); 257 - write("video buffering...", { x: px + 8, y: py + Math.floor(ph / 2), size: 1, font: "font_1" }); 258 - } 259 - ink(220, 230, 240); 260 - write("now playing", { x: px, y: py + ph + 4, size: 1, font: "font_1" }); 261 - ink(150, 180, 210); 262 - write((nowPlaying || deck0?.title || "deck 0").replace(/\.mp4$/, ""), { x: px, y: py + ph + 14, size: 1, font: "font_1" }); 263 - if (deck0?.duration > 0) { 264 - const prog = Math.max(0, Math.min(1, (deck0?.position || 0) / deck0.duration)); 265 - ink(50, 60, 72); 266 - box(px, py + ph + 22, previewW, 5, true); 267 - ink(120, 220, 180); 268 - box(px, py + ph + 22, Math.max(1, Math.floor(previewW * prog)), 5, true); 269 - } 270 - } 333 + const listRight = w - 8; 271 334 272 335 if (tapes.length === 0) { 273 336 ink(180, 180, 200); 274 337 write("No tapes recorded yet.", { x: 20, y: listY + 8, size: 1, font: "font_1" }); 275 338 ink(120, 140, 170); 276 - write("Press PrintScreen in notepat to record a tape.", 339 + write("Press PrintScreen (or Insert) in notepat to record.", 277 340 { x: 20, y: listY + 24, size: 1, font: "font_1" }); 278 341 } else { 279 342 for (let i = scrollStart; i < scrollEnd; i++) { ··· 283 346 ink(40, 60, 100, 200); 284 347 box(4, y - 2, listRight - 4, rowH, true); 285 348 } 286 - // Source badge (☁ cloud / 📁 local — shown as small ascii tag) 287 349 const badge = t.source === "cloud" ? "C" : "L"; 288 350 const badgeColor = t.source === "cloud" ? [120, 220, 180] : [220, 200, 120]; 289 351 ink(badgeColor[0], badgeColor[1], badgeColor[2]); 290 352 write(badge, { x: 10, y: y + 2, size: 1, font: "font_1" }); 291 - // Name 292 353 if (i === selection) ink(255, 255, 255); 293 354 else ink(200, 220, 240); 294 355 let label = t.name.replace(/\.mp4$/, ""); 295 - if (showPreview && label.length > 20) label = label.slice(0, 20) + "..."; 296 356 write(label, { x: 24, y: y + 2, size: 1, font: "font_1" }); 297 - // Right meta: local shows size, cloud shows code 298 357 ink(140, 160, 190); 299 358 const meta = t.source === "local" ? fmtBytes(t.size) : ("!" + (t.code || "?")); 300 359 const metaX = listRight - meta.length * 6 - 6; ··· 302 361 } 303 362 } 304 363 305 - // Message line 306 364 if (message && frame - messageFrame < 240) { 307 365 const age = frame - messageFrame; 308 366 const a = Math.max(80, 255 - Math.floor(age * 0.6)); 309 367 ink(255, 220, 140, a); 310 368 write(message, { x: 8, y: h - 14, size: 1, font: "font_1" }); 311 369 } 370 + } 371 + 372 + function formatTime(s) { 373 + if (!Number.isFinite(s) || s < 0) return "0:00"; 374 + const m = Math.floor(s / 60); 375 + const sec = Math.floor(s % 60); 376 + return m + ":" + (sec < 10 ? "0" : "") + sec; 312 377 } 313 378 314 379 function sim() {