personal memory agent
1{# Transcript viewer - dual-timeline interface #}
2
3<style>
4/* Transcripts app styles - all classes prefixed with .tr- to avoid conflicts */
5
6/*
7 * Layout context:
8 * - date_nav: true adds has-date-nav to body, which adds date-nav-height to workspace margin-top
9 * - Need to account for facet-bar + date-nav at top
10 *
11 * Height calculation:
12 * - 100vh (viewport)
13 * - minus 60px (facet-bar-height)
14 * - minus 40px (date-nav-height)
15 */
16
17/* Lock workspace scrolling - content must fit, only .tr-panel scrolls */
18body.has-date-nav .workspace:has(.tr-wrap) {
19 overflow: hidden;
20 height: calc(100vh - var(--facet-bar-height) - var(--date-nav-height));
21}
22
23/* Main container */
24.tr-wrap {
25 max-width: 1400px;
26 margin: 0 auto;
27 padding: 16px 24px;
28 box-sizing: border-box;
29 height: 100%;
30 overflow: hidden;
31}
32
33.tr-card {
34 height: 100%;
35 background: #ffffff;
36 display: grid;
37 grid-template-columns: 180px 100px 1fr;
38 overflow: hidden;
39}
40
41/* Left timeline */
42.tr-timeline {
43 position: relative;
44 border-right: 1px solid #e5e7eb;
45 user-select: none;
46 height: 100%;
47 overflow: hidden;
48 touch-action: pan-y;
49 padding: 12px 0;
50 box-sizing: border-box;
51}
52
53.tr-timeline-label {
54 position: absolute;
55 top: 0;
56 left: 0;
57 right: 0;
58 height: 12px;
59 display: flex;
60 align-items: center;
61 justify-content: center;
62 font-size: 12px;
63 color: #9ca3af;
64 text-transform: uppercase;
65 letter-spacing: 0.05em;
66 pointer-events: none;
67 z-index: 0;
68}
69
70.tr-timeline-legend {
71 position: absolute;
72 bottom: 0;
73 left: 0;
74 right: 0;
75 height: 12px;
76 display: flex;
77 align-items: center;
78 justify-content: center;
79 gap: 12px;
80 font-size: 12px;
81 color: #9ca3af;
82 pointer-events: none;
83 z-index: 0;
84}
85
86.tr-legend-dot {
87 display: inline-block;
88 width: 8px;
89 height: 8px;
90 border-radius: 50%;
91 margin-right: 4px;
92 vertical-align: middle;
93}
94
95.tr-grid {
96 position: absolute;
97 top: 12px;
98 bottom: 12px;
99 left: 0;
100 right: 0;
101}
102
103.tr-grid-hour {
104 position: absolute;
105 left: 0;
106 right: 0;
107 border-top: 1px solid #e5e7eb;
108}
109
110.tr-grid-quarter {
111 position: absolute;
112 left: 0;
113 right: 0;
114 border-top: 1px dashed #f3f4f6;
115}
116
117.tr-labels {
118 position: absolute;
119 left: 0;
120 top: 12px;
121 bottom: 12px;
122 width: 64px;
123 background: #f9fafb;
124 border-right: 1px solid #e5e7eb;
125 pointer-events: none;
126}
127
128.tr-label {
129 position: absolute;
130 right: 0;
131 padding-right: 8px;
132 transform: translateY(-50%);
133 font-size: 12px;
134 color: #6b7280;
135}
136
137/* Selection box */
138.tr-sel-wrap {
139 position: absolute;
140 left: 58px;
141 right: 8px;
142 pointer-events: none;
143 z-index: 2;
144}
145
146.tr-sel {
147 position: absolute;
148 left: -4px;
149 right: -4px;
150 height: 100%;
151 background: rgba(239, 246, 255, 0.7);
152 border: 1px solid #c7d2fe;
153 border-radius: 12px;
154 box-shadow: 0 1px 2px rgba(0,0,0,.06);
155 pointer-events: auto;
156 cursor: grab;
157 touch-action: none;
158}
159
160.tr-sel:active {
161 cursor: grabbing;
162}
163
164.tr-bumper {
165 position: absolute;
166 left: 40px;
167 right: 40px;
168 margin: auto;
169 height: 14px;
170 border: 1px solid #60a5fa;
171 border-radius: 999px;
172 background: #dbeafe;
173 cursor: ns-resize;
174 touch-action: none;
175}
176
177.tr-bumper-top {
178 top: -7px;
179}
180
181.tr-bumper-bottom {
182 bottom: -7px;
183}
184
185/* Segments lane (audio/screen indicators) */
186.tr-segments {
187 position: absolute;
188 left: 64px;
189 right: 0;
190 top: 12px;
191 bottom: 12px;
192 pointer-events: none;
193 z-index: 1;
194}
195
196.tr-seg {
197 position: absolute;
198 width: 40px;
199 border-radius: 12px;
200 box-shadow: 0 1px 1px rgba(0,0,0,.04);
201 pointer-events: auto;
202 cursor: pointer;
203}
204
205.tr-seg:focus-visible {
206 outline: 2px solid var(--accent, #4a9eff);
207 outline-offset: 1px;
208}
209
210.tr-seg-audio {
211 background: rgba(134, 239, 172, 0.6);
212 border: 1px solid #86efac;
213}
214
215.tr-seg-screen {
216 background: rgba(253, 230, 138, 0.6);
217 border: 1px solid #facc15;
218}
219
220.tr-now-marker {
221 position: absolute;
222 left: 64px;
223 right: 0;
224 height: 0;
225 border-top: 2px solid #ef4444;
226 z-index: 3;
227 pointer-events: none;
228}
229
230.tr-now-label {
231 position: absolute;
232 right: 4px;
233 top: -8px;
234 font-size: 12px;
235 color: #ef4444;
236 font-weight: 500;
237 line-height: 1;
238}
239
240/* Right content panel */
241.tr-content {
242 padding: 24px;
243 display: flex;
244 flex-direction: column;
245 gap: 16px;
246 height: 100%;
247 box-sizing: border-box;
248 overflow: hidden;
249}
250
251.tr-header {
252 display: flex;
253 align-items: center;
254 justify-content: space-between;
255 flex-wrap: wrap;
256 gap: 12px;
257}
258
259.tr-title {
260 font-size: 20px;
261 font-weight: 600;
262 margin: 0;
263}
264
265.tr-range-text {
266 color: #6b7280;
267 font-size: 14px;
268}
269
270.tr-nav-hint {
271 font-size: 12px;
272 color: #9ca3af;
273 margin-left: 8px;
274 display: none;
275}
276
277.tr-nav-hint.visible {
278 display: inline;
279}
280
281.tr-tabs {
282 gap: 8px;
283 padding: 8px 16px;
284 border-bottom: 1px solid #e5e7eb;
285 flex-shrink: 0;
286 display: none;
287}
288
289.tr-tabs.visible {
290 display: flex;
291}
292
293.tr-tab {
294 padding: 6px 14px;
295 border: 1px solid #d1d5db;
296 border-radius: 6px;
297 font-size: 13px;
298 cursor: pointer;
299 background: #fff;
300 color: #374151;
301 transition: all 0.15s;
302}
303
304.tr-tab:hover {
305 background: #f9fafb;
306}
307
308.tr-tab.active {
309 background: #3b82f6;
310 border-color: #3b82f6;
311 color: #fff;
312}
313
314.tr-tab:focus-visible {
315 outline: 2px solid var(--accent, #4a9eff);
316 outline-offset: 1px;
317}
318
319.tr-tab-pane {
320 display: none;
321 height: 100%;
322}
323
324.tr-tab-pane.active {
325 display: block;
326}
327
328.tr-md-content {
329 padding: 16px;
330 line-height: 1.6;
331 font-size: 14px;
332}
333
334.tr-md-content h1, .tr-md-content h2, .tr-md-content h3 {
335 margin-top: 16px;
336 margin-bottom: 8px;
337}
338
339.tr-md-content p {
340 margin-bottom: 12px;
341}
342
343.tr-md-content ul, .tr-md-content ol {
344 margin-bottom: 12px;
345 padding-left: 24px;
346}
347
348.tr-md-content code {
349 background: #f3f4f6;
350 padding: 2px 6px;
351 border-radius: 4px;
352 font-size: 13px;
353}
354
355.tr-md-content pre {
356 background: #f3f4f6;
357 padding: 12px;
358 border-radius: 6px;
359 overflow-x: auto;
360 margin-bottom: 12px;
361}
362
363.tr-screen-text {
364 padding: 8px 12px;
365 color: #6b7280;
366 font-size: 13px;
367 border-left: 3px solid #e5e7eb;
368 margin: 4px 0;
369}
370
371/* Delete button */
372.tr-delete-btn {
373 display: none;
374 align-items: center;
375 justify-content: center;
376 width: 32px;
377 height: 32px;
378 padding: 0;
379 border: 1px solid #e5e7eb;
380 border-radius: 6px;
381 background: #fff;
382 color: #9ca3af;
383 cursor: pointer;
384 transition: all 0.15s;
385 margin-left: 8px;
386}
387
388.tr-delete-btn:hover {
389 background: #fef2f2;
390 border-color: #fecaca;
391 color: #ef4444;
392}
393
394.tr-delete-btn.visible {
395 display: flex;
396}
397
398.tr-delete-btn svg {
399 width: 16px;
400 height: 16px;
401}
402
403.tr-panel {
404 flex: 1;
405 border: 1px solid #e5e7eb;
406 border-radius: 16px;
407 padding: 16px;
408 overflow-y: auto;
409 min-height: 0;
410 overflow-x: hidden;
411 font-size: 14px;
412 line-height: 1.5;
413}
414
415.tr-panel pre {
416 white-space: pre-wrap;
417 word-wrap: break-word;
418 overflow-wrap: break-word;
419}
420
421.tr-panel code {
422 white-space: pre-wrap;
423 word-wrap: break-word;
424}
425
426/* Middle zoom timeline (segment selector) */
427.tr-zoom {
428 position: relative;
429 border-right: 1px solid #e5e7eb;
430 user-select: none;
431 height: 100%;
432 overflow: hidden;
433 padding: 12px 0;
434 box-sizing: border-box;
435}
436
437.tr-zoom-timeline-label {
438 position: absolute;
439 top: 0;
440 left: 0;
441 right: 0;
442 height: 12px;
443 display: flex;
444 align-items: center;
445 justify-content: center;
446 font-size: 12px;
447 color: #9ca3af;
448 text-transform: uppercase;
449 letter-spacing: 0.05em;
450 pointer-events: none;
451 z-index: 0;
452}
453
454.tr-zoom-labels {
455 position: absolute;
456 left: 0;
457 top: 12px;
458 bottom: 12px;
459 width: 40px;
460 background: #f9fafb;
461 border-right: 1px solid #e5e7eb;
462 pointer-events: none;
463}
464
465.tr-zoom-label {
466 position: absolute;
467 right: 0;
468 padding-right: 6px;
469 transform: translateY(-50%);
470 font-size: 12px;
471 color: #6b7280;
472}
473
474.tr-zoom-grid {
475 position: absolute;
476 top: 12px;
477 bottom: 12px;
478 left: 0;
479 right: 0;
480}
481
482.tr-zoom-segments {
483 position: absolute;
484 left: 44px;
485 right: 4px;
486 top: 12px;
487 bottom: 12px;
488}
489
490/* Segment pills in zoom view */
491.tr-zoom-pill {
492 position: absolute;
493 left: 0;
494 right: 0;
495 border-radius: 8px;
496 cursor: pointer;
497 transition: all 0.15s ease;
498 box-shadow: 0 1px 2px rgba(0,0,0,.08);
499}
500
501.tr-zoom-pill:hover {
502 filter: brightness(0.95);
503 box-shadow: 0 2px 4px rgba(0,0,0,.12);
504}
505
506.tr-zoom-pill.tr-active {
507 box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.5), 0 2px 4px rgba(0,0,0,.12);
508}
509
510.tr-zoom-pill:focus-visible {
511 outline: 2px solid var(--accent, #4a9eff);
512 outline-offset: 1px;
513}
514
515.tr-zoom-pill-audio {
516 background: linear-gradient(135deg, rgba(134, 239, 172, 0.8), rgba(134, 239, 172, 0.6));
517 border: 1px solid #86efac;
518}
519
520.tr-zoom-pill-screen {
521 background: linear-gradient(135deg, rgba(253, 230, 138, 0.8), rgba(253, 230, 138, 0.6));
522 border: 1px solid #facc15;
523}
524
525.tr-zoom-pill-both {
526 background: linear-gradient(to right, rgba(134, 239, 172, 0.75), rgba(194, 234, 155, 0.7) 50%, rgba(253, 230, 138, 0.75));
527 border: 1px solid #bef264;
528}
529
530.tr-zoom-empty {
531 position: absolute;
532 inset: 0;
533 display: flex;
534 align-items: center;
535 justify-content: center;
536 color: #9ca3af;
537 font-size: 12px;
538 text-align: center;
539 padding: 16px;
540}
541
542/* Dragging state */
543.tr-dragging {
544 user-select: none;
545 -webkit-user-select: none;
546}
547
548/* Image modal */
549.tr-screenshot-modal {
550 position: fixed;
551 inset: 0;
552 background: rgba(0,0,0,0.85);
553 display: flex;
554 z-index: 1000;
555}
556
557.tr-modal-nav {
558 width: 48px;
559 display: flex;
560 align-items: center;
561 justify-content: center;
562 cursor: pointer;
563 color: rgba(255,255,255,0.4);
564 transition: all 0.15s;
565 flex-shrink: 0;
566}
567
568.tr-modal-nav:hover {
569 background: rgba(255,255,255,0.1);
570 color: rgba(255,255,255,0.9);
571}
572
573.tr-modal-nav.disabled {
574 cursor: default;
575 color: rgba(255,255,255,0.15);
576 pointer-events: none;
577}
578
579.tr-modal-nav svg {
580 width: 24px;
581 height: 24px;
582}
583
584.tr-modal-center {
585 flex: 1;
586 display: flex;
587 flex-direction: column;
588 min-width: 0;
589}
590
591.tr-modal-header {
592 display: flex;
593 align-items: center;
594 gap: 12px;
595 padding: 16px 20px;
596 flex-shrink: 0;
597}
598
599.tr-modal-badge {
600 padding: 4px 10px;
601 border-radius: 6px;
602 font-size: 12px;
603 font-weight: 500;
604 background: rgba(255,255,255,0.15);
605 color: #fff;
606}
607
608.tr-modal-badge-monitor {
609 background: rgba(124, 58, 237, 0.3);
610 color: #c4b5fd;
611}
612
613.tr-modal-badge-category {
614 background: rgba(59, 130, 246, 0.3);
615 color: #93c5fd;
616}
617
618.tr-modal-close {
619 margin-left: auto;
620 width: 36px;
621 height: 36px;
622 background: rgba(255,255,255,0.15);
623 border: none;
624 border-radius: 50%;
625 color: #fff;
626 font-size: 18px;
627 cursor: pointer;
628 display: flex;
629 align-items: center;
630 justify-content: center;
631 flex-shrink: 0;
632}
633
634.tr-modal-close:hover {
635 background: rgba(255,255,255,0.3);
636}
637
638.tr-modal-img-wrap {
639 flex: 1;
640 display: flex;
641 align-items: center;
642 justify-content: center;
643 min-height: 0;
644 padding: 0 20px;
645}
646
647.tr-modal-img-wrap img,
648.tr-modal-img-wrap canvas {
649 display: block;
650 max-width: 100%;
651 max-height: 100%;
652 object-fit: contain;
653 border-radius: 8px;
654 background: #1f2937;
655}
656
657.tr-modal-img-wrap canvas.loading {
658 animation: tr-pulse 1.5s ease-in-out infinite;
659}
660
661.tr-modal-img-wrap canvas.tr-masked-canvas {
662 cursor: pointer;
663}
664
665.tr-modal-badge-masked {
666 background: rgba(239, 68, 68, 0.3);
667 color: #fca5a5;
668}
669
670.tr-modal-description {
671 padding: 12px 20px 20px;
672 color: rgba(255,255,255,0.8);
673 font-size: 14px;
674 line-height: 1.5;
675 text-align: center;
676 flex-shrink: 0;
677}
678
679/* Unified timeline view */
680.tr-unified {
681 display: flex;
682 flex-direction: column;
683 gap: 12px;
684}
685
686.tr-audio-players {
687 display: flex;
688 flex-wrap: wrap;
689 gap: 12px;
690 padding: 12px;
691 background: #f9fafb;
692 border-radius: 12px;
693 border: 1px solid #e5e7eb;
694}
695
696.tr-audio-player {
697 flex: 1;
698 min-width: 200px;
699}
700
701.tr-audio-player audio {
702 width: 100%;
703}
704
705.tr-audio-player-label {
706 font-size: 12px;
707 color: #6b7280;
708 margin-bottom: 4px;
709}
710
711.tr-purge-notice {
712 padding: 0.5em 0.75em;
713 margin-bottom: 0.5em;
714 font-size: 0.8em;
715 color: #888;
716 background: #f8f8f8;
717 border-radius: 4px;
718 border-left: 3px solid #ccc;
719}
720
721.tr-entry {
722 display: flex;
723 gap: 12px;
724 padding: 10px 12px;
725 border-radius: 8px;
726 cursor: pointer;
727 transition: background 0.15s;
728}
729
730.tr-entry:hover {
731 background: #f9fafb;
732}
733
734.tr-entry-audio {
735 border-left: 3px solid #86efac;
736}
737
738.tr-entry-audio:focus-visible {
739 outline: 2px solid var(--accent, #4a9eff);
740 outline-offset: -1px;
741}
742
743.tr-entry.tr-entry-active {
744 background: #f0f9ff;
745 border-left-color: #3b82f6;
746}
747
748.tr-entry-screen {
749 border-left: 3px solid #facc15;
750}
751
752.tr-entry-time {
753 flex-shrink: 0;
754 width: 48px;
755 font-size: 12px;
756 color: #6b7280;
757 font-family: monospace;
758}
759
760.tr-entry-content {
761 flex: 1;
762 min-width: 0;
763}
764
765.tr-entry-text {
766 font-size: 14px;
767 line-height: 1.4;
768}
769
770.tr-entry-meta {
771 font-size: 11px;
772 color: #9ca3af;
773 margin-top: 2px;
774}
775
776.tr-entry-thumb {
777 flex-shrink: 0;
778 width: 120px;
779 height: 68px;
780 border-radius: 6px;
781 object-fit: cover;
782 border: 1px solid #e5e7eb;
783 background: #f3f4f6;
784}
785
786.tr-entry-thumb.loading {
787 animation: tr-pulse 1.5s ease-in-out infinite;
788}
789
790@keyframes tr-pulse {
791 0%, 100% { opacity: 0.6; }
792 50% { opacity: 1; }
793}
794
795.tr-entry-screen .tr-entry-content {
796 display: flex;
797 gap: 12px;
798 align-items: flex-start;
799}
800
801.tr-entry-desc {
802 flex: 1;
803 font-size: 13px;
804 color: #374151;
805 line-height: 1.4;
806}
807
808.tr-entry-badge {
809 display: inline-block;
810 padding: 2px 6px;
811 border-radius: 4px;
812 font-size: 12px;
813 font-weight: 500;
814 background: #dbeafe;
815 color: #1d4ed8;
816 margin-right: 6px;
817}
818
819.tr-entry-badge-monitor {
820 background: #f3e8ff;
821 color: #7c3aed;
822}
823
824.tr-speaker-label {
825 display: inline-flex;
826 align-items: center;
827 gap: 4px;
828 font-size: 12px;
829 font-weight: 500;
830 color: #6b7280;
831 margin-bottom: 2px;
832}
833
834.tr-speaker-label a {
835 color: #6b7280;
836 text-decoration: none;
837}
838
839.tr-speaker-label a:hover {
840 color: #374151;
841 text-decoration: underline;
842}
843
844.tr-speaker-label-owner {
845 color: #4f46e5;
846}
847
848.tr-speaker-label-owner a {
849 color: #4f46e5;
850}
851
852.tr-speaker-dot {
853 display: inline-block;
854 width: 6px;
855 height: 6px;
856 border-radius: 50%;
857}
858
859.tr-speaker-dot-high {
860 background: #22c55e;
861}
862
863.tr-speaker-dot-medium {
864 background: #eab308;
865}
866
867.tr-unified-empty {
868 text-align: center;
869 color: #9ca3af;
870 padding: 48px 24px;
871}
872
873.tr-empty-state {
874 display: flex;
875 flex-direction: column;
876 align-items: center;
877 justify-content: center;
878 height: 100%;
879 text-align: center;
880 color: #9ca3af;
881 padding: 48px 24px;
882 gap: 12px;
883}
884
885.tr-empty-icon svg {
886 width: 48px;
887 height: 48px;
888 stroke: #d1d5db;
889 fill: none;
890 stroke-width: 1.5;
891 stroke-linecap: round;
892 stroke-linejoin: round;
893}
894
895.tr-empty-heading {
896 font-size: 16px;
897 font-weight: 500;
898 color: #6b7280;
899 margin: 0;
900}
901
902.tr-empty-desc {
903 font-size: 14px;
904 color: #9ca3af;
905 margin: 0;
906}
907
908.tr-warning-notice {
909 display: none;
910 align-items: center;
911 gap: 8px;
912 padding: 8px 12px;
913 font-size: 13px;
914 color: #92400e;
915 background: #fffbeb;
916 border: 1px solid #fde68a;
917 border-radius: 8px;
918}
919
920.tr-warning-notice.visible {
921 display: flex;
922}
923
924.tr-warning-notice svg {
925 width: 16px;
926 height: 16px;
927 flex-shrink: 0;
928 stroke: #f59e0b;
929 fill: none;
930 stroke-width: 2;
931 stroke-linecap: round;
932 stroke-linejoin: round;
933}
934
935/* Markdown content styling within screen entries */
936.tr-entry-desc h3 {
937 display: none; /* Hide timestamp header - already shown in time column */
938}
939
940.tr-entry-desc p {
941 margin: 4px 0;
942}
943
944.tr-entry-desc strong {
945 color: #1f2937;
946}
947
948.tr-entry-desc pre {
949 background: #f3f4f6;
950 border: 1px solid #e5e7eb;
951 border-radius: 6px;
952 padding: 8px 12px;
953 margin: 8px 0;
954 overflow-x: auto;
955 font-size: 12px;
956}
957
958.tr-entry-desc code {
959 font-family: ui-monospace, monospace;
960 font-size: 12px;
961}
962
963.tr-entry-desc pre code {
964 background: none;
965 padding: 0;
966}
967
968/* Basic frame groups - collapsed by default */
969.tr-group {
970 border-left: 3px solid #e5e7eb;
971 border-radius: 8px;
972 margin: 4px 0;
973 background: #fafafa;
974}
975
976.tr-group-header {
977 display: flex;
978 align-items: center;
979 gap: 12px;
980 padding: 8px 12px;
981 cursor: pointer;
982 user-select: none;
983}
984
985.tr-group-header:hover {
986 background: #f3f4f6;
987}
988
989.tr-group-header:focus-visible {
990 outline: 2px solid var(--accent, #4a9eff);
991 outline-offset: -1px;
992}
993
994.tr-group-chevron {
995 width: 16px;
996 height: 16px;
997 color: #9ca3af;
998 transition: transform 0.15s;
999 flex-shrink: 0;
1000}
1001
1002.tr-group.expanded .tr-group-chevron {
1003 transform: rotate(90deg);
1004}
1005
1006.tr-group-time {
1007 font-size: 12px;
1008 color: #6b7280;
1009 font-family: monospace;
1010 flex-shrink: 0;
1011}
1012
1013.tr-group-count {
1014 font-size: 12px;
1015 color: #9ca3af;
1016}
1017
1018.tr-group-grid {
1019 display: none;
1020 padding: 8px 12px 12px;
1021 gap: 8px;
1022 grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
1023}
1024
1025.tr-group.expanded .tr-group-grid {
1026 display: grid;
1027}
1028
1029.tr-group-item {
1030 position: relative;
1031 cursor: pointer;
1032 border-radius: 6px;
1033 overflow: hidden;
1034 border: 1px solid #e5e7eb;
1035 transition: box-shadow 0.15s;
1036}
1037
1038.tr-group-item:hover {
1039 box-shadow: 0 2px 8px rgba(0,0,0,0.12);
1040}
1041
1042.tr-group-item img,
1043.tr-group-item canvas {
1044 width: 100%;
1045 aspect-ratio: 16/9;
1046 object-fit: cover;
1047 display: block;
1048 background: #f3f4f6;
1049}
1050
1051.tr-group-item canvas.loading {
1052 animation: tr-pulse 1.5s ease-in-out infinite;
1053}
1054
1055.tr-group-item-badge {
1056 position: absolute;
1057 bottom: 4px;
1058 left: 4px;
1059 padding: 2px 6px;
1060 border-radius: 4px;
1061 font-size: 12px;
1062 font-weight: 500;
1063 background: rgba(0,0,0,0.6);
1064 color: #fff;
1065 max-width: calc(100% - 8px);
1066 overflow: hidden;
1067 text-overflow: ellipsis;
1068 white-space: nowrap;
1069}
1070
1071.sr-only {
1072 position: absolute;
1073 width: 1px;
1074 height: 1px;
1075 padding: 0;
1076 margin: -1px;
1077 overflow: hidden;
1078 clip: rect(0, 0, 0, 0);
1079 white-space: nowrap;
1080 border: 0;
1081}
1082</style>
1083
1084<div class="tr-wrap">
1085 <div class="tr-card">
1086 <!-- Left timeline -->
1087 <div id="trTimeline" class="tr-timeline" aria-label="Day timeline">
1088 <div class="tr-timeline-label">day</div>
1089 <div class="tr-grid" id="trGrid"></div>
1090 <div class="tr-labels" id="trLabels"></div>
1091 <div class="tr-segments" id="trSegments"></div>
1092 <div class="tr-sel-wrap" id="trSelWrap">
1093 <div class="tr-sel" data-handle="move">
1094 <div class="tr-bumper tr-bumper-top" data-handle="start" title="Drag to adjust start"></div>
1095 <div class="tr-bumper tr-bumper-bottom" data-handle="end" title="Drag to adjust end"></div>
1096 </div>
1097 </div>
1098 <div class="tr-timeline-legend">
1099 <span><span class="tr-legend-dot" style="background: #86efac;"></span>audio</span>
1100 <span><span class="tr-legend-dot" style="background: #facc15;"></span>screen</span>
1101 </div>
1102 </div>
1103
1104 <!-- Middle zoom timeline -->
1105 <div class="tr-zoom" id="trZoom">
1106 <div class="tr-zoom-timeline-label">detail</div>
1107 <div class="tr-zoom-grid" id="trZoomGrid"></div>
1108 <div class="tr-zoom-labels" id="trZoomLabels"></div>
1109 <div class="tr-zoom-segments" id="trZoomSegments"></div>
1110 </div>
1111
1112 <!-- Right content panel -->
1113 <div class="tr-content">
1114 <div class="tr-header">
1115 <div>
1116 <h2 class="tr-title">Transcripts</h2>
1117 <div class="tr-range-text" id="trRangeText"></div>
1118 <span class="tr-nav-hint" id="trNavHint">[ ] to navigate</span>
1119 </div>
1120 <button type="button" id="trDeleteBtn" class="tr-delete-btn" title="Delete segment">
1121 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1122 <polyline points="3 6 5 6 21 6"></polyline>
1123 <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
1124 </svg>
1125 </button>
1126 </div>
1127 <div class="tr-tabs" id="trTabs"></div>
1128 <div id="trWarningNotice" class="tr-warning-notice">
1129 <svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
1130 <span id="trWarningText"></span>
1131 </div>
1132 <div class="tr-panel" id="trPanel"></div>
1133 </div>
1134 </div>
1135</div>
1136
1137<script src="{{ vendor_lib('marked') }}"></script>
1138<script>
1139(() => {
1140 // Timeline bounds - computed dynamically from content
1141 const DEFAULT_START = 8 * 60; // 8:00 AM default
1142 const DEFAULT_END = 20 * 60; // 8:00 PM default
1143 const MIN_SPAN = 12 * 60; // Minimum 12-hour span
1144 const BUFFER = 30; // 30-minute buffer on each side
1145
1146 let timelineStart = DEFAULT_START;
1147 let timelineEnd = DEFAULT_END;
1148
1149 const STEP = 15;
1150 const DEFAULT_LEN = 60;
1151 const MIN_LEN = 15;
1152
1153 const day = '{{ day }}';
1154
1155 // Elements - main timeline
1156 const timeline = document.getElementById('trTimeline');
1157 const grid = document.getElementById('trGrid');
1158 const labels = document.getElementById('trLabels');
1159 const segmentsLane = document.getElementById('trSegments');
1160 const selWrap = document.getElementById('trSelWrap');
1161 const sel = selWrap.querySelector('.tr-sel');
1162
1163 // Elements - zoom timeline
1164 const zoom = document.getElementById('trZoom');
1165 const zoomGrid = document.getElementById('trZoomGrid');
1166 const zoomLabels = document.getElementById('trZoomLabels');
1167 const zoomSegments = document.getElementById('trZoomSegments');
1168
1169 // Elements - content panel
1170 const titleEl = document.querySelector('.tr-title');
1171 const rangeText = document.getElementById('trRangeText');
1172 const tabsContainer = document.getElementById('trTabs');
1173 tabsContainer.addEventListener('keydown', e => {
1174 if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
1175 e.preventDefault();
1176 const tabs = [...tabsContainer.querySelectorAll('.tr-tab')];
1177 const idx = tabs.indexOf(e.target);
1178 if (idx < 0) return;
1179 const next = e.key === 'ArrowRight'
1180 ? tabs[(idx + 1) % tabs.length]
1181 : tabs[(idx - 1 + tabs.length) % tabs.length];
1182 activateTab(next.dataset.tab);
1183 next.focus();
1184 });
1185 const panel = document.getElementById('trPanel');
1186 const deleteBtn = document.getElementById('trDeleteBtn');
1187 const emptyIcons = {
1188 day: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>',
1189 nothing: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>',
1190 transcript: '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>',
1191 audio: '<svg viewBox="0 0 24 24"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon><path d="M19.07 4.93a10 10 0 0 1 0 14.14"></path><path d="M15.54 8.46a5 5 0 0 1 0 7.07"></path></svg>',
1192 screen: '<svg viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect><line x1="8" y1="21" x2="16" y2="21"></line><line x1="12" y1="17" x2="12" y2="21"></line></svg>'
1193 };
1194
1195 function emptyStateHTML(icon, heading, desc) {
1196 return '<div class="tr-empty-state">' +
1197 '<div class="tr-empty-icon">' + icon + '</div>' +
1198 '<p class="tr-empty-heading">' + heading + '</p>' +
1199 '<p class="tr-empty-desc">' + desc + '</p>' +
1200 '</div>';
1201 }
1202
1203 panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript');
1204
1205 // State
1206 let height = timeline.clientHeight;
1207 let ppm = height / (timelineEnd - timelineStart);
1208 let range = { start: 9 * 60, end: 10 * 60 };
1209 let drag = null;
1210 let allSegments = [];
1211 let selectedSegment = null;
1212 let updateNowPosition = null;
1213
1214 // Zoom state
1215 let zoomHeight = zoom.clientHeight;
1216 let zoomPpm = 0;
1217
1218 // Modal state - all screen frames for navigation
1219 let allScreenFrames = [];
1220 let currentFrameIndex = -1;
1221
1222 // ========================================
1223 // FrameCapture - Client-side thumbnail + on-demand full frame decoder
1224 // ========================================
1225 class FrameCapture {
1226 constructor() {
1227 // Map of video URL -> { video, ready, width, height, thumbs }
1228 this.videos = new Map();
1229 // Pending thumbnail promises per frame
1230 this.pendingThumbs = new Map();
1231 }
1232
1233 // Load video and wait for metadata
1234 loadVideo(url) {
1235 if (this.videos.has(url)) {
1236 const entry = this.videos.get(url);
1237 if (entry.ready) return Promise.resolve(entry);
1238 return entry.promise;
1239 }
1240
1241 const video = document.createElement('video');
1242 video.preload = 'metadata';
1243 video.muted = true;
1244 video.playsInline = true;
1245 video.crossOrigin = 'anonymous';
1246
1247 const promise = new Promise((resolve, reject) => {
1248 video.onloadedmetadata = () => {
1249 const entry = this.videos.get(url);
1250 entry.ready = true;
1251 entry.width = video.videoWidth;
1252 entry.height = video.videoHeight;
1253 resolve(entry);
1254 };
1255 video.onerror = () => reject(new Error(`Failed to load video: ${url}`));
1256 video.src = url;
1257 });
1258
1259 this.videos.set(url, {
1260 video,
1261 ready: false,
1262 width: 0,
1263 height: 0,
1264 promise,
1265 thumbs: new Map(),
1266 queue: Promise.resolve()
1267 });
1268 return promise;
1269 }
1270
1271 async _withVideoQueue(videoUrl, task) {
1272 const entry = await this.loadVideo(videoUrl);
1273 const run = entry.queue.then(task, task);
1274 entry.queue = run.catch(() => {});
1275 return run;
1276 }
1277
1278 async _seekTo(video, timestamp) {
1279 return new Promise((resolve, reject) => {
1280 const onSeeked = () => {
1281 video.removeEventListener('seeked', onSeeked);
1282 video.removeEventListener('error', onError);
1283 resolve();
1284 };
1285 const onError = () => {
1286 video.removeEventListener('seeked', onSeeked);
1287 video.removeEventListener('error', onError);
1288 reject(new Error('Video seek failed'));
1289 };
1290 video.addEventListener('seeked', onSeeked);
1291 video.addEventListener('error', onError);
1292 video.currentTime = timestamp;
1293 });
1294 }
1295
1296 async captureThumbnail(videoUrl, frameId, width, height) {
1297 const entry = await this.loadVideo(videoUrl);
1298 const cacheKey = `${videoUrl}|${frameId}`;
1299
1300 if (entry.thumbs.has(frameId)) {
1301 return entry.thumbs.get(frameId);
1302 }
1303
1304 if (this.pendingThumbs.has(cacheKey)) {
1305 return this.pendingThumbs.get(cacheKey);
1306 }
1307
1308 const pending = this._withVideoQueue(videoUrl, async () => {
1309 const video = entry.video;
1310 const timestamp = Math.max(0, frameId - 1);
1311 await this._seekTo(video, timestamp);
1312
1313 let bitmap = null;
1314 try {
1315 if (width && height) {
1316 bitmap = await createImageBitmap(video, {
1317 resizeWidth: width,
1318 resizeHeight: height,
1319 resizeQuality: 'high'
1320 });
1321 } else {
1322 bitmap = await createImageBitmap(video);
1323 }
1324 } catch (err) {
1325 bitmap = await createImageBitmap(video);
1326 }
1327
1328 const canvas = document.createElement('canvas');
1329 canvas.width = width || bitmap.width;
1330 canvas.height = height || bitmap.height;
1331 const ctx = canvas.getContext('2d');
1332 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1333 if (bitmap && typeof bitmap.close === 'function') {
1334 bitmap.close();
1335 }
1336 entry.thumbs.set(frameId, canvas);
1337 return canvas;
1338 });
1339
1340 this.pendingThumbs.set(cacheKey, pending);
1341 try {
1342 return await pending;
1343 } finally {
1344 this.pendingThumbs.delete(cacheKey);
1345 }
1346 }
1347
1348 async captureFullFrame(videoUrl, frameId) {
1349 return this._withVideoQueue(videoUrl, async () => {
1350 const entry = await this.loadVideo(videoUrl);
1351 const video = entry.video;
1352 const timestamp = Math.max(0, frameId - 1);
1353 await this._seekTo(video, timestamp);
1354 return createImageBitmap(video);
1355 });
1356 }
1357
1358 async prefetchThumbnails(videoUrl, frameIds, onProgress = null) {
1359 if (!frameIds || frameIds.length === 0) return null;
1360
1361 const sorted = [...new Set(frameIds)].sort((a, b) => a - b);
1362 for (let i = 0; i < sorted.length; i += 1) {
1363 const frameId = sorted[i];
1364 await this.captureThumbnail(videoUrl, frameId, 120, 68);
1365 if (onProgress) onProgress(i + 1, sorted.length);
1366 }
1367
1368 return this.videos.get(videoUrl);
1369 }
1370
1371 // Clear all loaded videos and cached thumbnails
1372 clear() {
1373 for (const entry of this.videos.values()) {
1374 entry.thumbs.clear();
1375 entry.video.src = '';
1376 entry.video.load();
1377 }
1378 this.videos.clear();
1379 this.pendingThumbs.clear();
1380 }
1381
1382 // Draw thumbnail to canvas (no overlays - those are only for full frame view)
1383 async drawThumbnail(canvas, videoUrl, frameId, options = {}) {
1384 const { width, height } = options;
1385
1386 try {
1387 const thumb = await this.captureThumbnail(videoUrl, frameId, width, height);
1388 if (!thumb) {
1389 canvas.classList.remove('loading');
1390 return false;
1391 }
1392
1393 canvas.width = width || thumb.width;
1394 canvas.height = height || thumb.height;
1395
1396 const ctx = canvas.getContext('2d');
1397 ctx.drawImage(thumb, 0, 0, canvas.width, canvas.height);
1398
1399 canvas.classList.remove('loading');
1400 return true;
1401 } catch (err) {
1402 canvas.classList.remove('loading');
1403 console.warn('Thumbnail draw failed:', err);
1404 return false;
1405 }
1406 }
1407
1408 // Draw full-resolution frame to canvas (no caching)
1409 async drawFull(canvas, videoUrl, frameId, options = {}) {
1410 const { boxCoords, participants, aruco } = options;
1411
1412 try {
1413 const bitmap = await this.captureFullFrame(videoUrl, frameId);
1414 if (!bitmap) {
1415 canvas.classList.remove('loading');
1416 return false;
1417 }
1418
1419 canvas.width = bitmap.width;
1420 canvas.height = bitmap.height;
1421
1422 const ctx = canvas.getContext('2d');
1423 ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
1424
1425 this._applyOverlays(ctx, canvas, bitmap.width, bitmap.height, { boxCoords, participants, aruco });
1426
1427 if (bitmap && typeof bitmap.close === 'function') {
1428 bitmap.close();
1429 }
1430 canvas.classList.remove('loading');
1431 return true;
1432 } catch (err) {
1433 canvas.classList.remove('loading');
1434 console.warn('Full frame draw failed:', err);
1435 return false;
1436 }
1437 }
1438
1439 // Compute mask polygon from ArUco corner tag markers
1440 // Corner tag IDs: 6=TL, 7=TR, 2=BR, 4=BL
1441 // Each marker has corners in order [TL, TR, BR, BL]
1442 _computeArucoMaskPolygon(aruco) {
1443 if (!aruco || !aruco.masked || !aruco.markers) return null;
1444
1445 const cornerTagIds = { 6: 0, 7: 1, 2: 2, 4: 3 }; // id -> which corner to use
1446 const tagCorners = {};
1447
1448 for (const marker of aruco.markers) {
1449 if (marker.id in cornerTagIds && marker.corners?.length === 4) {
1450 const cornerIdx = cornerTagIds[marker.id];
1451 tagCorners[marker.id] = marker.corners[cornerIdx];
1452 }
1453 }
1454
1455 // Need all 4 corner tags
1456 if (!(6 in tagCorners && 7 in tagCorners && 2 in tagCorners && 4 in tagCorners)) {
1457 return null;
1458 }
1459
1460 // Return polygon: TL, TR, BR, BL
1461 return [tagCorners[6], tagCorners[7], tagCorners[2], tagCorners[4]];
1462 }
1463
1464 _applyOverlays(ctx, canvas, sourceWidth, sourceHeight, options = {}) {
1465 const { boxCoords, participants, aruco } = options;
1466 const scaleX = canvas.width / sourceWidth;
1467 const scaleY = canvas.height / sourceHeight;
1468
1469 // Apply ArUco mask first (so other overlays draw on top)
1470 const maskPolygon = this._computeArucoMaskPolygon(aruco);
1471 if (maskPolygon) {
1472 ctx.fillStyle = '#000000';
1473 ctx.beginPath();
1474 ctx.moveTo(maskPolygon[0][0] * scaleX, maskPolygon[0][1] * scaleY);
1475 for (let i = 1; i < maskPolygon.length; i++) {
1476 ctx.lineTo(maskPolygon[i][0] * scaleX, maskPolygon[i][1] * scaleY);
1477 }
1478 ctx.closePath();
1479 ctx.fill();
1480 }
1481
1482 if (boxCoords && boxCoords.length === 4) {
1483 const [xMin, yMin, xMax, yMax] = boxCoords;
1484 ctx.strokeStyle = '#ef4444';
1485 ctx.lineWidth = 3;
1486 ctx.strokeRect(
1487 xMin * scaleX,
1488 yMin * scaleY,
1489 (xMax - xMin) * scaleX,
1490 (yMax - yMin) * scaleY
1491 );
1492 }
1493
1494 if (participants && participants.length > 0) {
1495 for (const p of participants) {
1496 const x = (p.left / 100) * canvas.width;
1497 const y = (p.top / 100) * canvas.height;
1498 const w = (p.width / 100) * canvas.width;
1499 const h = (p.height / 100) * canvas.height;
1500
1501 const colors = {
1502 speaking: '#fbbf24',
1503 active: '#4ade80',
1504 muted: '#f87171',
1505 presenting: '#60a5fa',
1506 unknown: '#9ca3af'
1507 };
1508 ctx.strokeStyle = colors[p.status] || colors.unknown;
1509 ctx.lineWidth = 2;
1510 ctx.setLineDash([5, 5]);
1511 ctx.strokeRect(x, y, w, h);
1512 ctx.setLineDash([]);
1513
1514 const labelY = y + h + 16;
1515 ctx.font = '11px system-ui, sans-serif';
1516 const textWidth = ctx.measureText(p.name).width;
1517 ctx.fillStyle = colors[p.status] || colors.unknown;
1518 ctx.fillRect(x, y + h + 2, textWidth + 8, 16);
1519
1520 ctx.fillStyle = '#000';
1521 ctx.fillText(p.name, x + 4, labelY - 3);
1522 }
1523 }
1524 }
1525 }
1526
1527 // Global frame capture instance
1528 let frameCapture = new FrameCapture();
1529
1530 // Utilities
1531 const y = (m) => (m - timelineStart) * ppm;
1532 const mFromY = (py) => Math.max(timelineStart, Math.min(timelineEnd, Math.round(py / ppm) + timelineStart));
1533 const snap = (m) => Math.round(m / STEP) * STEP;
1534 const hhmm = (m) => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
1535
1536 // Zoom utilities - map minutes to pixels within the zoomed range
1537 const zoomY = (m) => (m - range.start) * zoomPpm;
1538
1539 function parseTime(timeStr) {
1540 const [hh, mm] = timeStr.split(':').map(Number);
1541 return hh * 60 + mm;
1542 }
1543
1544 // Format cost in cents with exact USD in title
1545 // Returns {text: "3c", title: "$0.0312"} or {text: "", title: ""} if null/zero
1546 function formatCost(costUSD) {
1547 if (costUSD === null || costUSD === undefined) {
1548 return { text: '', title: '' };
1549 }
1550 const cents = Math.round(costUSD * 100);
1551 const exactUSD = '$' + costUSD.toFixed(4);
1552 return { text: cents + 'c', title: exactUSD };
1553 }
1554
1555 // Format bytes as human-readable size (KB, MB, GB)
1556 function formatSize(bytes) {
1557 if (!bytes) return '';
1558 if (bytes < 1024) return bytes + ' B';
1559 if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
1560 if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
1561 return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
1562 }
1563
1564 // Build range text with cost and total media size
1565 function updateRangeText() {
1566 if (!selectedSegment || !segmentData) return;
1567 const seg = selectedSegment;
1568 let parts = [`${seg.start} - ${seg.end}`];
1569
1570 const costInfo = formatCost(segmentData.cost);
1571 if (costInfo.text) {
1572 parts.push(`<span title="${costInfo.title}">${costInfo.text}</span>`);
1573 }
1574
1575 const sizes = segmentData.media_sizes;
1576 if (sizes) {
1577 let total = 0;
1578 const breakdown = [];
1579 if (sizes.audio) {
1580 total += sizes.audio;
1581 breakdown.push('audio ' + formatSize(sizes.audio));
1582 }
1583 if (sizes.screen) {
1584 total += sizes.screen;
1585 breakdown.push('screen ' + formatSize(sizes.screen));
1586 }
1587 if (total) {
1588 const title = breakdown.join(', ');
1589 parts.push(`<span title="${title}">${formatSize(total)}</span>`);
1590 }
1591 }
1592
1593 rangeText.innerHTML = parts.join(' · ');
1594 }
1595
1596 function computeTimelineBounds(ranges) {
1597 // Compute dynamic timeline bounds from content ranges
1598 // Returns {start, end} in minutes, snapped to hours
1599 const allRanges = [...(ranges.audio || []), ...(ranges.screen || [])];
1600
1601 if (allRanges.length === 0) {
1602 return { start: DEFAULT_START, end: DEFAULT_END };
1603 }
1604
1605 // Find min/max times across all ranges
1606 let minTime = Infinity;
1607 let maxTime = -Infinity;
1608 for (const [start, end] of allRanges) {
1609 const s = parseTime(start);
1610 const e = parseTime(end);
1611 if (s < minTime) minTime = s;
1612 if (e > maxTime) maxTime = e;
1613 }
1614
1615 // Add buffer and snap to hours
1616 let start = Math.floor((minTime - BUFFER) / 60) * 60;
1617 let end = Math.ceil((maxTime + BUFFER) / 60) * 60;
1618
1619 // Enforce minimum span - extend end if needed
1620 if (end - start < MIN_SPAN) {
1621 end = start + MIN_SPAN;
1622 }
1623
1624 // Clamp to valid day range (0:00 - 24:00)
1625 start = Math.max(0, start);
1626 end = Math.min(24 * 60, end);
1627
1628 return { start, end };
1629 }
1630
1631 function addSegmentIndicator(type, startMin, endMin, column) {
1632 const el = document.createElement('div');
1633 el.className = 'tr-seg ' + (type === 'screen' ? 'tr-seg-screen' : 'tr-seg-audio');
1634 el.style.top = y(startMin) + 'px';
1635 el.style.height = Math.max(2, y(endMin) - y(startMin)) + 'px';
1636 el.style.left = (column === 1 ? 56 : 8) + 'px';
1637 // Zero-padded HH:MM label
1638 const _h = Math.floor(startMin / 60);
1639 const _m = startMin % 60;
1640 const _label = `Segment ${String(_h).padStart(2, '0')}:${String(_m).padStart(2, '0')}`;
1641 el.setAttribute('role', 'button');
1642 el.setAttribute('tabindex', '0');
1643 el.setAttribute('aria-label', _label);
1644 el.title = hhmm(startMin) + ' – ' + hhmm(endMin) + ' (' + (endMin - startMin) + ' min, ' + type + ')';
1645 el.addEventListener('click', e => {
1646 e.stopPropagation();
1647 const midMin = (startMin + endMin) / 2;
1648 const start = snap(midMin - DEFAULT_LEN / 2);
1649 range = { start, end: start + DEFAULT_LEN };
1650 renderTimeline();
1651 updateZoom();
1652 });
1653 el.addEventListener('keydown', e => {
1654 if (e.key === 'Enter' || e.key === ' ') {
1655 e.preventDefault();
1656 const midMin = (startMin + endMin) / 2;
1657 const start = snap(midMin - DEFAULT_LEN / 2);
1658 range = { start, end: start + DEFAULT_LEN };
1659 renderTimeline();
1660 updateZoom();
1661 }
1662 });
1663 segmentsLane.appendChild(el);
1664 }
1665
1666 function buildGrid() {
1667 grid.innerHTML = '';
1668 labels.innerHTML = '';
1669
1670 for (let h = timelineStart / 60; h <= timelineEnd / 60; h++) {
1671 const hourLine = document.createElement('div');
1672 hourLine.className = 'tr-grid-hour';
1673 hourLine.style.top = y(h * 60) + 'px';
1674 grid.appendChild(hourLine);
1675
1676 const lab = document.createElement('div');
1677 lab.className = 'tr-label';
1678 lab.style.top = y(h * 60) + 'px';
1679 lab.textContent = String(h).padStart(2, '0') + ':00';
1680 labels.appendChild(lab);
1681
1682 if (h < timelineEnd / 60) {
1683 [15, 30, 45].forEach(m => {
1684 const q = document.createElement('div');
1685 q.className = 'tr-grid-quarter';
1686 q.style.top = y(h * 60 + m) + 'px';
1687 grid.appendChild(q);
1688 });
1689 }
1690 }
1691 }
1692
1693 function renderTimeline() {
1694 selWrap.style.top = y(range.start) + 'px';
1695 selWrap.style.height = (y(range.end) - y(range.start)) + 'px';
1696 }
1697
1698 function buildZoomGrid() {
1699 zoomGrid.innerHTML = '';
1700 zoomLabels.innerHTML = '';
1701
1702 const rangeLen = range.end - range.start;
1703 // Determine label interval based on range length
1704 let labelInterval = 5; // default 5 min
1705 if (rangeLen > 120) labelInterval = 15;
1706 else if (rangeLen > 60) labelInterval = 10;
1707
1708 for (let m = range.start; m <= range.end; m++) {
1709 const yPos = zoomY(m);
1710
1711 if (m % 60 === 0) {
1712 const line = document.createElement('div');
1713 line.className = 'tr-grid-hour';
1714 line.style.top = yPos + 'px';
1715 zoomGrid.appendChild(line);
1716 } else if (m % 15 === 0) {
1717 const line = document.createElement('div');
1718 line.className = 'tr-grid-quarter';
1719 line.style.top = yPos + 'px';
1720 zoomGrid.appendChild(line);
1721 }
1722
1723 if (m % labelInterval === 0) {
1724 const lab = document.createElement('div');
1725 lab.className = 'tr-zoom-label';
1726 lab.style.top = yPos + 'px';
1727 lab.textContent = hhmm(m);
1728 zoomLabels.appendChild(lab);
1729 }
1730 }
1731 }
1732
1733 function filterSegmentsInRange() {
1734 return allSegments.filter(seg => {
1735 const segStart = parseTime(seg.start);
1736 const segEnd = parseTime(seg.end);
1737 return segEnd > range.start && segStart < range.end;
1738 });
1739 }
1740
1741 function buildZoomSegments() {
1742 const filtered = filterSegmentsInRange();
1743 const streams = [...new Set(filtered.map(s => s.stream))].sort();
1744 const colCount = streams.length;
1745 zoomSegments.innerHTML = '';
1746
1747 if (filtered.length === 0) {
1748 const empty = document.createElement('div');
1749 empty.className = 'tr-zoom-empty';
1750 empty.textContent = 'No segments in selected range';
1751 zoomSegments.appendChild(empty);
1752 return;
1753 }
1754
1755 filtered.forEach(seg => {
1756 const segStart = parseTime(seg.start);
1757 const segEnd = parseTime(seg.end);
1758
1759 // Clamp to visible range
1760 const visStart = Math.max(segStart, range.start);
1761 const visEnd = Math.min(segEnd, range.end);
1762
1763 const pill = document.createElement('div');
1764 pill.className = 'tr-zoom-pill';
1765 pill.setAttribute('role', 'button');
1766 pill.setAttribute('tabindex', '0');
1767
1768 if (colCount > 1) {
1769 const colIdx = streams.indexOf(seg.stream);
1770 const colWidth = 100 / colCount;
1771 const gap = 0.5;
1772 pill.style.left = (colIdx * colWidth + gap / 2) + '%';
1773 pill.style.right = 'auto';
1774 pill.style.width = (colWidth - gap) + '%';
1775 }
1776
1777 // Determine pill type based on content
1778 const hasAudio = seg.types.includes('audio');
1779 const hasScreen = seg.types.includes('screen');
1780 if (hasAudio && hasScreen) {
1781 pill.classList.add('tr-zoom-pill-both');
1782 } else if (hasAudio) {
1783 pill.classList.add('tr-zoom-pill-audio');
1784 } else {
1785 pill.classList.add('tr-zoom-pill-screen');
1786 }
1787 const typeLabel = (hasAudio && hasScreen) ? 'audio and screen' : hasAudio ? 'audio' : 'screen';
1788
1789 if (selectedSegment && selectedSegment.key === seg.key) {
1790 pill.classList.add('tr-active');
1791 }
1792
1793 pill.style.top = zoomY(visStart) + 'px';
1794 pill.style.height = Math.max(4, zoomY(visEnd) - zoomY(visStart)) + 'px';
1795 const duration = Math.round(visEnd - visStart);
1796 const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : 'screen';
1797 pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc;
1798 pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel);
1799 pill.dataset.key = seg.key;
1800
1801 pill.addEventListener('click', () => selectSegment(seg));
1802 pill.addEventListener('keydown', e => {
1803 if (e.key === 'Enter' || e.key === ' ') {
1804 e.preventDefault();
1805 selectSegment(seg);
1806 }
1807 });
1808 zoomSegments.appendChild(pill);
1809 });
1810 }
1811
1812 function selectSegment(seg, updateHash = true) {
1813 selectedSegment = seg;
1814 document.getElementById('trNavHint').classList.add('visible');
1815
1816 // Update URL hash for shareable links
1817 if (updateHash) {
1818 history.replaceState(null, '', `#${seg.key}`);
1819 }
1820
1821 // Update active state in zoom view
1822 zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => {
1823 pill.classList.toggle('tr-active', pill.dataset.key === seg.key);
1824 });
1825
1826 titleEl.textContent = seg.stream;
1827 rangeText.textContent = `${seg.start} - ${seg.end}`;
1828
1829 // Show delete button when segment is selected
1830 deleteBtn.classList.add('visible');
1831
1832 // Load transcript content
1833 loadSegmentContent(seg);
1834 }
1835
1836 // Step through segments with [ ] keys
1837 function navigateSegment(delta) {
1838 if (allSegments.length === 0) return;
1839 let currentIdx = selectedSegment
1840 ? allSegments.findIndex(s => s.key === selectedSegment.key)
1841 : -1;
1842 let nextIdx = currentIdx === -1
1843 ? (delta > 0 ? 0 : allSegments.length - 1)
1844 : currentIdx + delta;
1845 if (nextIdx < 0 || nextIdx >= allSegments.length) return;
1846 const seg = allSegments[nextIdx];
1847 const segStart = parseTime(seg.start);
1848 const segEnd = parseTime(seg.end);
1849 if (segStart < range.start || segEnd > range.end) {
1850 const rangeLen = range.end - range.start;
1851 const segMid = (segStart + segEnd) / 2;
1852 let newStart = snap(Math.max(timelineStart, segMid - rangeLen / 2));
1853 newStart = Math.min(newStart, timelineEnd - rangeLen);
1854 range = { start: newStart, end: newStart + rangeLen };
1855 renderTimeline();
1856 updateZoom();
1857 }
1858 selectSegment(seg);
1859 }
1860
1861 // Unified timeline state
1862 let segmentData = null;
1863 let currentVideoFiles = {}; // filename -> video URL mapping
1864 let groupEntriesByIdx = new Map();
1865 let activeTab = null;
1866 let tabPanes = {}; // tabId -> pane element
1867 let screenDecoded = false;
1868
1869 function loadSegmentContent(seg) {
1870 const segmentToken = seg.key;
1871
1872 // Clear old data, videos, tabs, and show loading message immediately
1873 segmentData = null;
1874 currentVideoFiles = {};
1875 tabPanes = {};
1876 activeTab = null;
1877 screenDecoded = false;
1878 frameCapture.clear();
1879 tabsContainer.classList.remove('visible');
1880 tabsContainer.innerHTML = '';
1881 document.getElementById('trWarningNotice').classList.remove('visible');
1882 panel.innerHTML = '<div class="tr-unified-empty"><p>Loading segment...</p></div>';
1883
1884 fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`)
1885 .then(r => r.json())
1886 .then(data => {
1887 if (!selectedSegment || selectedSegment.key !== segmentToken) {
1888 return;
1889 }
1890 segmentData = data;
1891 updateRangeText();
1892 buildTabBar(data);
1893 activateTab('transcript');
1894 const warningNotice = document.getElementById('trWarningNotice');
1895 if (data.warnings > 0) {
1896 document.getElementById('trWarningText').textContent = data.warnings + ' warning' + (data.warnings === 1 ? '' : 's') + ' during processing';
1897 warningNotice.classList.add('visible');
1898 } else {
1899 warningNotice.classList.remove('visible');
1900 }
1901 })
1902 .catch(() => {
1903 tabsContainer.classList.remove('visible');
1904 tabsContainer.innerHTML = '';
1905 panel.innerHTML = emptyStateHTML(emptyIcons.transcript, 'couldn\'t load this segment', 'something went wrong loading the transcript. try selecting the segment again, or refresh the page.');
1906 });
1907 }
1908
1909 function prepareScreenFrames(data, targetEl, segmentToken) {
1910 if (screenDecoded) {
1911 return Promise.resolve();
1912 }
1913
1914 const isStaleSegment = () => !selectedSegment || selectedSegment.key !== segmentToken;
1915 if (isStaleSegment()) {
1916 return Promise.resolve();
1917 }
1918
1919 currentVideoFiles = data.video_files || {};
1920
1921 const nonBasicByVideo = new Map();
1922 (data.chunks || []).forEach(chunk => {
1923 if (chunk.type !== 'screen') return;
1924 if (chunk.basic === true) return;
1925 const filename = chunk.source_ref?.filename;
1926 const frameId = chunk.source_ref?.frame_id;
1927 if (!filename || !frameId) return;
1928 if (!nonBasicByVideo.has(filename)) {
1929 nonBasicByVideo.set(filename, new Set());
1930 }
1931 nonBasicByVideo.get(filename).add(frameId);
1932 });
1933
1934 const totalFrames = Array.from(nonBasicByVideo.values()).reduce(
1935 (sum, frames) => sum + frames.size,
1936 0
1937 );
1938 const perVideoProgress = new Map();
1939 let lastStatusUpdate = 0;
1940
1941 const updateLoadingStatus = (done) => {
1942 if (isStaleSegment()) return;
1943 const statusEl = targetEl.querySelector('[data-role="loading-status"]');
1944 if (!statusEl) return;
1945 if (!totalFrames) {
1946 statusEl.textContent = 'Loading screen entries...';
1947 return;
1948 }
1949 const decoded = Array.from(perVideoProgress.values()).reduce((sum, count) => sum + count, 0);
1950 const pct = Math.min(100, Math.round((decoded / totalFrames) * 100));
1951 statusEl.textContent = done
1952 ? 'Rendering screen entries...'
1953 : `Decoding key frames ${decoded}/${totalFrames} (${pct}%)...`;
1954 };
1955
1956 const makeProgressHandler = (videoUrl) => (count) => {
1957 if (isStaleSegment()) return;
1958 const now = Date.now();
1959 perVideoProgress.set(videoUrl, count);
1960 if (now - lastStatusUpdate > 150) {
1961 lastStatusUpdate = now;
1962 updateLoadingStatus(false);
1963 }
1964 };
1965
1966 updateLoadingStatus(false);
1967
1968 const decodeJobs = [];
1969 Object.entries(currentVideoFiles).forEach(([filename, url]) => {
1970 const frameIds = Array.from(nonBasicByVideo.get(filename) || []);
1971 if (frameIds.length > 0) {
1972 decodeJobs.push(frameCapture.prefetchThumbnails(url, frameIds, makeProgressHandler(url)));
1973 }
1974 });
1975
1976 if (decodeJobs.length === 0) {
1977 screenDecoded = true;
1978 return Promise.resolve();
1979 }
1980
1981 return Promise.all(decodeJobs)
1982 .then(() => {
1983 if (isStaleSegment()) return;
1984 screenDecoded = true;
1985 updateLoadingStatus(true);
1986 })
1987 .catch(() => {
1988 if (isStaleSegment()) return;
1989 screenDecoded = true;
1990 updateLoadingStatus(true);
1991 });
1992 }
1993
1994 function buildTabBar(data) {
1995 tabsContainer.innerHTML = '';
1996 tabsContainer.setAttribute('role', 'tablist');
1997 tabsContainer.classList.remove('visible');
1998 panel.innerHTML = '';
1999 tabPanes = {};
2000 activeTab = null;
2001 screenDecoded = false;
2002
2003 const addTab = (tabId, label) => {
2004 const btn = document.createElement('button');
2005 btn.type = 'button';
2006 btn.className = 'tr-tab';
2007 btn.dataset.tab = tabId;
2008 btn.textContent = label;
2009 btn.setAttribute('role', 'tab');
2010 btn.id = 'tr-tab-' + tabId;
2011 btn.setAttribute('aria-selected', 'false');
2012 btn.setAttribute('aria-controls', 'tr-tabpanel-' + tabId);
2013 btn.setAttribute('tabindex', '-1');
2014 btn.addEventListener('click', () => activateTab(tabId));
2015 tabsContainer.appendChild(btn);
2016 };
2017
2018 addTab('transcript', 'Transcript');
2019 if (data.audio_file) {
2020 addTab('audio', 'Audio');
2021 }
2022 if ((data.chunks || []).some(chunk => chunk.type === 'screen')) {
2023 addTab('screen', 'Screen');
2024 }
2025
2026 const mdStems = Object.keys(data.md_files || {}).sort((a, b) => a.localeCompare(b));
2027 mdStems.forEach(stem => addTab(`md-${stem}`, stem));
2028
2029 tabsContainer.classList.add('visible');
2030 }
2031
2032 function activateTab(tabId) {
2033 if (!segmentData || tabId === activeTab) {
2034 return;
2035 }
2036
2037 tabsContainer.querySelectorAll('.tr-tab').forEach(tab => {
2038 const isActive = tab.dataset.tab === tabId;
2039 tab.classList.toggle('active', isActive);
2040 tab.setAttribute('aria-selected', String(isActive));
2041 tab.setAttribute('tabindex', isActive ? '0' : '-1');
2042 });
2043
2044 Object.values(tabPanes).forEach(pane => pane.classList.remove('active'));
2045
2046 let pane = tabPanes[tabId];
2047 if (!pane) {
2048 pane = document.createElement('div');
2049 pane.className = 'tr-tab-pane';
2050 pane.dataset.tab = tabId;
2051 pane.setAttribute('role', 'tabpanel');
2052 pane.setAttribute('tabindex', '0');
2053 pane.id = 'tr-tabpanel-' + tabId;
2054 pane.setAttribute('aria-labelledby', 'tr-tab-' + tabId);
2055 panel.appendChild(pane);
2056 tabPanes[tabId] = pane;
2057
2058 if (tabId === 'transcript') {
2059 renderSegmentTimeline(segmentData, true, true, pane);
2060 } else if (tabId === 'audio') {
2061 renderSegmentTimeline(segmentData, true, false, pane);
2062 } else if (tabId === 'screen') {
2063 const segmentToken = selectedSegment?.key;
2064 pane.innerHTML = '<div class="tr-unified-empty"><p data-role="loading-status">Loading screen entries...</p></div>';
2065 prepareScreenFrames(segmentData, pane, segmentToken)
2066 .then(() => {
2067 if (!selectedSegment || selectedSegment.key !== segmentToken) {
2068 return;
2069 }
2070 if (tabPanes[tabId] !== pane) {
2071 return;
2072 }
2073 renderSegmentTimeline(segmentData, false, true, pane);
2074 })
2075 .catch(() => {
2076 if (!selectedSegment || selectedSegment.key !== segmentToken) {
2077 return;
2078 }
2079 if (tabPanes[tabId] !== pane) {
2080 return;
2081 }
2082 pane.innerHTML = emptyStateHTML(emptyIcons.screen, 'couldn\'t load screen entries', 'something went wrong decoding the screen data. try selecting the segment again.');
2083 });
2084 } else if (tabId.startsWith('md-')) {
2085 const stem = tabId.slice(3);
2086 const content = (segmentData.md_files || {})[stem] || '';
2087 pane.innerHTML = `<div class="tr-md-content">${marked.parse(content)}</div>`;
2088 }
2089 }
2090
2091 pane.classList.add('active');
2092 activeTab = tabId;
2093 }
2094
2095 function renderSegmentTimeline(data, showAudio, showScreen, targetEl) {
2096 const chunks = (data.chunks || []).filter(c => {
2097 if (c.type === 'audio' && !showAudio) return false;
2098 if (c.type === 'screen' && !showScreen) return false;
2099 return true;
2100 });
2101
2102 const textOnlyScreen = showScreen && Object.keys(currentVideoFiles).length === 0;
2103
2104 // Build flat list of all screen frames for modal navigation
2105 allScreenFrames = chunks.filter(c => c.type === 'screen');
2106 currentFrameIndex = -1;
2107
2108 if (chunks.length === 0) {
2109 const tabType = !showScreen ? 'audio' : !showAudio ? 'screen' : 'transcript';
2110 const tabEmptyMap = {
2111 transcript: { icon: emptyIcons.transcript, heading: 'no transcript entries', desc: 'this segment has no transcript content' },
2112 audio: { icon: emptyIcons.audio, heading: 'no audio entries', desc: 'this segment has no audio content' },
2113 screen: { icon: emptyIcons.screen, heading: 'no screen entries', desc: 'this segment has no screen captures' }
2114 };
2115 const emptyInfo = tabEmptyMap[tabType] || tabEmptyMap.transcript;
2116 targetEl.innerHTML = emptyStateHTML(emptyInfo.icon, emptyInfo.heading, emptyInfo.desc);
2117 return;
2118 }
2119
2120 // Group sequential basic screen frames together
2121 const displayItems = textOnlyScreen ? chunks : groupBasicScreenFrames(chunks);
2122 groupEntriesByIdx = new Map();
2123
2124 let html = `<div class="tr-unified" role="list" aria-label="Transcript entries, ${displayItems.length} items">`;
2125
2126 // Audio player section (if we have audio)
2127 if (data.audio_file && showAudio) {
2128 html += '<div class="tr-audio-players" role="presentation">';
2129 html += '<div class="tr-audio-player">';
2130 html += '<div class="tr-audio-player-label">Segment Audio</div>';
2131 html += `<audio data-role="segment-audio" controls preload="metadata"><source src="${data.audio_file}" type="audio/flac">Your browser does not support audio.</audio>`;
2132 html += '</div></div>';
2133 }
2134
2135 if (data.media_purged && !data.audio_file && showAudio) {
2136 html += '<div class="tr-purge-notice" role="presentation">Raw recording removed per retention policy</div>';
2137 }
2138
2139 // Render items (chunks or groups)
2140 displayItems.forEach((item, idx) => {
2141 if (item.type === 'screen-group') {
2142 groupEntriesByIdx.set(idx, item.entries || []);
2143 // Render collapsed group of basic frames
2144 html += renderScreenGroup(item, idx);
2145 } else if (item.type === 'audio') {
2146 const timeStr = item.time || '';
2147 html += `<div class="tr-entry tr-entry-audio" data-idx="${idx}" data-type="audio" data-timestamp="${item.timestamp}" role="listitem" tabindex="0" aria-label="Play from ${timeStr}">`;
2148 html += `<div class="tr-entry-time">${timeStr}</div>`;
2149 html += '<div class="tr-entry-content">';
2150 html += '<span class="sr-only">Audio: </span>';
2151 if (item.speaker_label) {
2152 const sl = item.speaker_label;
2153 const dotClass = sl.confidence === 'high' ? 'tr-speaker-dot-high' : 'tr-speaker-dot-medium';
2154 const labelClass = sl.is_owner ? 'tr-speaker-label tr-speaker-label-owner' : 'tr-speaker-label';
2155 const displayName = sl.is_owner ? 'You' : escapeHtml(sl.name);
2156 const entityHref = '/app/entities#' + encodeURIComponent(sl.entity_id);
2157 html += `<div class="${labelClass}" aria-label="Speaker: ${displayName}, ${sl.confidence} confidence"><span class="tr-speaker-dot ${dotClass}"></span><a href="${entityHref}">${displayName}</a><span class="sr-only">${sl.confidence} confidence</span></div>`;
2158 }
2159 html += `<div class="tr-entry-text">${escapeHtml(item.markdown)}</div>`;
2160 html += '</div></div>';
2161 } else if (item.type === 'screen') {
2162 if (textOnlyScreen) {
2163 const timeStr = item.time || '';
2164 const markdown = item.markdown ? marked.parse(item.markdown) : 'Screen activity';
2165 html += '<div class="tr-entry" role="listitem">';
2166 html += `<div class="tr-entry-time">${timeStr}</div>`;
2167 html += '<div class="tr-entry-content">';
2168 html += '<span class="sr-only">Screen: </span>';
2169 html += `<div class="tr-screen-text">${markdown}</div>`;
2170 html += '</div></div>';
2171 } else {
2172 // Enhanced screen frame - render fully
2173 html += renderEnhancedScreenEntry(item, idx);
2174 }
2175 }
2176 });
2177
2178 html += '</div>';
2179 targetEl.innerHTML = html;
2180
2181 // Get audio element reference
2182 const paneAudioEl = targetEl.querySelector('audio[data-role="segment-audio"]');
2183
2184 // Add click handlers for audio entries to seek
2185 targetEl.querySelectorAll('.tr-entry-audio').forEach(entry => {
2186 entry.addEventListener('click', () => {
2187 if (paneAudioEl && segmentData?.audio_file) {
2188 const timestamp = parseInt(entry.dataset.timestamp, 10);
2189 const baseTimestamp = chunks[0]?.timestamp || timestamp;
2190 const offsetSec = (timestamp - baseTimestamp) / 1000;
2191 paneAudioEl.currentTime = Math.max(0, offsetSec);
2192 paneAudioEl.play();
2193 }
2194 });
2195 });
2196
2197 // Add keyboard handler for audio entries
2198 targetEl.querySelectorAll('.tr-entry-audio').forEach(entry => {
2199 entry.addEventListener('keydown', e => {
2200 if (e.key === 'Enter' || e.key === ' ') {
2201 e.preventDefault();
2202 entry.click();
2203 }
2204 });
2205 });
2206
2207 // Playback highlight: track current entry during audio playback
2208 if (paneAudioEl) {
2209 const baseTimestamp = chunks[0]?.timestamp || 0;
2210 let activeEntry = null;
2211
2212 paneAudioEl.addEventListener('timeupdate', () => {
2213 const currentMs = baseTimestamp + (paneAudioEl.currentTime * 1000);
2214 const entries = targetEl.querySelectorAll('.tr-entry-audio[data-timestamp]');
2215 let best = null;
2216 for (const el of entries) {
2217 const ts = parseInt(el.dataset.timestamp, 10);
2218 if (ts <= currentMs) best = el;
2219 else break;
2220 }
2221 if (best === activeEntry) return;
2222 if (activeEntry) activeEntry.classList.remove('tr-entry-active');
2223 activeEntry = best;
2224 if (activeEntry) {
2225 activeEntry.classList.add('tr-entry-active');
2226 activeEntry.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
2227 }
2228 });
2229
2230 paneAudioEl.addEventListener('ended', () => {
2231 if (activeEntry) {
2232 activeEntry.classList.remove('tr-entry-active');
2233 activeEntry = null;
2234 }
2235 });
2236 }
2237
2238 // Add click handlers for enhanced screen entries to open modal
2239 targetEl.querySelectorAll('.tr-entry-screen').forEach(entry => {
2240 const thumb = entry.querySelector('.tr-entry-thumb');
2241 if (thumb) {
2242 thumb.style.cursor = 'pointer';
2243 thumb.addEventListener('click', (e) => {
2244 e.stopPropagation();
2245 const frameIdx = parseInt(entry.dataset.frameIdx, 10);
2246 if (!isNaN(frameIdx)) openImageModal(frameIdx);
2247 });
2248 }
2249 });
2250
2251 // Add click handlers for group headers to expand/collapse
2252 targetEl.querySelectorAll('.tr-group-header').forEach(header => {
2253 header.addEventListener('click', () => {
2254 const groupEl = header.parentElement;
2255 const isExpanded = groupEl.classList.toggle('expanded');
2256 header.setAttribute('aria-expanded', String(isExpanded));
2257 if (!isExpanded) return;
2258 if (groupEl.dataset.prefetched === 'true') return;
2259 const groupIdx = parseInt(groupEl.dataset.idx, 10);
2260 if (isNaN(groupIdx)) return;
2261 const entries = groupEntriesByIdx.get(groupIdx) || [];
2262 prefetchGroupThumbnails(entries, groupEl, targetEl);
2263 });
2264 header.addEventListener('keydown', e => {
2265 if (e.key === 'Enter' || e.key === ' ') {
2266 e.preventDefault();
2267 header.click();
2268 }
2269 });
2270 });
2271
2272 // Add click handlers for group grid items to open modal
2273 targetEl.querySelectorAll('.tr-group-item').forEach(item => {
2274 item.addEventListener('click', () => {
2275 const frameIdx = parseInt(item.dataset.frameIdx, 10);
2276 if (!isNaN(frameIdx)) openImageModal(frameIdx);
2277 });
2278 });
2279
2280 // Set up lazy loading for canvas thumbnails using IntersectionObserver
2281 if (!textOnlyScreen) {
2282 setupLazyCanvasLoading(targetEl);
2283 }
2284 }
2285
2286 function prefetchGroupThumbnails(entries, groupEl, targetEl) {
2287 const frameIdsByVideo = new Map();
2288 for (const entry of entries) {
2289 const filename = entry.source_ref?.filename;
2290 const frameId = entry.source_ref?.frame_id;
2291 if (!filename || !frameId) continue;
2292 if (!frameIdsByVideo.has(filename)) {
2293 frameIdsByVideo.set(filename, new Set());
2294 }
2295 frameIdsByVideo.get(filename).add(frameId);
2296 }
2297
2298 const jobs = [];
2299 for (const [filename, frameIds] of frameIdsByVideo.entries()) {
2300 const url = currentVideoFiles[filename];
2301 if (!url) continue;
2302 jobs.push(frameCapture.prefetchThumbnails(url, Array.from(frameIds)));
2303 }
2304
2305 if (jobs.length > 0) {
2306 groupEl.dataset.prefetched = 'true';
2307 Promise.all(jobs).finally(() => {
2308 setupLazyCanvasLoading(targetEl);
2309 });
2310 }
2311 }
2312
2313 // Lazy load canvas thumbnails when they become visible
2314 function setupLazyCanvasLoading(targetEl = panel) {
2315 const canvases = targetEl.querySelectorAll('canvas[data-video-url]');
2316 if (canvases.length === 0) return;
2317
2318 const observer = new IntersectionObserver((entries) => {
2319 for (const entry of entries) {
2320 if (entry.isIntersecting) {
2321 const canvas = entry.target;
2322 observer.unobserve(canvas);
2323 loadCanvasThumbnail(canvas);
2324 }
2325 }
2326 }, { rootMargin: '100px' });
2327
2328 canvases.forEach(canvas => observer.observe(canvas));
2329 }
2330
2331 // Load a single canvas thumbnail
2332 function loadCanvasThumbnail(canvas) {
2333 const videoUrl = canvas.dataset.videoUrl;
2334 const frameId = parseInt(canvas.dataset.frameId, 10);
2335
2336 if (!videoUrl || isNaN(frameId)) {
2337 canvas.classList.remove('loading');
2338 return;
2339 }
2340
2341 // Draw at thumbnail size (120x68) - no overlays on thumbnails
2342 frameCapture.drawThumbnail(canvas, videoUrl, frameId, {
2343 width: 120,
2344 height: 68
2345 });
2346 }
2347
2348 function groupBasicScreenFrames(chunks) {
2349 // Group sequential basic screen frames, keep audio and enhanced screens separate
2350 const result = [];
2351 let currentGroup = null;
2352
2353 for (const chunk of chunks) {
2354 const isBasicScreen = chunk.type === 'screen' && chunk.basic === true;
2355
2356 if (isBasicScreen) {
2357 // Add to current group or start new one
2358 if (!currentGroup) {
2359 currentGroup = {
2360 type: 'screen-group',
2361 entries: [],
2362 startTime: chunk.time,
2363 endTime: chunk.time
2364 };
2365 }
2366 currentGroup.entries.push(chunk);
2367 currentGroup.endTime = chunk.time;
2368 } else {
2369 // Flush any pending group
2370 if (currentGroup) {
2371 result.push(currentGroup);
2372 currentGroup = null;
2373 }
2374 result.push(chunk);
2375 }
2376 }
2377
2378 // Flush final group
2379 if (currentGroup) {
2380 result.push(currentGroup);
2381 }
2382
2383 return result;
2384 }
2385
2386 function findFrameIndex(chunk) {
2387 // Find this chunk's index in allScreenFrames by matching source_ref
2388 const ref = chunk.source_ref;
2389 return allScreenFrames.findIndex(f =>
2390 f.source_ref?.filename === ref?.filename &&
2391 f.source_ref?.frame_id === ref?.frame_id
2392 );
2393 }
2394
2395 // Get video URL for a chunk
2396 function getVideoUrlForChunk(chunk) {
2397 const filename = chunk.source_ref?.filename;
2398 return filename ? currentVideoFiles[filename] : null;
2399 }
2400
2401 function renderScreenGroup(group, idx) {
2402 const count = group.entries.length;
2403 const timeRange = group.startTime === group.endTime
2404 ? group.startTime
2405 : `${group.startTime} - ${group.endTime}`;
2406 const countText = count === 1 ? '1 frame' : `${count} frames`;
2407
2408 let html = `<div class="tr-group" data-idx="${idx}" role="listitem">`;
2409 html += `<div class="tr-group-header" role="button" tabindex="0" aria-expanded="false" aria-controls="tr-group-grid-${idx}">`;
2410 html += `<svg class="tr-group-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>`;
2411 html += `<span class="tr-group-time">${timeRange}</span>`;
2412 html += `<span class="tr-group-count">${countText}</span>`;
2413 html += '</div>';
2414
2415 // Grid of thumbnails (hidden until expanded)
2416 html += `<div class="tr-group-grid" id="tr-group-grid-${idx}">`;
2417 for (const entry of group.entries) {
2418 const videoUrl = getVideoUrlForChunk(entry);
2419 const frameId = entry.source_ref?.frame_id;
2420 const analysis = entry.source_ref?.analysis || {};
2421 const category = analysis.primary || 'unknown';
2422 const description = analysis.visual_description || category;
2423 const frameIdx = findFrameIndex(entry);
2424
2425 if (videoUrl && frameId) {
2426 html += `<div class="tr-group-item" data-frame-idx="${frameIdx}" title="${escapeHtml(description)}">`;
2427 html += `<canvas class="loading" data-video-url="${escapeHtml(videoUrl)}" data-frame-id="${frameId}"></canvas>`;
2428 html += `<span class="tr-group-item-badge">${escapeHtml(category)}</span>`;
2429 html += '</div>';
2430 }
2431 }
2432 html += '</div>';
2433
2434 html += '</div>';
2435 return html;
2436 }
2437
2438 function renderEnhancedScreenEntry(chunk, idx) {
2439 const timeStr = chunk.time || '';
2440 const monitor = chunk.source_ref?.monitor || '';
2441 const videoUrl = getVideoUrlForChunk(chunk);
2442 const frameId = chunk.source_ref?.frame_id;
2443 const frameIdx = findFrameIndex(chunk);
2444
2445 let html = `<div class="tr-entry tr-entry-screen" data-idx="${idx}" data-frame-idx="${frameIdx}" data-type="screen" role="listitem">`;
2446 html += `<div class="tr-entry-time">${timeStr}</div>`;
2447 html += '<div class="tr-entry-content">';
2448 html += '<span class="sr-only">Screen: </span>';
2449
2450 if (videoUrl && frameId) {
2451 html += `<canvas class="tr-entry-thumb loading" data-video-url="${escapeHtml(videoUrl)}" data-frame-id="${frameId}"></canvas>`;
2452 }
2453
2454 html += '<div class="tr-entry-desc">';
2455 if (monitor) {
2456 const monitorPos = getMonitorPosition(monitor);
2457 if (monitorPos) html += `<span class="tr-entry-badge tr-entry-badge-monitor">${monitorPos}</span>`;
2458 }
2459 if (chunk.markdown) {
2460 html += marked.parse(chunk.markdown);
2461 }
2462 html += '</div>';
2463
2464 html += '</div></div>';
2465 return html;
2466 }
2467
2468 function escapeHtml(str) {
2469 if (!str) return '';
2470 return str
2471 .replace(/&/g, '&')
2472 .replace(/</g, '<')
2473 .replace(/>/g, '>')
2474 .replace(/"/g, '"');
2475 }
2476
2477 function openImageModal(frameIndex) {
2478 if (frameIndex < 0 || frameIndex >= allScreenFrames.length) return;
2479
2480 currentFrameIndex = frameIndex;
2481 let maskHidden = false; // Track if user has revealed masked content
2482 const triggerElement = document.activeElement;
2483 const prevOverflow = document.body.style.overflow;
2484
2485 const modal = document.createElement('div');
2486 modal.className = 'tr-screenshot-modal';
2487 modal.id = 'trImageModal';
2488 modal.setAttribute('role', 'dialog');
2489 modal.setAttribute('aria-modal', 'true');
2490 modal.setAttribute('aria-label', 'Screenshot viewer');
2491
2492 const drawFrame = (canvas, f, showMask) => {
2493 const videoUrl = getVideoUrlForChunk(f);
2494 const frameId = f.source_ref?.frame_id;
2495 const boxCoords = f.source_ref?.box_2d;
2496 const aruco = f.source_ref?.aruco;
2497 const participants = f.source_ref?.participants || [];
2498
2499 if (videoUrl && frameId) {
2500 frameCapture.drawFull(canvas, videoUrl, frameId, {
2501 boxCoords,
2502 participants,
2503 aruco: showMask ? aruco : null // Pass null to skip mask
2504 });
2505 } else {
2506 canvas.classList.remove('loading');
2507 }
2508 };
2509
2510 const updateModalContent = () => {
2511 const f = allScreenFrames[currentFrameIndex];
2512 const monitor = f.source_ref?.monitor || '';
2513 const monitorPos = getMonitorPosition(monitor);
2514 const analysis = f.source_ref?.analysis || {};
2515 const category = analysis.primary || '';
2516 const description = analysis.visual_description || '';
2517 const aruco = f.source_ref?.aruco;
2518 const isMasked = aruco?.masked && !maskHidden;
2519 const hasPrev = currentFrameIndex > 0;
2520 const hasNext = currentFrameIndex < allScreenFrames.length - 1;
2521
2522 modal.innerHTML = `
2523 <div class="tr-modal-nav${hasPrev ? '' : ' disabled'}" data-dir="prev" title="Previous frame (Left arrow)" role="button" tabindex="${hasPrev ? '0' : '-1'}">
2524 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
2525 </div>
2526 <div class="tr-modal-center">
2527 <div class="tr-modal-header">
2528 ${monitorPos ? `<span class="tr-modal-badge tr-modal-badge-monitor">${monitorPos}</span>` : ''}
2529 ${category ? `<span class="tr-modal-badge tr-modal-badge-category">${escapeHtml(category)}</span>` : ''}
2530 ${isMasked ? '<span class="tr-modal-badge tr-modal-badge-masked" title="Click image to reveal">Masked</span>' : ''}
2531 <button class="tr-modal-close" title="Close (Esc)" aria-label="Close">×</button>
2532 </div>
2533 <div class="tr-modal-img-wrap">
2534 <canvas id="trModalCanvas" class="loading${isMasked ? ' tr-masked-canvas' : ''}"></canvas>
2535 </div>
2536 ${description ? `<div class="tr-modal-description">${escapeHtml(description)}</div>` : ''}
2537 </div>
2538 <div class="tr-modal-nav${hasNext ? '' : ' disabled'}" data-dir="next" title="Next frame (Right arrow)" role="button" tabindex="${hasNext ? '0' : '-1'}">
2539 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
2540 </div>
2541 `;
2542
2543 // Draw frame to modal canvas
2544 const canvas = modal.querySelector('#trModalCanvas');
2545 drawFrame(canvas, f, !maskHidden);
2546
2547 // Add click-to-reveal handler for masked frames
2548 if (aruco?.masked) {
2549 canvas.addEventListener('click', () => {
2550 if (!maskHidden) {
2551 maskHidden = true;
2552 canvas.classList.remove('tr-masked-canvas');
2553 canvas.classList.add('loading');
2554 modal.querySelector('.tr-modal-badge-masked')?.remove();
2555 drawFrame(canvas, f, false);
2556 }
2557 });
2558 }
2559 };
2560
2561 const navigateFrame = (delta) => {
2562 const newIndex = currentFrameIndex + delta;
2563 if (newIndex >= 0 && newIndex < allScreenFrames.length) {
2564 currentFrameIndex = newIndex;
2565 maskHidden = false; // Reset mask state when navigating
2566 updateModalContent();
2567 modal.querySelector('.tr-modal-close')?.focus();
2568 }
2569 };
2570
2571 const closeModal = () => {
2572 modal.remove();
2573 document.removeEventListener('keydown', handleKeys);
2574 document.body.style.overflow = prevOverflow;
2575 if (triggerElement && document.contains(triggerElement)) triggerElement.focus();
2576 currentFrameIndex = -1;
2577 };
2578
2579 const handleKeys = (e) => {
2580 if (e.key === 'Escape') closeModal();
2581 else if (e.key === 'ArrowLeft') { e.preventDefault(); navigateFrame(-1); }
2582 else if (e.key === 'ArrowRight') { e.preventDefault(); navigateFrame(1); }
2583 else if (e.key === 'Tab') {
2584 const focusable = [...modal.querySelectorAll('button:not([disabled]), [tabindex="0"]')];
2585 if (focusable.length === 0) return;
2586 const currentIndex = focusable.indexOf(document.activeElement);
2587 if (e.shiftKey) {
2588 e.preventDefault();
2589 focusable[currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1].focus();
2590 } else {
2591 e.preventDefault();
2592 focusable[currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1].focus();
2593 }
2594 }
2595 };
2596
2597 // Event delegation for modal clicks
2598 modal.addEventListener('click', (e) => {
2599 const target = e.target.closest('.tr-modal-close, .tr-modal-nav:not(.disabled)');
2600 if (!target) return;
2601 if (target.classList.contains('tr-modal-close')) closeModal();
2602 else if (target.classList.contains('tr-modal-nav')) {
2603 navigateFrame(target.dataset.dir === 'prev' ? -1 : 1);
2604 }
2605 });
2606
2607 document.body.style.overflow = 'hidden';
2608 document.body.appendChild(modal);
2609 updateModalContent();
2610 modal.querySelector('.tr-modal-close')?.focus();
2611 document.addEventListener('keydown', handleKeys);
2612 }
2613
2614 function updateZoom() {
2615 zoom.setAttribute('aria-label', 'Detail timeline (' + hhmm(range.start) + '\u2013' + hhmm(range.end) + ')');
2616 zoomHeight = zoom.clientHeight - 24; // account for padding
2617 const rangeLen = range.end - range.start;
2618 if (rangeLen > 0) {
2619 zoomPpm = zoomHeight / rangeLen;
2620 buildZoomGrid();
2621 buildZoomSegments();
2622 }
2623 }
2624
2625 // Resize observers
2626 // Account for 12px padding top and bottom
2627 const PADDING = 24;
2628
2629 new ResizeObserver(() => {
2630 height = timeline.clientHeight - PADDING;
2631 ppm = height / (timelineEnd - timelineStart);
2632 buildGrid();
2633 renderTimeline();
2634 if (updateNowPosition) updateNowPosition();
2635 }).observe(timeline);
2636
2637 new ResizeObserver(() => {
2638 updateZoom();
2639 }).observe(zoom);
2640
2641 // Load combined transcript data
2642 fetch(`/app/transcripts/api/day/${day}`)
2643 .then(r => {
2644 if (!r.ok) throw new Error(`Day data failed: ${r.status}`);
2645 return r.json();
2646 })
2647 .then(data => {
2648 // Apply dynamic timeline bounds from ranges
2649 const bounds = computeTimelineBounds(data);
2650 timelineStart = bounds.start;
2651 timelineEnd = bounds.end;
2652 timeline.setAttribute('aria-label', 'Day timeline (' + hhmm(timelineStart) + '\u2013' + hhmm(timelineEnd) + ')');
2653
2654 // Recalculate pixels-per-minute with new bounds
2655 height = timeline.clientHeight - PADDING;
2656 ppm = height / (timelineEnd - timelineStart);
2657
2658 // Set initial selection range within bounds
2659 // Center on current time if viewing today, otherwise midpoint
2660 const now = new Date();
2661 const todayStr = String(now.getFullYear()) +
2662 String(now.getMonth() + 1).padStart(2, '0') +
2663 String(now.getDate()).padStart(2, '0');
2664 let center;
2665 if (day === todayStr) {
2666 const nowMin = now.getHours() * 60 + now.getMinutes();
2667 center = Math.max(timelineStart, Math.min(timelineEnd, nowMin));
2668 } else {
2669 center = (timelineStart + timelineEnd) / 2;
2670 }
2671 range = { start: snap(center - DEFAULT_LEN / 2), end: snap(center + DEFAULT_LEN / 2) };
2672 if (range.start < timelineStart) {
2673 range = { start: timelineStart, end: timelineStart + DEFAULT_LEN };
2674 }
2675 if (range.end > timelineEnd) {
2676 range = { start: timelineEnd - DEFAULT_LEN, end: timelineEnd };
2677 }
2678
2679 // Build the grid and render timeline
2680 buildGrid();
2681 renderTimeline();
2682
2683 // Add segment indicators from ranges
2684 (data.audio || []).forEach(rg => {
2685 const [s, e] = rg.map(parseTime);
2686 addSegmentIndicator('audio', s, e, 0);
2687 });
2688 (data.screen || []).forEach(rg => {
2689 const [s, e] = rg.map(parseTime);
2690 addSegmentIndicator('screen', s, e, 1);
2691 });
2692
2693 // Now-marker for today
2694 if (day === todayStr) {
2695 const marker = document.createElement('div');
2696 marker.className = 'tr-now-marker';
2697 marker.setAttribute('aria-label', 'Current time');
2698 const lbl = document.createElement('span');
2699 lbl.className = 'tr-now-label';
2700 lbl.textContent = 'now';
2701 marker.appendChild(lbl);
2702 timeline.appendChild(marker);
2703
2704 updateNowPosition = function() {
2705 const n = new Date();
2706 const nowMin = n.getHours() * 60 + n.getMinutes();
2707 if (nowMin < timelineStart || nowMin > timelineEnd) {
2708 marker.style.display = 'none';
2709 } else {
2710 marker.style.display = '';
2711 marker.style.top = y(nowMin) + 'px';
2712 }
2713 };
2714 updateNowPosition();
2715 setInterval(updateNowPosition, 60000);
2716 }
2717
2718 // Store segments and update zoom
2719 allSegments = data.segments || [];
2720 updateZoom();
2721 if (allSegments.length === 0) {
2722 panel.innerHTML = emptyStateHTML(emptyIcons.nothing, 'nothing captured', 'no recordings were found for this day');
2723 }
2724
2725 // Check for hash fragment to auto-select segment
2726 const hash = window.location.hash.slice(1);
2727 if (hash) {
2728 const seg = allSegments.find(s => s.key === hash);
2729 if (seg) {
2730 const segStart = parseTime(seg.start);
2731 const segEnd = parseTime(seg.end);
2732 const rangeLen = range.end - range.start;
2733 const segMid = (segStart + segEnd) / 2;
2734 let newStart = snap(Math.max(timelineStart, segMid - rangeLen / 2));
2735 newStart = Math.min(newStart, timelineEnd - rangeLen);
2736 range = { start: newStart, end: newStart + rangeLen };
2737 renderTimeline();
2738 updateZoom();
2739 selectSegment(seg, false);
2740 }
2741 }
2742 })
2743 .catch(err => {
2744 console.error('Failed to load transcript data:', err);
2745 zoomSegments.innerHTML = '';
2746 panel.innerHTML = emptyStateHTML(emptyIcons.transcript, 'couldn\'t load transcripts', 'the data service may be offline. try refreshing the page.');
2747 });
2748
2749 // Handle browser back/forward
2750 window.addEventListener('hashchange', () => {
2751 const hash = window.location.hash.slice(1);
2752 if (hash) {
2753 const seg = allSegments.find(s => s.key === hash);
2754 if (seg && (!selectedSegment || selectedSegment.key !== hash)) {
2755 selectSegment(seg, false);
2756 }
2757 }
2758 });
2759
2760 // Keyboard navigation for segment stepping
2761 document.addEventListener('keydown', (e) => {
2762 const tag = e.target.tagName;
2763 if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
2764 if (document.getElementById('trImageModal')) return;
2765 if (e.key === ']') { e.preventDefault(); navigateSegment(1); }
2766 else if (e.key === '[') { e.preventDefault(); navigateSegment(-1); }
2767 });
2768
2769 // Click on timeline to set range
2770 timeline.addEventListener('click', (e) => {
2771 if (e.target.closest('.tr-sel')) return;
2772 const box = timeline.getBoundingClientRect();
2773 const py = e.clientY - box.top;
2774 let mid = snap(mFromY(py));
2775 let start = Math.max(timelineStart, Math.min(timelineEnd - DEFAULT_LEN, mid - DEFAULT_LEN / 2));
2776 start = snap(start);
2777 range = { start, end: snap(start + DEFAULT_LEN) };
2778 renderTimeline();
2779 updateZoom();
2780 });
2781
2782 // Drag handlers for main selection
2783 function onPointerMove(ev) {
2784 if (!drag) return;
2785 ev.preventDefault();
2786 const dy = ev.clientY - drag.y0;
2787 const dMin = Math.round((dy / ppm) / STEP) * STEP;
2788
2789 if (drag.mode === 'move') {
2790 const len = drag.r0.end - drag.r0.start;
2791 let s = drag.r0.start + dMin;
2792 s = Math.max(timelineStart, Math.min(timelineEnd - len, s));
2793 s = snap(s);
2794 range = { start: s, end: snap(s + len) };
2795 } else if (drag.mode === 'start') {
2796 let s = drag.r0.start + dMin;
2797 s = Math.max(timelineStart, Math.min(drag.r0.end - MIN_LEN, s));
2798 range = { start: snap(s), end: snap(drag.r0.end) };
2799 } else if (drag.mode === 'end') {
2800 let e = drag.r0.end + dMin;
2801 e = Math.min(timelineEnd, Math.max(drag.r0.start + MIN_LEN, e));
2802 range = { start: snap(drag.r0.start), end: snap(e) };
2803 }
2804 renderTimeline();
2805 updateZoom();
2806 }
2807
2808 function onPointerUp() {
2809 drag = null;
2810 document.body.classList.remove('tr-dragging');
2811 window.removeEventListener('pointermove', onPointerMove);
2812 window.removeEventListener('pointerup', onPointerUp);
2813 }
2814
2815 function beginDrag(mode) {
2816 return (ev) => {
2817 ev.stopPropagation();
2818 ev.preventDefault();
2819 document.body.classList.add('tr-dragging');
2820 drag = { mode, y0: ev.clientY, r0: { ...range } };
2821 window.addEventListener('pointermove', onPointerMove);
2822 window.addEventListener('pointerup', onPointerUp);
2823 };
2824 }
2825
2826 sel.addEventListener('pointerdown', beginDrag('move'));
2827 sel.querySelector('[data-handle="start"]').addEventListener('pointerdown', beginDrag('start'));
2828 sel.querySelector('[data-handle="end"]').addEventListener('pointerdown', beginDrag('end'));
2829
2830 function getMonitorPosition(monitor) {
2831 if (!monitor) return null;
2832 // Extract position from monitor string (e.g., "center_DP-3" -> "Center")
2833 const pos = monitor.split('_')[0];
2834 if (!pos) return null;
2835 // Capitalize first letter
2836 return pos.charAt(0).toUpperCase() + pos.slice(1);
2837 }
2838
2839 // Clear selection and reset UI state
2840 function clearSegmentSelection() {
2841 selectedSegment = null;
2842 segmentData = null;
2843 currentVideoFiles = {};
2844 activeTab = null;
2845 tabPanes = {};
2846 screenDecoded = false;
2847 frameCapture.clear();
2848 allScreenFrames = [];
2849 currentFrameIndex = -1;
2850 groupEntriesByIdx.clear();
2851
2852 // Stop and clear audio player reference
2853 panel.querySelectorAll('audio').forEach(audio => audio.pause());
2854
2855 // Hide delete button
2856 deleteBtn.classList.remove('visible');
2857 document.getElementById('trNavHint').classList.remove('visible');
2858
2859 // Clear URL hash
2860 history.replaceState(null, '', window.location.pathname);
2861
2862 // Reset UI
2863 titleEl.textContent = 'Transcripts';
2864 rangeText.textContent = '';
2865 tabsContainer.innerHTML = '';
2866 tabsContainer.classList.remove('visible');
2867 document.getElementById('trWarningNotice').classList.remove('visible');
2868 panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript');
2869
2870 // Clear active state in zoom view
2871 zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => {
2872 pill.classList.remove('tr-active');
2873 });
2874 }
2875
2876 // Delete segment handler
2877 deleteBtn.addEventListener('click', async () => {
2878 if (!selectedSegment) return;
2879
2880 const seg = selectedSegment;
2881 const confirmMsg = `Delete segment ${seg.start} - ${seg.end}?\n\n` +
2882 `This will permanently remove all audio, screen recordings, and transcripts for this segment.\n\n` +
2883 `This cannot be undone.`;
2884
2885 if (!confirm(confirmMsg)) return;
2886
2887 try {
2888 const response = await fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, {
2889 method: 'DELETE'
2890 });
2891
2892 if (!response.ok) {
2893 const data = await response.json().catch(() => ({}));
2894 throw new Error(data.error || 'Failed to delete segment');
2895 }
2896
2897 // Remove segment from local state
2898 allSegments = allSegments.filter(s => s.key !== seg.key);
2899
2900 // Clear selection and UI
2901 clearSegmentSelection();
2902
2903 // Re-render zoom timeline
2904 buildZoomSegments();
2905
2906 // Refresh range indicators on left timeline
2907 fetch(`/app/transcripts/api/ranges/${day}`)
2908 .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges'))
2909 .then(data => {
2910 // Clear and rebuild segment indicators
2911 segmentsLane.innerHTML = '';
2912 (data.audio || []).forEach(rg => {
2913 const [s, e] = rg.map(parseTime);
2914 addSegmentIndicator('audio', s, e, 0);
2915 });
2916 (data.screen || []).forEach(rg => {
2917 const [s, e] = rg.map(parseTime);
2918 addSegmentIndicator('screen', s, e, 1);
2919 });
2920 })
2921 .catch(() => {
2922 // Range indicators may be stale, but segment is deleted
2923 });
2924
2925 } catch (err) {
2926 const notice = document.createElement('div');
2927 notice.className = 'tr-warning-notice visible';
2928 notice.style.borderColor = '#fca5a5';
2929 notice.style.background = '#fef2f2';
2930 notice.style.color = '#991b1b';
2931 notice.innerHTML = '<svg viewBox="0 0 24 24"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg> <span>' + escapeHtml(err.message) + '</span>';
2932 panel.insertBefore(notice, panel.firstChild);
2933 setTimeout(() => notice.remove(), 5000);
2934 }
2935 });
2936
2937})();
2938</script>