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