Monorepo for Aesthetic.Computer
aesthetic.computer
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">◀</button>
609 <button class="tv-btn" id="tv-playpause" title="Play/Pause">▮▮</button>
610 <button class="tv-btn" id="tv-next" title="Next">▶</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 · display protocol · 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 · <a href="https://aesthetic.computer" onclick="event.preventDefault(); openModal(this.href)">aesthetic.computer</a>
671 · <a href="https://kidlisp.com" onclick="event.preventDefault(); openModal(this.href)">kidlisp</a>
672 · <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 · ${chPls.length} playlists · ${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">▶</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 ? '▮▮' : '▶';
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>