personal memory agent
0
fork

Configure Feed

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

at main 3555 lines 104 kB view raw
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()">&times;</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">&times;</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>