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