Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 1045 lines 33 kB view raw
1<!-- 2DP-1 Feed Landing Page for feed.aesthetic.computer 3Live channel/playlist explorer backed by Go + Postgres (V2) 4Created: 2026.02.20, migrated to V2: 2026.04.08 5--> 6<!DOCTYPE html> 7<html lang="en"> 8<head> 9 <meta charset="UTF-8"> 10 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 11 <title>feed · Aesthetic Computer</title> 12 <meta name="description" content="DP-1 feed server for Aesthetic Computer — channels and playlists for user-generated art, code, music, and more"> 13 <link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png"> 14 <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> 15 16 <style> 17 /* -- reset & root -- */ 18 * { margin: 0; padding: 0; box-sizing: border-box; } 19 ::-webkit-scrollbar { display: none; } 20 21 /* -- dark (default) -- */ 22 :root { 23 --bg: #1a1a2e; 24 --text: #e8e8e8; 25 --dim: #888; 26 --pink: #ff6b9d; 27 --cyan: #4ecdc4; 28 --green: #6bcb77; 29 --gold: #ffd93d; 30 --box-bg: rgba(255,255,255,0.03); 31 --box-border: rgba(255,255,255,0.1); 32 } 33 34 /* -- light -- */ 35 body.light-mode { 36 --bg: #f5f5f5; 37 --text: #1a1a2e; 38 --dim: #666; 39 --pink: rgb(205, 92, 155); 40 --cyan: #0891b2; 41 --green: #059669; 42 --box-bg: rgba(0,0,0,0.03); 43 --box-border: rgba(0,0,0,0.12); 44 } 45 46 body { 47 font-family: 'Berkeley Mono Variable', 'Menlo', monospace; 48 font-size: 14px; 49 line-height: 1.5; 50 -webkit-text-size-adjust: none; 51 background: var(--bg); 52 color: var(--text); 53 cursor: url('https://aesthetic.computer/aesthetic.computer/cursors/precise.svg') 12 12, auto; 54 } 55 56 a { color: var(--pink); text-decoration: none; } 57 a:hover { text-decoration: underline; } 58 59 /* -- animations -- */ 60 @keyframes fadeIn { 61 from { opacity: 0; } 62 to { opacity: 1; } 63 } 64 65 @keyframes slideIn { 66 from { opacity: 0; transform: translateY(6px); } 67 to { opacity: 1; transform: translateY(0); } 68 } 69 70 @keyframes pulse { 71 0%, 100% { opacity: 1; } 72 50% { opacity: 0.5; } 73 } 74 75 @keyframes float { 76 0%, 100% { transform: translateY(0); } 77 50% { transform: translateY(-3px); } 78 } 79 80 /* -- layout -- */ 81 .container { 82 max-width: 720px; 83 margin: 0 auto; 84 padding: 2em 1em; 85 animation: fadeIn 0.4s ease-out; 86 } 87 88 /* -- header -- */ 89 header { 90 text-align: center; 91 padding: 2em 0 1.5em; 92 margin-bottom: 1.5em; 93 } 94 95 .logo { 96 font-size: 2.2em; 97 font-weight: normal; 98 letter-spacing: -0.02em; 99 margin-bottom: 0.1em; 100 } 101 102 .logo b { color: var(--pink); } 103 .dot-pink { color: var(--pink); font-weight: bold; } 104 .dot-cyan { color: var(--cyan); font-weight: bold; } 105 106 .subtitle { 107 color: var(--dim); 108 font-size: 0.85em; 109 margin-bottom: 1em; 110 } 111 112 .status-dot { 113 display: inline-block; 114 width: 8px; 115 height: 8px; 116 border-radius: 50%; 117 background: var(--green); 118 animation: pulse 2s ease-in-out infinite; 119 margin-right: 0.3em; 120 vertical-align: middle; 121 } 122 123 .status-dot.offline { 124 background: #ef4444; 125 animation: none; 126 } 127 128 .stats { 129 display: flex; 130 justify-content: center; 131 gap: 0.6em; 132 flex-wrap: wrap; 133 font-size: 0.8em; 134 } 135 136 .stat { 137 padding: 0.3em 0.7em; 138 background: var(--box-bg); 139 border: 1px solid var(--box-border); 140 border-radius: 4px; 141 color: var(--dim); 142 } 143 144 .stat strong { color: var(--pink); } 145 146 /* -- sections -- */ 147 .section { 148 margin-bottom: 1.5em; 149 animation: slideIn 0.5s ease-out both; 150 } 151 152 .section:nth-child(2) { animation-delay: 0.1s; } 153 .section:nth-child(3) { animation-delay: 0.2s; } 154 .section:nth-child(4) { animation-delay: 0.3s; } 155 156 .section-hd { 157 font-size: 0.85em; 158 color: var(--dim); 159 text-transform: uppercase; 160 letter-spacing: 0.1em; 161 margin-bottom: 0.6em; 162 display: flex; 163 justify-content: space-between; 164 align-items: center; 165 } 166 167 .section-count { color: var(--pink); } 168 169 /* -- channel card -- */ 170 .channel-card { 171 background: var(--box-bg); 172 border: 1px solid var(--box-border); 173 border-radius: 6px; 174 padding: 1.2em; 175 position: relative; 176 overflow: hidden; 177 } 178 179 .channel-card::before { 180 content: ''; 181 position: absolute; 182 top: 0; 183 left: 0; 184 right: 0; 185 height: 2px; 186 background: linear-gradient(90deg, var(--pink), var(--cyan)); 187 } 188 189 .channel-title { 190 font-size: 1.3em; 191 margin-bottom: 0.2em; 192 } 193 194 .channel-title b { color: var(--pink); } 195 196 .channel-meta { 197 color: var(--dim); 198 font-size: 0.85em; 199 line-height: 1.6; 200 } 201 202 .channel-curator { 203 display: inline-flex; 204 align-items: center; 205 gap: 0.3em; 206 color: var(--cyan); 207 } 208 209 /* -- playlist rows -- */ 210 .playlist-list { 211 border: 1px solid var(--box-border); 212 border-radius: 6px; 213 overflow: hidden; 214 } 215 216 .pl-row { 217 padding: 0.8em 1em; 218 border-bottom: 1px solid var(--box-border); 219 display: flex; 220 justify-content: space-between; 221 align-items: center; 222 gap: 0.8em; 223 cursor: pointer; 224 transition: background 0.15s, padding-left 0.15s; 225 } 226 227 .pl-row:last-child { border-bottom: none; } 228 .pl-row:hover { background: var(--box-bg); padding-left: 1.2em; } 229 .pl-row.open { background: var(--box-bg); } 230 231 .pl-left { 232 display: flex; 233 align-items: center; 234 gap: 0.6em; 235 flex: 1; 236 min-width: 0; 237 } 238 239 .pl-icon { 240 font-size: 1.1em; 241 flex-shrink: 0; 242 animation: float 3s ease-in-out infinite; 243 } 244 245 .pl-row:nth-child(2) .pl-icon { animation-delay: 0.3s; } 246 .pl-row:nth-child(3) .pl-icon { animation-delay: 0.6s; } 247 .pl-row:nth-child(4) .pl-icon { animation-delay: 0.9s; } 248 .pl-row:nth-child(5) .pl-icon { animation-delay: 1.2s; } 249 250 .pl-info { min-width: 0; } 251 252 .pl-title { 253 color: var(--text); 254 font-weight: normal; 255 white-space: nowrap; 256 overflow: hidden; 257 text-overflow: ellipsis; 258 } 259 260 .pl-summary { 261 font-size: 0.8em; 262 color: var(--dim); 263 white-space: nowrap; 264 overflow: hidden; 265 text-overflow: ellipsis; 266 max-width: 400px; 267 } 268 269 .pl-right { 270 display: flex; 271 align-items: center; 272 gap: 0.6em; 273 flex-shrink: 0; 274 font-size: 0.8em; 275 color: var(--dim); 276 } 277 278 .badge { 279 padding: 0.15em 0.5em; 280 border-radius: 3px; 281 font-size: 0.75em; 282 text-transform: uppercase; 283 letter-spacing: 0.05em; 284 } 285 286 .badge-dynamic { 287 background: rgba(255,107,157,0.12); 288 color: var(--pink); 289 } 290 291 .badge-static { 292 background: var(--box-bg); 293 border: 1px solid var(--box-border); 294 color: var(--dim); 295 } 296 297 /* -- expanded piece list -- */ 298 .pl-items { 299 max-height: 0; 300 overflow: hidden; 301 transition: max-height 0.3s ease-out; 302 border-bottom: 1px solid var(--box-border); 303 } 304 305 .pl-items.open { 306 max-height: 2000px; 307 transition: max-height 0.5s ease-in; 308 } 309 310 .pl-items:last-child { border-bottom: none; } 311 312 .pl-items-inner { 313 padding: 0.5em 1em 0.8em; 314 display: grid; 315 grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); 316 gap: 0.3em; 317 } 318 319 .piece { 320 display: flex; 321 align-items: center; 322 gap: 0.3em; 323 padding: 0.25em 0.4em; 324 border-radius: 3px; 325 transition: background 0.1s; 326 font-size: 0.85em; 327 } 328 329 .piece:hover { background: rgba(255,107,157,0.08); } 330 331 .piece-code { 332 color: var(--pink); 333 text-decoration: none; 334 font-weight: normal; 335 } 336 337 .piece-code:hover { text-decoration: underline; } 338 339 /* -- api endpoints -- */ 340 .endpoint-grid { 341 display: grid; 342 grid-template-columns: 1fr 1fr; 343 gap: 0.3em; 344 } 345 346 @media (max-width: 500px) { 347 .endpoint-grid { grid-template-columns: 1fr; } 348 } 349 350 .ep { 351 padding: 0.4em 0.6em; 352 background: var(--box-bg); 353 border: 1px solid var(--box-border); 354 border-radius: 4px; 355 display: flex; 356 gap: 0.5em; 357 font-size: 0.8em; 358 } 359 360 .ep .method { 361 color: var(--cyan); 362 font-weight: normal; 363 flex-shrink: 0; 364 min-width: 3em; 365 } 366 367 .ep .method-post { color: var(--green); } 368 .ep .method-put { color: var(--gold); } 369 370 .ep .path { color: var(--dim); } 371 372 /* -- footer -- */ 373 footer { 374 margin-top: 2.5em; 375 padding-top: 1em; 376 border-top: 1px solid var(--box-border); 377 text-align: center; 378 font-size: 0.8em; 379 color: var(--dim); 380 } 381 382 footer .heart { 383 color: var(--pink); 384 display: inline-block; 385 animation: float 2s ease-in-out infinite; 386 } 387 388 /* -- responsive -- */ 389 @media (max-width: 600px) { 390 .container { padding: 1em 0.6em; } 391 .logo { font-size: 1.6em; } 392 .pl-summary { display: none; } 393 .pl-right span:last-child { display: none; } 394 .pl-items-inner { grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); } 395 } 396 397 .loading { color: var(--dim); animation: pulse 1.5s ease-in-out infinite; } 398 399 /* -- tv player -- */ 400 .tv-wrapper { 401 margin-bottom: 1.5em; 402 animation: slideIn 0.5s ease-out both; 403 } 404 405 .tv-header { 406 display: flex; 407 justify-content: space-between; 408 align-items: center; 409 margin-bottom: 0.5em; 410 } 411 412 .tv-label { 413 font-size: 0.85em; 414 color: var(--dim); 415 text-transform: uppercase; 416 letter-spacing: 0.1em; 417 } 418 419 .tv-now-playing { 420 font-size: 0.8em; 421 color: var(--pink); 422 } 423 424 .tv-screen { 425 position: relative; 426 width: 100%; 427 aspect-ratio: 16 / 9; 428 background: #000; 429 border-radius: 6px; 430 overflow: hidden; 431 border: 1px solid var(--box-border); 432 } 433 434 .tv-screen iframe { 435 width: 100%; 436 height: 100%; 437 border: none; 438 display: block; 439 transition: opacity 0.3s ease; 440 } 441 442 .tv-controls { 443 display: flex; 444 align-items: center; 445 gap: 0.4em; 446 margin-top: 0.5em; 447 } 448 449 .tv-btn { 450 background: var(--box-bg); 451 border: 1px solid var(--box-border); 452 border-radius: 4px; 453 color: var(--text); 454 font-family: inherit; 455 font-size: 0.8em; 456 padding: 0.3em 0.6em; 457 cursor: pointer; 458 transition: background 0.15s, border-color 0.15s; 459 } 460 461 .tv-btn:hover { 462 background: rgba(255,107,157,0.08); 463 border-color: var(--pink); 464 } 465 466 .tv-btn.active { 467 border-color: var(--pink); 468 color: var(--pink); 469 } 470 471 .tv-progress { 472 flex: 1; 473 height: 3px; 474 background: var(--box-border); 475 border-radius: 2px; 476 overflow: hidden; 477 cursor: pointer; 478 } 479 480 .tv-progress-bar { 481 height: 100%; 482 background: var(--pink); 483 border-radius: 2px; 484 transition: width 0.3s linear; 485 } 486 487 .tv-index { 488 font-size: 0.75em; 489 color: var(--dim); 490 min-width: 4em; 491 text-align: right; 492 } 493 494 .tv-playlist-picker { 495 display: flex; 496 gap: 0.3em; 497 margin-top: 0.4em; 498 flex-wrap: wrap; 499 } 500 501 .tv-playlist-chip { 502 background: var(--box-bg); 503 border: 1px solid var(--box-border); 504 border-radius: 4px; 505 color: var(--dim); 506 font-family: inherit; 507 font-size: 0.75em; 508 padding: 0.2em 0.5em; 509 cursor: pointer; 510 transition: background 0.15s, border-color 0.15s, color 0.15s; 511 } 512 513 .tv-playlist-chip:hover { 514 border-color: var(--pink); 515 color: var(--text); 516 } 517 518 .tv-playlist-chip.active { 519 border-color: var(--pink); 520 color: var(--pink); 521 background: rgba(255,107,157,0.08); 522 } 523 524 /* -- modal overlay -- */ 525 .feed-modal { 526 position: fixed; 527 top: 0; 528 left: 0; 529 right: 0; 530 bottom: 0; 531 z-index: 9999; 532 display: flex; 533 align-items: center; 534 justify-content: center; 535 } 536 537 .feed-modal-backdrop { 538 position: absolute; 539 top: 0; 540 left: 0; 541 right: 0; 542 bottom: 0; 543 background: rgba(0, 0, 0, 0.75); 544 } 545 546 .feed-modal-content { 547 position: relative; 548 width: 90%; 549 max-width: 900px; 550 height: 80vh; 551 background: var(--bg); 552 border-radius: 8px; 553 overflow: hidden; 554 box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4); 555 } 556 557 .feed-modal-close { 558 position: absolute; 559 top: -40px; 560 right: 0; 561 z-index: 10; 562 background: rgba(255, 255, 255, 0.9); 563 border: none; 564 border-radius: 50%; 565 width: 32px; 566 height: 32px; 567 font-size: 20px; 568 line-height: 1; 569 cursor: pointer; 570 color: #000; 571 display: flex; 572 align-items: center; 573 justify-content: center; 574 transition: background 0.15s, transform 0.15s; 575 } 576 577 .feed-modal-close:hover { 578 background: #fff; 579 transform: scale(1.1); 580 } 581 582 .feed-modal-content iframe { 583 width: 100%; 584 height: 100%; 585 border: none; 586 } 587 </style> 588</head> 589<body> 590 <div class="container"> 591 <header> 592 <div class="logo"><b>feed</b><span class="dot-pink">.</span><b>aesthetic</b><span class="dot-cyan">.</span><b>computer</b></div> 593 <p class="subtitle">channels and playlists for aesthetic computer</p> 594 <div class="stats" id="stats"> 595 <span class="loading">connecting...</span> 596 </div> 597 </header> 598 599 <div class="tv-wrapper" id="tv-wrapper" style="display:none"> 600 <div class="tv-header"> 601 <span class="tv-label">now playing</span> 602 <span class="tv-now-playing" id="tv-now-playing"></span> 603 </div> 604 <div class="tv-screen"> 605 <iframe id="tv-iframe" allow="autoplay" sandbox="allow-scripts allow-same-origin"></iframe> 606 </div> 607 <div class="tv-controls"> 608 <button class="tv-btn" id="tv-prev" title="Previous">&#9664;</button> 609 <button class="tv-btn" id="tv-playpause" title="Play/Pause">&#9646;&#9646;</button> 610 <button class="tv-btn" id="tv-next" title="Next">&#9654;</button> 611 <div class="tv-progress" id="tv-progress"> 612 <div class="tv-progress-bar" id="tv-progress-bar"></div> 613 </div> 614 <span class="tv-index" id="tv-index"></span> 615 </div> 616 <div class="tv-playlist-picker" id="tv-playlist-picker"></div> 617 </div> 618 619 <div id="content"> 620 <div class="loading" style="text-align:center;padding:2em">loading feed...</div> 621 </div> 622 623 <div class="section" id="api-section" style="animation-delay:0.3s"> 624 <div class="section-hd"> 625 <span>API</span> 626 <span class="section-count" id="api-version"></span> 627 </div> 628 <div class="endpoint-grid"> 629 <div class="ep"><span class="method">GET</span><span class="path">/health</span></div> 630 <div class="ep"><span class="method">GET</span><span class="path">/api/v1</span></div> 631 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/playlists</span></div> 632 <div class="ep"><span class="method method-post">POST</span><span class="path">/api/v1/playlists</span></div> 633 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/playlists/:id</span></div> 634 <div class="ep"><span class="method method-put">PUT</span><span class="path">/api/v1/playlists/:id</span></div> 635 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/playlist-groups</span></div> 636 <div class="ep"><span class="method method-post">POST</span><span class="path">/api/v1/playlist-groups</span></div> 637 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/channels</span></div> 638 <div class="ep"><span class="method method-post">POST</span><span class="path">/api/v1/channels</span></div> 639 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/playlist-items</span></div> 640 <div class="ep"><span class="method">GET</span><span class="path">/api/v1/registry/channels</span></div> 641 </div> 642 </div> 643 644 <div class="section" id="protocol-section" style="animation-delay:0.4s"> 645 <div class="section-hd"> 646 <span>PROTOCOL</span> 647 </div> 648 <div class="channel-card" style="padding:1em"> 649 <div style="font-size:0.9em;margin-bottom:0.6em"> 650 <b style="color:var(--cyan)">DP-1</b> <span style="color:var(--dim)">v1.1.0</span> 651 </div> 652 <div style="font-size:0.8em;color:var(--dim);line-height:1.6"> 653 an open, vendor-neutral protocol for signed digital art playlists. 654 cryptographically signed with Ed25519, supporting multi-signature verification. 655 </div> 656 <div style="display:flex;gap:0.8em;margin-top:0.8em;flex-wrap:wrap;font-size:0.8em"> 657 <a href="https://github.com/display-protocol/dp1">spec</a> 658 <a href="https://github.com/display-protocol/dp1-feed-v2">feed server</a> 659 <a href="https://github.com/display-protocol/dp1-validator">validator</a> 660 <a href="https://feralfile.com" style="color:var(--cyan)">feral file</a> 661 </div> 662 <div style="margin-top:0.6em;font-size:0.7em;color:var(--dim)"> 663 CC BY 4.0 &middot; display protocol &middot; feral file 664 </div> 665 </div> 666 </div> 667 668 <footer> 669 <a href="https://github.com/display-protocol/dp1-feed-v2">dp1-feed-v2</a> 670 &middot; <a href="https://aesthetic.computer" onclick="event.preventDefault(); openModal(this.href)">aesthetic.computer</a> 671 &middot; <a href="https://kidlisp.com" onclick="event.preventDefault(); openModal(this.href)">kidlisp</a> 672 &middot; <a href="https://feralfile.com" style="color:var(--cyan)">feral file</a> 673 <br> 674 <span class="heart">~</span> 675 </footer> 676 </div> 677 678 <script> 679 // Theme: follow system preference 680 function applyTheme() { 681 if (window.matchMedia('(prefers-color-scheme: light)').matches) { 682 document.body.classList.add('light-mode'); 683 } else { 684 document.body.classList.remove('light-mode'); 685 } 686 } 687 applyTheme(); 688 window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', applyTheme); 689 690 const API = '/api/v1'; 691 const esc = s => { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }; 692 693 // Pick an icon for a channel or playlist 694 const channelIcons = { 695 kidlisp: '\u{1F4BB}', // laptop 696 paintings: '\u{1F3A8}', // artist palette 697 mugs: '\u{2615}', // hot beverage 698 clocks: '\u{23F0}', // alarm clock 699 moods: '\u{1F30A}', // wave 700 chats: '\u{1F4AC}', // speech bubble 701 instruments: '\u{1F3B9}', // musical keyboard 702 tapes: '\u{1F4FC}', // videocassette 703 }; 704 705 function chIcon(title) { 706 const t = (title || '').toLowerCase(); 707 for (const [k, v] of Object.entries(channelIcons)) { 708 if (t.includes(k)) return v; 709 } 710 return '\u{1F4BF}'; 711 } 712 713 function plIcon(title) { 714 const t = (title || '').toLowerCase(); 715 if (t.includes('top 100')) return '\u{1F3B0}'; 716 if (t.includes('@jeffrey')) return '\u{1F451}'; 717 if (t.includes('@fifi')) return '\u{1F338}'; 718 if (t.includes('color')) return '\u{1F308}'; 719 if (t.includes('chord')) return '\u{1F3B9}'; 720 if (t.includes('recent')) return '\u{2728}'; 721 if (t.includes('mood')) return '\u{1F30A}'; 722 if (t.includes('chat')) return '\u{1F4AC}'; 723 return '\u{1F4BF}'; 724 } 725 726 async function load() { 727 // Health 728 try { 729 const [health, info] = await Promise.all([ 730 fetch(`${API}/health`).then(r => r.json()), 731 fetch(`${API}`).then(r => r.json()), 732 ]); 733 const el = document.getElementById('stats'); 734 const dot = (health.status === 'ok' || health.status === 'healthy') 735 ? '<span class="status-dot"></span>' 736 : '<span class="status-dot offline"></span>'; 737 const parts = [dot + '<span class="stat"><strong>online</strong></span>']; 738 if (info.version) parts.push(`<span class="stat">v<strong>${esc(info.version)}</strong></span>`); 739 if (info.runtime) parts.push(`<span class="stat">${esc(info.runtime)}</span>`); 740 if (info.extensionsEnabled) parts.push('<span class="stat">extensions</span>'); 741 el.innerHTML = parts.join(''); 742 // Show spec version in API section header 743 const apiVer = document.getElementById('api-version'); 744 if (apiVer && info.specification) apiVer.textContent = info.specification; 745 } catch (e) { 746 document.getElementById('stats').innerHTML = 747 '<span class="status-dot offline"></span><span class="stat" style="color:#ef4444"><strong>offline</strong></span>'; 748 } 749 750 // Channels + playlists 751 try { 752 const chData = await fetch(`${API}/channels`).then(r => r.json()); 753 const channels = chData.items || []; 754 755 // Fetch all playlists for all channels 756 const allPlaylists = []; 757 const plById = {}; 758 for (const ch of channels) { 759 for (const url of (ch.playlists || [])) { 760 const id = url.split('/').pop(); 761 if (!plById[id]) { 762 const p = await fetch(`${API}/playlists/${id}`).then(r => r.json()).catch(() => null); 763 if (p) { plById[id] = p; allPlaylists.push(p); } 764 } 765 } 766 } 767 768 window._playlists = []; // will be filled in channel order 769 let globalIdx = 0; 770 let totalPlaylists = 0; 771 let totalItems = 0; 772 allPlaylists.forEach(p => { totalPlaylists++; totalItems += (p.items?.length || 0); }); 773 774 // Stats summary 775 const statsEl = document.getElementById('stats'); 776 const existingStats = statsEl.innerHTML; 777 statsEl.innerHTML = existingStats + 778 ` <span class="stat"><strong>${channels.length}</strong> channels</span>` + 779 ` <span class="stat"><strong>${totalPlaylists}</strong> playlists</span>` + 780 ` <span class="stat"><strong>${totalItems}</strong> items</span>`; 781 782 let html = ''; 783 784 for (const ch of channels) { 785 const chPlIds = (ch.playlists || []).map(url => url.split('/').pop()); 786 const chPls = chPlIds.map(id => plById[id]).filter(Boolean); 787 const chItems = chPls.reduce((n, p) => n + (p.items?.length || 0), 0); 788 const icon = chIcon(ch.title); 789 790 html += `<div class="section"> 791 <div class="channel-card"> 792 <div class="channel-title"><span style="margin-right:0.3em">${icon}</span><b>${esc(ch.title)}</b></div> 793 <div class="channel-meta">${esc(ch.summary || '')}</div> 794 <div class="channel-meta" style="margin-top:0.4em"> 795 <span class="channel-curator">${esc(ch.curator || '-')}</span> 796 &middot; ${chPls.length} playlists &middot; ${chItems} items 797 </div> 798 </div> 799 <div class="playlist-list" style="margin-top:0.5em">`; 800 801 for (const p of chPls) { 802 const idx = globalIdx++; 803 window._playlists[idx] = p; 804 const count = p.items?.length || 0; 805 const isStatic = /(color|chord|chat)/i.test(p.title || ''); 806 const pIcon = plIcon(p.title); 807 808 html += `<div class="pl-row" onclick="toggle(${idx})" id="plr-${idx}"> 809 <div class="pl-left"> 810 <span class="pl-icon">${pIcon}</span> 811 <div class="pl-info"> 812 <div class="pl-title">${esc(p.title || p.slug)}</div> 813 <div class="pl-summary">${esc(p.summary || '')}</div> 814 </div> 815 </div> 816 <div class="pl-right"> 817 <span class="badge ${isStatic ? 'badge-static' : 'badge-dynamic'}">${isStatic ? 'static' : 'daily'}</span> 818 <span>${count}</span> 819 <button class="tv-btn" onclick="event.stopPropagation(); tvSwitchPlaylist('${p.id}')" title="Play on TV" style="font-size:0.9em;padding:0.15em 0.4em">&#9654;</button> 820 </div> 821 </div> 822 <div class="pl-items" id="pli-${idx}"> 823 <div class="pl-items-inner" id="plic-${idx}"></div> 824 </div>`; 825 } 826 827 html += '</div></div>'; 828 } 829 830 document.getElementById('content').innerHTML = html; 831 832 // Start TV player — default to Colors playlist 833 tv.allPlaylists = allPlaylists; 834 const colorsPlaylist = allPlaylists.find(p => /^colors$/i.test(p.title)); 835 if (colorsPlaylist) tvLoad(colorsPlaylist); 836 else if (allPlaylists.length) tvLoad(allPlaylists[0]); 837 tvBuildPicker(); 838 } catch (e) { 839 document.getElementById('content').innerHTML = 840 '<div style="text-align:center;color:var(--dim);padding:2em">could not load feed data</div>'; 841 } 842 } 843 844 function toggle(i) { 845 const items = document.getElementById('pli-' + i); 846 const row = document.getElementById('plr-' + i); 847 const inner = document.getElementById('plic-' + i); 848 if (!items) return; 849 850 const wasOpen = items.classList.contains('open'); 851 items.classList.toggle('open'); 852 row.classList.toggle('open'); 853 854 // Lazy-render items on first open 855 if (!wasOpen && inner && !inner.dataset.loaded) { 856 const p = window._playlists?.[i]; 857 if (p?.items?.length) { 858 inner.innerHTML = p.items.map((item, j) => { 859 const code = item.title || item.id || '-'; 860 const url = tvSourceUrl(item); 861 return `<div class="piece"><a class="piece-code" href="${esc(url)}" onclick="event.preventDefault(); openModal(this.href)">${esc(code)}</a></div>`; 862 }).join(''); 863 } else { 864 inner.innerHTML = '<div style="color:var(--dim);grid-column:1/-1">empty</div>'; 865 } 866 inner.dataset.loaded = '1'; 867 } 868 } 869 870 // -- Modal -- 871 function openModal(url) { 872 const existing = document.getElementById('feed-modal'); 873 if (existing) existing.remove(); 874 875 const modal = document.createElement('div'); 876 modal.id = 'feed-modal'; 877 modal.className = 'feed-modal'; 878 modal.innerHTML = ` 879 <div class="feed-modal-backdrop"></div> 880 <div class="feed-modal-content"> 881 <button class="feed-modal-close">\u00d7</button> 882 <iframe src="${url}" frameborder="0"></iframe> 883 </div> 884 `; 885 document.body.appendChild(modal); 886 887 modal.querySelector('.feed-modal-backdrop').addEventListener('click', closeModal); 888 modal.querySelector('.feed-modal-close').addEventListener('click', closeModal); 889 document.addEventListener('keydown', handleEscape); 890 } 891 892 function closeModal() { 893 const modal = document.getElementById('feed-modal'); 894 if (modal) modal.remove(); 895 document.removeEventListener('keydown', handleEscape); 896 } 897 898 function handleEscape(e) { 899 if (e.key === 'Escape') closeModal(); 900 } 901 902 // -- TV Player -- 903 const tv = { 904 items: [], 905 index: 0, 906 playing: true, 907 timer: null, 908 elapsed: 0, 909 tick: null, 910 playlistId: null, 911 allPlaylists: [], // filled after load 912 pieceReady: false, 913 }; 914 915 // Listen for boot-log 'ready:' from aesthetic.computer iframe 916 window.addEventListener('message', (e) => { 917 if (e.data?.type === 'boot-log' && e.data.message?.startsWith?.('ready:')) { 918 if (!tv.pieceReady) { 919 tv.pieceReady = true; 920 const iframe = document.getElementById('tv-iframe'); 921 iframe.style.opacity = '1'; 922 if (tv.playing) tvStartTimer(); 923 } 924 } 925 }); 926 927 // Route all pieces through aesthetic.computer 928 // device.kidlisp.com/CODE → aesthetic.computer/CODE 929 function tvSourceUrl(item) { 930 const src = item.source || item.url || ''; 931 const m = src.match(/device\.kidlisp\.com\/([^?/]+)/); 932 if (m) return `https://aesthetic.computer/${m[1]}`; 933 return src; 934 } 935 936 function tvLoad(playlist) { 937 tv.items = (playlist.items || []).slice(); 938 tv.index = 0; 939 tv.playlistId = playlist.id; 940 tv.playing = true; 941 // highlight active chip 942 document.querySelectorAll('.tv-playlist-chip').forEach(c => { 943 c.classList.toggle('active', c.dataset.id === playlist.id); 944 }); 945 tvShow(); 946 document.getElementById('tv-wrapper').style.display = ''; 947 } 948 949 function tvShow() { 950 if (!tv.items.length) return; 951 const item = tv.items[tv.index]; 952 const iframe = document.getElementById('tv-iframe'); 953 tvStopTimer(); 954 tv.elapsed = 0; 955 tv.pieceReady = false; 956 tvUpdateProgress(); 957 document.getElementById('tv-now-playing').textContent = item.title || ''; 958 document.getElementById('tv-index').textContent = 959 `${tv.index + 1} / ${tv.items.length}`; 960 // Dim iframe until piece is ready (boot-log 'ready:' via postMessage) 961 iframe.style.opacity = '0.15'; 962 // Fallback: if no ready signal in 8s, start anyway 963 if (tv.readyFallback) clearTimeout(tv.readyFallback); 964 tv.readyFallback = setTimeout(() => { 965 if (!tv.pieceReady) { 966 tv.pieceReady = true; 967 iframe.style.opacity = '1'; 968 if (tv.playing) tvStartTimer(); 969 } 970 }, 8000); 971 iframe.src = tvSourceUrl(item); 972 } 973 974 function tvStartTimer() { 975 tvStopTimer(); 976 if (!tv.playing) return; 977 const duration = tv.items[tv.index]?.duration || 8; 978 tv.elapsed = 0; 979 tv.tick = setInterval(() => { 980 tv.elapsed += 0.25; 981 tvUpdateProgress(); 982 if (tv.elapsed >= duration) { 983 tvNext(); 984 } 985 }, 250); 986 } 987 988 function tvStopTimer() { 989 if (tv.tick) { clearInterval(tv.tick); tv.tick = null; } 990 } 991 992 function tvUpdateProgress() { 993 const duration = tv.items[tv.index]?.duration || 8; 994 const pct = Math.min(100, (tv.elapsed / duration) * 100); 995 document.getElementById('tv-progress-bar').style.width = pct + '%'; 996 } 997 998 function tvNext() { 999 tv.index = (tv.index + 1) % tv.items.length; 1000 tvShow(); 1001 } 1002 1003 function tvPrev() { 1004 tv.index = (tv.index - 1 + tv.items.length) % tv.items.length; 1005 tvShow(); 1006 } 1007 1008 function tvToggle() { 1009 tv.playing = !tv.playing; 1010 document.getElementById('tv-playpause').innerHTML = 1011 tv.playing ? '&#9646;&#9646;' : '&#9654;'; 1012 document.getElementById('tv-playpause').classList.toggle('active', !tv.playing); 1013 if (tv.playing) tvStartTimer(); else tvStopTimer(); 1014 } 1015 1016 document.getElementById('tv-prev').addEventListener('click', tvPrev); 1017 document.getElementById('tv-next').addEventListener('click', tvNext); 1018 document.getElementById('tv-playpause').addEventListener('click', tvToggle); 1019 document.getElementById('tv-progress').addEventListener('click', (e) => { 1020 const rect = e.currentTarget.getBoundingClientRect(); 1021 const pct = (e.clientX - rect.left) / rect.width; 1022 const duration = tv.items[tv.index]?.duration || 8; 1023 tv.elapsed = pct * duration; 1024 tvUpdateProgress(); 1025 }); 1026 1027 // Build playlist picker chips after data loads 1028 function tvBuildPicker() { 1029 const picker = document.getElementById('tv-playlist-picker'); 1030 picker.innerHTML = tv.allPlaylists.map(p => { 1031 const icon = plIcon(p.title); 1032 return `<button class="tv-playlist-chip${p.id === tv.playlistId ? ' active' : ''}" 1033 data-id="${p.id}" onclick="tvSwitchPlaylist('${p.id}')">${icon} ${esc(p.title)}</button>`; 1034 }).join(''); 1035 } 1036 1037 function tvSwitchPlaylist(id) { 1038 const pl = tv.allPlaylists.find(p => p.id === id); 1039 if (pl) tvLoad(pl); 1040 } 1041 1042 load(); 1043 </script> 1044</body> 1045</html>