personal memory agent
0
fork

Configure Feed

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

transcripts: switch segment panel to tabbed views

Replace the right-panel checkbox/toggle model with a tab-based segment panel.\n\nAdd tabs for Transcript (default), Audio, Screen, and per-segment markdown files.\nUse cached tab panes for instant switching, render markdown tabs via marked, and keep Screen decoding lazy by preparing frames only on first Screen-tab activation.\n\nUpdate the segment API payload to include md_files (stem -> content) so markdown tabs render from a single segment fetch.

+314 -149
+10
apps/transcripts/routes.py
··· 10 10 import shutil 11 11 from datetime import date 12 12 from glob import glob 13 + from pathlib import Path 13 14 from typing import Any 14 15 15 16 from flask import ( ··· 336 337 # Get cost data for this segment 337 338 cost_data = get_usage_cost(day, segment=segment_key) 338 339 340 + # Collect agent .md files 341 + md_files = {} 342 + for md_path in sorted(Path(segment_dir).glob("*.md")): 343 + try: 344 + md_files[md_path.stem] = md_path.read_text() 345 + except Exception: 346 + continue 347 + 339 348 return jsonify( 340 349 { 341 350 "chunks": chunks, 342 351 "audio_file": audio_file_url, 343 352 "video_files": video_files, 353 + "md_files": md_files, 344 354 "segment_key": segment_key, 345 355 "cost": cost_data["cost"], 346 356 "media_sizes": media_sizes,
+304 -149
apps/transcripts/workspace.html
··· 198 198 font-size: 14px; 199 199 } 200 200 201 - .tr-controls { 202 - display: flex; 203 - align-items: center; 204 - gap: 12px; 205 - flex-wrap: wrap; 201 + .tr-tabs { 202 + gap: 8px; 203 + padding: 8px 16px; 204 + border-bottom: 1px solid #e5e7eb; 205 + flex-shrink: 0; 206 + display: none; 206 207 } 207 208 208 - .tr-checkbox-label { 209 + .tr-tabs.visible { 209 210 display: flex; 210 - align-items: center; 211 - gap: 4px; 211 + } 212 + 213 + .tr-tab { 214 + padding: 6px 14px; 215 + border: 1px solid #d1d5db; 216 + border-radius: 6px; 217 + font-size: 13px; 212 218 cursor: pointer; 219 + background: #fff; 220 + color: #374151; 221 + transition: all 0.15s; 222 + } 223 + 224 + .tr-tab:hover { 225 + background: #f9fafb; 226 + } 227 + 228 + .tr-tab.active { 229 + background: #3b82f6; 230 + border-color: #3b82f6; 231 + color: #fff; 232 + } 233 + 234 + .tr-tab-pane { 235 + display: none; 236 + height: 100%; 237 + } 238 + 239 + .tr-tab-pane.active { 240 + display: block; 241 + } 242 + 243 + .tr-md-content { 244 + padding: 16px; 245 + line-height: 1.6; 213 246 font-size: 14px; 247 + } 248 + 249 + .tr-md-content h1, .tr-md-content h2, .tr-md-content h3 { 250 + margin-top: 16px; 251 + margin-bottom: 8px; 252 + } 253 + 254 + .tr-md-content p { 255 + margin-bottom: 12px; 256 + } 257 + 258 + .tr-md-content ul, .tr-md-content ol { 259 + margin-bottom: 12px; 260 + padding-left: 24px; 261 + } 262 + 263 + .tr-md-content code { 264 + background: #f3f4f6; 265 + padding: 2px 6px; 266 + border-radius: 4px; 267 + font-size: 13px; 268 + } 269 + 270 + .tr-md-content pre { 271 + background: #f3f4f6; 272 + padding: 12px; 273 + border-radius: 6px; 274 + overflow-x: auto; 275 + margin-bottom: 12px; 276 + } 277 + 278 + .tr-screen-text { 279 + padding: 8px 12px; 280 + color: #6b7280; 281 + font-size: 13px; 282 + border-left: 3px solid #e5e7eb; 283 + margin: 4px 0; 214 284 } 215 285 216 286 /* Delete button */ ··· 791 861 <h2 class="tr-title">Transcript Preview</h2> 792 862 <div class="tr-range-text" id="trRangeText">Select a segment to view</div> 793 863 </div> 794 - <div class="tr-controls"> 795 - <label class="tr-checkbox-label"> 796 - <input type="checkbox" id="trAudioCheck" checked> 797 - <span>Audio</span> 798 - </label> 799 - <label class="tr-checkbox-label"> 800 - <input type="checkbox" id="trScreenCheck" checked> 801 - <span>Screen</span> 802 - </label> 803 - <button type="button" id="trDeleteBtn" class="tr-delete-btn" title="Delete segment"> 804 - <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 805 - <polyline points="3 6 5 6 21 6"></polyline> 806 - <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> 807 - </svg> 808 - </button> 809 - </div> 864 + <button type="button" id="trDeleteBtn" class="tr-delete-btn" title="Delete segment"> 865 + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 866 + <polyline points="3 6 5 6 21 6"></polyline> 867 + <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> 868 + </svg> 869 + </button> 810 870 </div> 871 + <div class="tr-tabs" id="trTabs"></div> 811 872 <div class="tr-panel" id="trPanel"></div> 812 873 </div> 813 874 </div> ··· 847 908 848 909 // Elements - content panel 849 910 const rangeText = document.getElementById('trRangeText'); 911 + const tabsContainer = document.getElementById('trTabs'); 850 912 const panel = document.getElementById('trPanel'); 851 - const audioCheck = document.getElementById('trAudioCheck'); 852 - const screenCheck = document.getElementById('trScreenCheck'); 853 913 const deleteBtn = document.getElementById('trDeleteBtn'); 854 914 855 915 // State ··· 1210 1270 return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; 1211 1271 } 1212 1272 1213 - // Build range text with cost and media size, reactive to audio/screen toggles 1273 + // Build range text with cost and total media size 1214 1274 function updateRangeText() { 1215 1275 if (!selectedSegment || !segmentData) return; 1216 1276 const seg = selectedSegment; ··· 1225 1285 if (sizes) { 1226 1286 let total = 0; 1227 1287 const breakdown = []; 1228 - if (audioCheck.checked && sizes.audio) { 1288 + if (sizes.audio) { 1229 1289 total += sizes.audio; 1230 1290 breakdown.push('audio ' + formatSize(sizes.audio)); 1231 1291 } 1232 - if (screenCheck.checked && sizes.screen) { 1292 + if (sizes.screen) { 1233 1293 total += sizes.screen; 1234 1294 breakdown.push('screen ' + formatSize(sizes.screen)); 1235 1295 } ··· 1434 1494 1435 1495 // Unified timeline state 1436 1496 let segmentData = null; 1437 - let segmentAudioEl = null; 1438 1497 let currentVideoFiles = {}; // filename -> video URL mapping 1439 1498 let groupEntriesByIdx = new Map(); 1499 + let activeTab = null; 1500 + let tabPanes = {}; // tabId -> pane element 1501 + let screenDecoded = false; 1440 1502 1441 1503 function loadSegmentContent(seg) { 1442 - const showAudio = audioCheck.checked; 1443 - const showScreen = screenCheck.checked; 1444 1504 const segmentToken = seg.key; 1445 1505 1446 - // Clear old data, videos, and show loading message immediately 1506 + // Clear old data, videos, tabs, and show loading message immediately 1447 1507 segmentData = null; 1448 1508 currentVideoFiles = {}; 1509 + tabPanes = {}; 1510 + activeTab = null; 1511 + screenDecoded = false; 1449 1512 frameCapture.clear(); 1450 - panel.innerHTML = '<div class="tr-unified-empty"><p id="trLoadingStatus">Loading segment...</p></div>'; 1513 + tabsContainer.classList.remove('visible'); 1514 + tabsContainer.innerHTML = ''; 1515 + panel.innerHTML = '<div class="tr-unified-empty"><p>Loading segment...</p></div>'; 1451 1516 1452 - // Use new unified segment endpoint 1453 1517 fetch(`/app/transcripts/api/segment/${day}/${seg.key}`) 1454 1518 .then(r => r.json()) 1455 1519 .then(data => { 1456 - if (selectedSegment && selectedSegment.key !== segmentToken) { 1520 + if (!selectedSegment || selectedSegment.key !== segmentToken) { 1457 1521 return; 1458 1522 } 1459 1523 segmentData = data; 1460 - currentVideoFiles = showScreen ? (data.video_files || {}) : {}; 1524 + updateRangeText(); 1525 + buildTabBar(data); 1526 + activateTab('transcript'); 1527 + }) 1528 + .catch(() => { 1529 + tabsContainer.classList.remove('visible'); 1530 + tabsContainer.innerHTML = ''; 1531 + panel.innerHTML = '<p>Error loading transcript.</p>'; 1532 + }); 1533 + } 1461 1534 1462 - // Update range text with cost and media sizes 1463 - updateRangeText(); 1535 + function prepareScreenFrames(data, targetEl, segmentToken) { 1536 + if (screenDecoded) { 1537 + return Promise.resolve(); 1538 + } 1464 1539 1465 - // Skip keyframe prefetching when screen is disabled 1466 - if (!showScreen) { 1467 - renderSegmentTimeline(data, showAudio, showScreen); 1468 - return; 1469 - } 1540 + const isStaleSegment = () => !selectedSegment || selectedSegment.key !== segmentToken; 1541 + if (isStaleSegment()) { 1542 + return Promise.resolve(); 1543 + } 1470 1544 1471 - const nonBasicByVideo = new Map(); 1472 - (data.chunks || []).forEach(chunk => { 1473 - if (chunk.type !== 'screen') return; 1474 - if (chunk.basic === true) return; 1475 - const filename = chunk.source_ref?.filename; 1476 - const frameId = chunk.source_ref?.frame_id; 1477 - if (!filename || !frameId) return; 1478 - if (!nonBasicByVideo.has(filename)) { 1479 - nonBasicByVideo.set(filename, new Set()); 1480 - } 1481 - nonBasicByVideo.get(filename).add(frameId); 1482 - }); 1545 + currentVideoFiles = data.video_files || {}; 1483 1546 1484 - const totalFrames = Array.from(nonBasicByVideo.values()).reduce( 1485 - (sum, frames) => sum + frames.size, 1486 - 0 1487 - ); 1488 - const perVideoProgress = new Map(); 1489 - let lastStatusUpdate = 0; 1547 + const nonBasicByVideo = new Map(); 1548 + (data.chunks || []).forEach(chunk => { 1549 + if (chunk.type !== 'screen') return; 1550 + if (chunk.basic === true) return; 1551 + const filename = chunk.source_ref?.filename; 1552 + const frameId = chunk.source_ref?.frame_id; 1553 + if (!filename || !frameId) return; 1554 + if (!nonBasicByVideo.has(filename)) { 1555 + nonBasicByVideo.set(filename, new Set()); 1556 + } 1557 + nonBasicByVideo.get(filename).add(frameId); 1558 + }); 1490 1559 1491 - const updateLoadingStatus = (done) => { 1492 - const statusEl = document.getElementById('trLoadingStatus'); 1493 - if (!statusEl) return; 1494 - if (!totalFrames) { 1495 - statusEl.textContent = 'Loading segment...'; 1496 - return; 1497 - } 1498 - const decoded = Array.from(perVideoProgress.values()).reduce((sum, count) => sum + count, 0); 1499 - const pct = Math.min(100, Math.round((decoded / totalFrames) * 100)); 1500 - statusEl.textContent = done 1501 - ? 'Rendering transcript...' 1502 - : `Decoding key frames ${decoded}/${totalFrames} (${pct}%)...`; 1503 - }; 1560 + const totalFrames = Array.from(nonBasicByVideo.values()).reduce( 1561 + (sum, frames) => sum + frames.size, 1562 + 0 1563 + ); 1564 + const perVideoProgress = new Map(); 1565 + let lastStatusUpdate = 0; 1504 1566 1505 - const makeProgressHandler = (videoUrl) => (count) => { 1506 - const now = Date.now(); 1507 - perVideoProgress.set(videoUrl, count); 1508 - if (now - lastStatusUpdate > 150) { 1509 - lastStatusUpdate = now; 1510 - updateLoadingStatus(false); 1511 - } 1512 - }; 1567 + const updateLoadingStatus = (done) => { 1568 + if (isStaleSegment()) return; 1569 + const statusEl = targetEl.querySelector('[data-role="loading-status"]'); 1570 + if (!statusEl) return; 1571 + if (!totalFrames) { 1572 + statusEl.textContent = 'Loading screen entries...'; 1573 + return; 1574 + } 1575 + const decoded = Array.from(perVideoProgress.values()).reduce((sum, count) => sum + count, 0); 1576 + const pct = Math.min(100, Math.round((decoded / totalFrames) * 100)); 1577 + statusEl.textContent = done 1578 + ? 'Rendering screen entries...' 1579 + : `Decoding key frames ${decoded}/${totalFrames} (${pct}%)...`; 1580 + }; 1513 1581 1582 + const makeProgressHandler = (videoUrl) => (count) => { 1583 + if (isStaleSegment()) return; 1584 + const now = Date.now(); 1585 + perVideoProgress.set(videoUrl, count); 1586 + if (now - lastStatusUpdate > 150) { 1587 + lastStatusUpdate = now; 1514 1588 updateLoadingStatus(false); 1589 + } 1590 + }; 1591 + 1592 + updateLoadingStatus(false); 1593 + 1594 + const decodeJobs = []; 1595 + Object.entries(currentVideoFiles).forEach(([filename, url]) => { 1596 + const frameIds = Array.from(nonBasicByVideo.get(filename) || []); 1597 + if (frameIds.length > 0) { 1598 + decodeJobs.push(frameCapture.prefetchThumbnails(url, frameIds, makeProgressHandler(url))); 1599 + } 1600 + }); 1515 1601 1516 - const decodeJobs = []; 1517 - Object.entries(currentVideoFiles).forEach(([filename, url]) => { 1518 - const frameIds = Array.from(nonBasicByVideo.get(filename) || []); 1519 - if (frameIds.length > 0) { 1520 - decodeJobs.push(frameCapture.prefetchThumbnails(url, frameIds, makeProgressHandler(url))); 1521 - } 1522 - }); 1602 + if (decodeJobs.length === 0) { 1603 + screenDecoded = true; 1604 + return Promise.resolve(); 1605 + } 1606 + 1607 + return Promise.all(decodeJobs) 1608 + .then(() => { 1609 + if (isStaleSegment()) return; 1610 + screenDecoded = true; 1611 + updateLoadingStatus(true); 1612 + }) 1613 + .catch(() => { 1614 + if (isStaleSegment()) return; 1615 + screenDecoded = true; 1616 + updateLoadingStatus(true); 1617 + }); 1618 + } 1619 + 1620 + function buildTabBar(data) { 1621 + tabsContainer.innerHTML = ''; 1622 + tabsContainer.classList.remove('visible'); 1623 + panel.innerHTML = ''; 1624 + tabPanes = {}; 1625 + activeTab = null; 1626 + screenDecoded = false; 1627 + 1628 + const addTab = (tabId, label) => { 1629 + const btn = document.createElement('button'); 1630 + btn.type = 'button'; 1631 + btn.className = 'tr-tab'; 1632 + btn.dataset.tab = tabId; 1633 + btn.textContent = label; 1634 + btn.addEventListener('click', () => activateTab(tabId)); 1635 + tabsContainer.appendChild(btn); 1636 + }; 1637 + 1638 + addTab('transcript', 'Transcript'); 1639 + if (data.audio_file) { 1640 + addTab('audio', 'Audio'); 1641 + } 1642 + if ((data.chunks || []).some(chunk => chunk.type === 'screen')) { 1643 + addTab('screen', 'Screen'); 1644 + } 1645 + 1646 + const mdStems = Object.keys(data.md_files || {}).sort((a, b) => a.localeCompare(b)); 1647 + mdStems.forEach(stem => addTab(`md-${stem}`, stem)); 1648 + 1649 + tabsContainer.classList.add('visible'); 1650 + } 1651 + 1652 + function activateTab(tabId) { 1653 + if (!segmentData || tabId === activeTab) { 1654 + return; 1655 + } 1523 1656 1524 - if (decodeJobs.length === 0) { 1525 - renderSegmentTimeline(data, showAudio, showScreen); 1526 - return; 1527 - } 1657 + tabsContainer.querySelectorAll('.tr-tab').forEach(tab => { 1658 + tab.classList.toggle('active', tab.dataset.tab === tabId); 1659 + }); 1660 + 1661 + Object.values(tabPanes).forEach(pane => pane.classList.remove('active')); 1662 + 1663 + let pane = tabPanes[tabId]; 1664 + if (!pane) { 1665 + pane = document.createElement('div'); 1666 + pane.className = 'tr-tab-pane'; 1667 + pane.dataset.tab = tabId; 1668 + panel.appendChild(pane); 1669 + tabPanes[tabId] = pane; 1528 1670 1529 - Promise.all(decodeJobs) 1671 + if (tabId === 'transcript') { 1672 + renderSegmentTimeline(segmentData, true, true, pane); 1673 + } else if (tabId === 'audio') { 1674 + renderSegmentTimeline(segmentData, true, false, pane); 1675 + } else if (tabId === 'screen') { 1676 + const segmentToken = selectedSegment?.key; 1677 + pane.innerHTML = '<div class="tr-unified-empty"><p data-role="loading-status">Loading screen entries...</p></div>'; 1678 + prepareScreenFrames(segmentData, pane, segmentToken) 1530 1679 .then(() => { 1531 - if (selectedSegment && selectedSegment.key !== segmentToken) { 1680 + if (!selectedSegment || selectedSegment.key !== segmentToken) { 1532 1681 return; 1533 1682 } 1534 - updateLoadingStatus(true); 1535 - renderSegmentTimeline(data, showAudio, showScreen); 1683 + if (tabPanes[tabId] !== pane) { 1684 + return; 1685 + } 1686 + renderSegmentTimeline(segmentData, false, true, pane); 1536 1687 }) 1537 1688 .catch(() => { 1538 - if (selectedSegment && selectedSegment.key !== segmentToken) { 1689 + if (!selectedSegment || selectedSegment.key !== segmentToken) { 1690 + return; 1691 + } 1692 + if (tabPanes[tabId] !== pane) { 1539 1693 return; 1540 1694 } 1541 - updateLoadingStatus(true); 1542 - renderSegmentTimeline(data, showAudio, showScreen); 1695 + pane.innerHTML = '<p>Error loading screen entries.</p>'; 1543 1696 }); 1544 - }) 1545 - .catch(() => { 1546 - panel.innerHTML = '<p>Error loading transcript.</p>'; 1547 - }); 1697 + } else if (tabId.startsWith('md-')) { 1698 + const stem = tabId.slice(3); 1699 + const content = (segmentData.md_files || {})[stem] || ''; 1700 + pane.innerHTML = `<div class="tr-md-content">${marked.parse(content)}</div>`; 1701 + } 1702 + } 1703 + 1704 + pane.classList.add('active'); 1705 + activeTab = tabId; 1548 1706 } 1549 1707 1550 - function renderSegmentTimeline(data, showAudio, showScreen) { 1708 + function renderSegmentTimeline(data, showAudio, showScreen, targetEl) { 1551 1709 const chunks = (data.chunks || []).filter(c => { 1552 1710 if (c.type === 'audio' && !showAudio) return false; 1553 1711 if (c.type === 'screen' && !showScreen) return false; 1554 1712 return true; 1555 1713 }); 1714 + 1715 + const textOnlyScreen = showScreen && Object.keys(currentVideoFiles).length === 0; 1556 1716 1557 1717 // Build flat list of all screen frames for modal navigation 1558 1718 allScreenFrames = chunks.filter(c => c.type === 'screen'); 1559 1719 currentFrameIndex = -1; 1560 1720 1561 1721 if (chunks.length === 0) { 1562 - panel.innerHTML = '<div class="tr-unified-empty"><p>No entries to display. Enable Audio or Screen above.</p></div>'; 1722 + targetEl.innerHTML = '<div class="tr-unified-empty"><p>No entries to display for this tab.</p></div>'; 1563 1723 return; 1564 1724 } 1565 1725 1566 1726 // Group sequential basic screen frames together 1567 - const displayItems = groupBasicScreenFrames(chunks); 1727 + const displayItems = textOnlyScreen ? chunks : groupBasicScreenFrames(chunks); 1568 1728 groupEntriesByIdx = new Map(); 1569 1729 1570 1730 let html = '<div class="tr-unified">'; ··· 1574 1734 html += '<div class="tr-audio-players">'; 1575 1735 html += '<div class="tr-audio-player">'; 1576 1736 html += '<div class="tr-audio-player-label">Segment Audio</div>'; 1577 - html += `<audio id="trSegmentAudio" controls preload="metadata"><source src="${data.audio_file}" type="audio/flac">Your browser does not support audio.</audio>`; 1737 + html += `<audio data-role="segment-audio" controls preload="metadata"><source src="${data.audio_file}" type="audio/flac">Your browser does not support audio.</audio>`; 1578 1738 html += '</div></div>'; 1579 1739 } 1580 1740 ··· 1592 1752 html += `<div class="tr-entry-text">${escapeHtml(item.markdown)}</div>`; 1593 1753 html += '</div></div>'; 1594 1754 } else if (item.type === 'screen') { 1595 - // Enhanced screen frame - render fully 1596 - html += renderEnhancedScreenEntry(item, idx); 1755 + if (textOnlyScreen) { 1756 + const timeStr = item.time || ''; 1757 + const markdown = item.markdown ? marked.parse(item.markdown) : 'Screen activity'; 1758 + html += '<div class="tr-entry">'; 1759 + html += `<div class="tr-entry-time">${timeStr}</div>`; 1760 + html += '<div class="tr-entry-content">'; 1761 + html += `<div class="tr-screen-text">${markdown}</div>`; 1762 + html += '</div></div>'; 1763 + } else { 1764 + // Enhanced screen frame - render fully 1765 + html += renderEnhancedScreenEntry(item, idx); 1766 + } 1597 1767 } 1598 1768 }); 1599 1769 1600 1770 html += '</div>'; 1601 - panel.innerHTML = html; 1771 + targetEl.innerHTML = html; 1602 1772 1603 1773 // Get audio element reference 1604 - segmentAudioEl = document.getElementById('trSegmentAudio'); 1774 + const paneAudioEl = targetEl.querySelector('audio[data-role="segment-audio"]'); 1605 1775 1606 1776 // Add click handlers for audio entries to seek 1607 - panel.querySelectorAll('.tr-entry-audio').forEach(entry => { 1777 + targetEl.querySelectorAll('.tr-entry-audio').forEach(entry => { 1608 1778 entry.addEventListener('click', () => { 1609 - if (segmentAudioEl && segmentData?.audio_file) { 1779 + if (paneAudioEl && segmentData?.audio_file) { 1610 1780 const timestamp = parseInt(entry.dataset.timestamp, 10); 1611 1781 const baseTimestamp = chunks[0]?.timestamp || timestamp; 1612 1782 const offsetSec = (timestamp - baseTimestamp) / 1000; 1613 - segmentAudioEl.currentTime = Math.max(0, offsetSec); 1614 - segmentAudioEl.play(); 1783 + paneAudioEl.currentTime = Math.max(0, offsetSec); 1784 + paneAudioEl.play(); 1615 1785 } 1616 1786 }); 1617 1787 }); 1618 1788 1619 1789 // Add click handlers for enhanced screen entries to open modal 1620 - panel.querySelectorAll('.tr-entry-screen').forEach(entry => { 1790 + targetEl.querySelectorAll('.tr-entry-screen').forEach(entry => { 1621 1791 const thumb = entry.querySelector('.tr-entry-thumb'); 1622 1792 if (thumb) { 1623 1793 thumb.style.cursor = 'pointer'; ··· 1630 1800 }); 1631 1801 1632 1802 // Add click handlers for group headers to expand/collapse 1633 - panel.querySelectorAll('.tr-group-header').forEach(header => { 1803 + targetEl.querySelectorAll('.tr-group-header').forEach(header => { 1634 1804 header.addEventListener('click', () => { 1635 1805 const groupEl = header.parentElement; 1636 1806 const isExpanded = groupEl.classList.toggle('expanded'); ··· 1639 1809 const groupIdx = parseInt(groupEl.dataset.idx, 10); 1640 1810 if (isNaN(groupIdx)) return; 1641 1811 const entries = groupEntriesByIdx.get(groupIdx) || []; 1642 - prefetchGroupThumbnails(entries, groupEl); 1812 + prefetchGroupThumbnails(entries, groupEl, targetEl); 1643 1813 }); 1644 1814 }); 1645 1815 1646 1816 // Add click handlers for group grid items to open modal 1647 - panel.querySelectorAll('.tr-group-item').forEach(item => { 1817 + targetEl.querySelectorAll('.tr-group-item').forEach(item => { 1648 1818 item.addEventListener('click', () => { 1649 1819 const frameIdx = parseInt(item.dataset.frameIdx, 10); 1650 1820 if (!isNaN(frameIdx)) openImageModal(frameIdx); ··· 1652 1822 }); 1653 1823 1654 1824 // Set up lazy loading for canvas thumbnails using IntersectionObserver 1655 - setupLazyCanvasLoading(); 1825 + if (!textOnlyScreen) { 1826 + setupLazyCanvasLoading(targetEl); 1827 + } 1656 1828 } 1657 1829 1658 - function prefetchGroupThumbnails(entries, groupEl) { 1830 + function prefetchGroupThumbnails(entries, groupEl, targetEl) { 1659 1831 const frameIdsByVideo = new Map(); 1660 1832 for (const entry of entries) { 1661 1833 const filename = entry.source_ref?.filename; ··· 1677 1849 if (jobs.length > 0) { 1678 1850 groupEl.dataset.prefetched = 'true'; 1679 1851 Promise.all(jobs).finally(() => { 1680 - setupLazyCanvasLoading(); 1852 + setupLazyCanvasLoading(targetEl); 1681 1853 }); 1682 1854 } 1683 1855 } 1684 1856 1685 1857 // Lazy load canvas thumbnails when they become visible 1686 - function setupLazyCanvasLoading() { 1687 - const canvases = panel.querySelectorAll('canvas[data-video-url]'); 1858 + function setupLazyCanvasLoading(targetEl = panel) { 1859 + const canvases = targetEl.querySelectorAll('canvas[data-video-url]'); 1688 1860 if (canvases.length === 0) return; 1689 1861 1690 1862 const observer = new IntersectionObserver((entries) => { ··· 2125 2297 return pos.charAt(0).toUpperCase() + pos.slice(1); 2126 2298 } 2127 2299 2128 - // Re-render when checkboxes change (use cached data) 2129 - audioCheck.addEventListener('change', () => { 2130 - if (selectedSegment && segmentData) { 2131 - updateRangeText(); 2132 - renderSegmentTimeline(segmentData, audioCheck.checked, screenCheck.checked); 2133 - } 2134 - }); 2135 - 2136 - screenCheck.addEventListener('change', () => { 2137 - if (selectedSegment && segmentData) { 2138 - // Restore video files when screen is re-enabled 2139 - if (screenCheck.checked && Object.keys(currentVideoFiles).length === 0) { 2140 - currentVideoFiles = segmentData.video_files || {}; 2141 - } 2142 - updateRangeText(); 2143 - renderSegmentTimeline(segmentData, audioCheck.checked, screenCheck.checked); 2144 - } 2145 - }); 2146 - 2147 2300 // Clear selection and reset UI state 2148 2301 function clearSegmentSelection() { 2149 2302 selectedSegment = null; 2150 2303 segmentData = null; 2151 2304 currentVideoFiles = {}; 2305 + activeTab = null; 2306 + tabPanes = {}; 2307 + screenDecoded = false; 2152 2308 frameCapture.clear(); 2153 2309 allScreenFrames = []; 2154 2310 currentFrameIndex = -1; 2155 2311 groupEntriesByIdx.clear(); 2156 2312 2157 2313 // Stop and clear audio player reference 2158 - if (segmentAudioEl) { 2159 - segmentAudioEl.pause(); 2160 - segmentAudioEl = null; 2161 - } 2314 + panel.querySelectorAll('audio').forEach(audio => audio.pause()); 2162 2315 2163 2316 // Hide delete button 2164 2317 deleteBtn.classList.remove('visible'); ··· 2168 2321 2169 2322 // Reset UI 2170 2323 rangeText.textContent = 'Select a segment to view'; 2324 + tabsContainer.innerHTML = ''; 2325 + tabsContainer.classList.remove('visible'); 2171 2326 panel.innerHTML = ''; 2172 2327 2173 2328 // Clear active state in zoom view