Something went wrong. Try again.
personal memory agent
Something went wrong. Try again.
102 kB · 3527 lines
HTML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528{# Transcript viewer - dual-timeline interface #}
<style>/* Transcripts app styles - all classes prefixed with .tr- to avoid conflicts */
/* * Layout context: * - date_nav: true adds has-date-nav to body, which adds date-nav-height to workspace margin-top * - Need to account for facet-bar + date-nav at top * * Height calculation: * - 100vh (viewport) * - minus 60px (facet-bar-height) * - minus 40px (date-nav-height) */
/* Lock workspace scrolling - content must fit, only .tr-panel scrolls */body.has-date-nav .workspace:has(.tr-wrap) { overflow: hidden; height: calc(100vh - var(--facet-bar-height) - var(--date-nav-height));}
body.has-app-bar.has-date-nav .workspace:has(.tr-wrap) { height: calc( 100vh - var(--facet-bar-height) - var(--date-nav-height) - var(--app-bar-height) - 80px );}
/* Main container */.tr-wrap { max-width: 1400px; margin: 0 auto; padding: 16px 24px; box-sizing: border-box; height: 100%; overflow: hidden;}
.tr-card { height: 100%; background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; display: grid; --tr-day-col: 200px; --tr-zoom-col: 120px; grid-template-columns: var(--tr-day-col) var(--tr-zoom-col) 1fr; overflow: hidden;}
/* Left timeline */.tr-timeline { position: relative; border-right: 1px solid #d1d5db; user-select: none; height: 100%; overflow: hidden; touch-action: pan-y; cursor: crosshair; padding: 12px 0; box-sizing: border-box;}
.tr-timeline-label { position: absolute; top: 0; left: 64px; right: 0; height: 16px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; pointer-events: none; z-index: 3; border-bottom: 1px solid #e5e7eb;}
.tr-timeline-legend { position: absolute; bottom: 0; left: 0; right: 0; height: 12px; display: flex; align-items: center; justify-content: center; gap: 12px; font-size: 12px; color: #9ca3af; pointer-events: none; z-index: 3;}
.tr-legend-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle;}
.tr-grid { position: absolute; top: 16px; bottom: 12px; left: 0; right: 0;}
.tr-grid-hour { position: absolute; left: 0; right: 0; border-top: 1px solid #e5e7eb;}
.tr-grid-quarter { position: absolute; left: 0; right: 0; border-top: 1px dashed #f3f4f6;}
.tr-labels { position: absolute; left: 0; top: 16px; bottom: 12px; width: 64px; background: #f9fafb; border-right: 1px solid #e5e7eb; pointer-events: none;}
.tr-label { position: absolute; right: 0; padding-right: 8px; transform: translateY(-50%); font-size: 12px; color: #6b7280; font-variant-numeric: tabular-nums;}
/* Selection box */.tr-sel-wrap { position: absolute; left: 64px; right: 8px; pointer-events: none; z-index: 2;}
.tr-sel { position: absolute; left: 0; right: -4px; height: 100%; background: rgba(239, 246, 255, 0.7); border: 1px solid #c7d2fe; border-radius: 12px; box-shadow: 0 1px 2px rgba(0,0,0,.06); pointer-events: auto; cursor: grab; touch-action: none;}
.tr-sel:active { cursor: grabbing;}
.tr-sel:hover { background: rgba(239, 246, 255, 0.85); border-color: #a5b4fc;}
.tr-sel:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 2px;}
.tr-bumper { position: absolute; left: 28px; right: 28px; margin: auto; height: 14px; border: 1px solid #60a5fa; border-radius: 999px; background: #dbeafe; cursor: ns-resize; touch-action: none; transition: background 0.15s ease, border-color 0.15s ease;}
.tr-bumper-top { top: -7px;}
.tr-bumper-bottom { bottom: -7px;}
.tr-bumper:hover { background: #bfdbfe; border-color: #3b82f6;}
.tr-bumper:active { background: #93c5fd; border-color: #2563eb;}
/* Segments lane (audio/screen indicators) */.tr-segments { --seg-col-0: 8px; --seg-col-1: 56px; position: absolute; left: 64px; right: 0; top: 16px; bottom: 12px; pointer-events: none; z-index: 1;}
.tr-seg { position: absolute; width: 40px; border-radius: 12px; box-shadow: 0 1px 1px rgba(0,0,0,.04); pointer-events: auto; cursor: pointer; transition: filter 0.15s ease, box-shadow 0.15s ease;}
.tr-seg:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 1px;}
.tr-seg:hover { filter: brightness(0.92); box-shadow: 0 2px 4px rgba(0,0,0,.10);}
.tr-seg:active { filter: brightness(0.88); box-shadow: 0 1px 2px rgba(0,0,0,.08);}
.tr-seg-col-0 { left: var(--seg-col-0); }.tr-seg-col-1 { left: var(--seg-col-1); }
.tr-seg-audio { background: rgba(134, 239, 172, 0.6); border: 1px solid #4ade80;}
.tr-seg-screen { background: rgba(253, 230, 138, 0.6); border: 1px solid #eab308;}
.tr-now-marker { position: absolute; left: 64px; right: 0; height: 0; border-top: 2px solid #ef4444; z-index: 3; pointer-events: none;}
.tr-now-label { position: absolute; left: 4px; top: -8px; font-size: 12px; color: #ef4444; font-weight: 500; line-height: 1;}
/* Right content panel */.tr-content { padding: 4px 24px 24px; display: flex; flex-direction: column; gap: 16px; height: 100%; box-sizing: border-box; overflow: hidden;}
.tr-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;}
.tr-header > div { display: flex; flex-direction: column; gap: 2px;}
.tr-title { font-size: 20px; font-weight: 600; margin: 0;}
.tr-range-text { color: #6b7280; font-size: 13px; font-variant-numeric: tabular-nums;}
.tr-nav-hint { font-size: 12px; color: #9ca3af; display: none;}
.tr-nav-hint.visible { display: inline;}
.tr-tabs { gap: 8px; padding: 8px 0; border-bottom: 1px solid #e5e7eb; flex-shrink: 0; display: none;}
.tr-tabs.visible { display: flex;}
.tr-tab { padding: 8px; margin: 0 -8px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 13px; cursor: pointer; background: #fff; color: #374151; transition: all 0.15s;}
.tr-tab:hover { background: #f9fafb;}
.tr-tab.active { background: #3b82f6; border-color: #3b82f6; color: #fff;}
.tr-tab:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 1px;}
.tr-tab:active:not(.active) { background: #f3f4f6; border-color: #9ca3af;}
.tr-tab-pane { display: none; height: 100%;}
.tr-tab-pane:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 2px;}
.tr-tab-pane.active { display: block;}
.tr-md-content { padding: 16px; line-height: 1.6; font-size: 14px; max-width: 70ch;}
.tr-md-content h1, .tr-md-content h2, .tr-md-content h3 { margin-top: 16px; margin-bottom: 8px;}
.tr-md-content p { margin-bottom: 12px;}
.tr-md-content ul, .tr-md-content ol { margin-bottom: 12px; padding-left: 24px;}
.tr-md-content code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 13px;}
.tr-md-content pre { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; margin-bottom: 12px;}
.tr-screen-text { padding: 8px 12px; color: #374151; font-size: 13px; border-left: 3px solid #e5e7eb; margin: 4px 0;}
/* Delete button */.tr-delete-btn { display: none; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0; border: 1px solid #e5e7eb; border-radius: 6px; background: #fff; color: #9ca3af; cursor: pointer; transition: all 0.15s; margin-left: 8px;}
.tr-delete-btn:hover { background: #fef2f2; border-color: #fecaca; color: #ef4444;}
.tr-delete-btn:focus-visible { outline: 2px solid #ef4444; outline-offset: 2px;}
.tr-delete-btn:active { background: #fee2e2; border-color: #f87171; color: #dc2626;}
.tr-delete-btn.visible { display: flex;}
.tr-delete-btn svg { width: 16px; height: 16px;}
.tr-panel { flex: 1; padding: 16px; overflow-y: auto; min-height: 0; overflow-x: hidden; font-size: 14px; line-height: 1.5; transition: opacity 0.1s ease;}
.tr-panel.loading { opacity: 0.5;}
.tr-panel pre { white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word;}
.tr-panel code { white-space: pre-wrap; word-wrap: break-word;}
/* Middle zoom timeline (segment selector) */.tr-zoom { position: relative; border-right: 1px solid #e5e7eb; user-select: none; height: 100%; overflow: hidden; padding: 12px 0; box-sizing: border-box;}
.tr-zoom-timeline-label { position: absolute; top: 0; left: 0; right: 0; height: 16px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 700; pointer-events: none; z-index: 3; border-bottom: 1px solid #e5e7eb;}
.tr-zoom-labels { position: absolute; left: 0; top: 16px; bottom: 12px; width: 48px; background: #f9fafb; border-right: 1px solid #e5e7eb; pointer-events: none;}
.tr-zoom-label { position: absolute; right: 0; padding-right: 8px; transform: translateY(-50%); font-size: 12px; color: #6b7280; font-variant-numeric: tabular-nums;}
.tr-zoom-grid { position: absolute; top: 16px; bottom: 12px; left: 0; right: 0;}
.tr-zoom-segments { position: absolute; left: 52px; right: 4px; top: 16px; bottom: 12px;}
.tr-zoom-segments > .surface-state { position: absolute; inset: 0; padding: 16px 12px; gap: 8px;}
.tr-zoom-segments > .surface-state .surface-state-icon svg { width: 32px; height: 32px;}
.tr-zoom-segments > .surface-state .surface-state-heading { font-size: 13px;}
.tr-zoom-segments > .surface-state .surface-state-desc { font-size: 11px; max-width: 24ch;}
/* Segment pills in zoom view */.tr-zoom-pill { position: absolute; left: 0; right: 0; border-radius: 8px; cursor: pointer; transition: all 0.15s ease; box-shadow: 0 1px 2px rgba(0,0,0,.08);}
.tr-zoom-pill:hover { filter: brightness(0.95); box-shadow: 0 2px 4px rgba(0,0,0,.12);}
.tr-zoom-pill.tr-active { box-shadow: 0 0 0 2px rgba(74, 158, 255, 0.5), 0 2px 4px rgba(0,0,0,.12);}
.tr-zoom-pill:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 1px;}
.tr-zoom-pill:active { filter: brightness(0.90); transform: scale(0.98);}
.tr-zoom-pill-audio { background: linear-gradient(135deg, rgba(134, 239, 172, 0.8), rgba(134, 239, 172, 0.6)); border: 1px solid #4ade80;}
.tr-zoom-pill-screen { background: linear-gradient(135deg, rgba(253, 230, 138, 0.8), rgba(253, 230, 138, 0.6)); border: 1px solid #eab308;}
.tr-zoom-pill-both { background: linear-gradient(to right, rgba(134, 239, 172, 0.75), rgba(194, 234, 155, 0.7) 50%, rgba(253, 230, 138, 0.75)); border: 1px solid #84cc16;}
/* Dragging state */.tr-dragging { user-select: none; -webkit-user-select: none;}
/* Image modal */.tr-screenshot-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.85); display: flex; z-index: 1000;}
.tr-modal-nav { width: 48px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: rgba(255,255,255,0.4); transition: all 0.15s; flex-shrink: 0;}
.tr-modal-nav:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.9);}
.tr-modal-nav:active { background: rgba(255,255,255,0.2); color: rgba(255,255,255,1);}
.tr-modal-nav:focus-visible { outline: 2px solid rgba(255,255,255,0.6); outline-offset: -2px;}
.tr-modal-nav.disabled { cursor: default; color: rgba(255,255,255,0.15); pointer-events: none;}
.tr-modal-nav svg { width: 24px; height: 24px;}
.tr-modal-center { flex: 1; display: flex; flex-direction: column; min-width: 0;}
.tr-modal-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; flex-shrink: 0;}
.tr-modal-badge { padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 500; background: rgba(255,255,255,0.15); color: #fff;}
.tr-modal-badge-monitor { background: rgba(124, 58, 237, 0.3); color: #c4b5fd;}
.tr-modal-badge-category { background: rgba(59, 130, 246, 0.3); color: #93c5fd;}
.tr-modal-close { margin-left: auto; width: 36px; height: 36px; background: rgba(255,255,255,0.15); border: none; border-radius: 50%; color: #fff; font-size: 18px; cursor: pointer; transition: background 0.15s ease; display: flex; align-items: center; justify-content: center; flex-shrink: 0;}
.tr-modal-close:hover { background: rgba(255,255,255,0.3);}
.tr-modal-close:active { background: rgba(255,255,255,0.4);}
.tr-modal-close:focus-visible { outline: 2px solid rgba(255,255,255,0.6); outline-offset: 2px;}
.tr-modal-img-wrap { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; padding: 0 20px;}
.tr-modal-img-wrap img,.tr-modal-img-wrap canvas { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 8px; background: #1f2937;}
.tr-modal-img-wrap canvas.loading { animation: tr-pulse 1.5s ease-in-out infinite;}
.tr-modal-img-wrap canvas.tr-masked-canvas { cursor: pointer; transition: opacity 0.15s ease;}
.tr-modal-img-wrap canvas.tr-masked-canvas:hover { opacity: 0.9;}
.tr-modal-badge-masked { background: rgba(239, 68, 68, 0.3); color: #fca5a5;}
.tr-modal-description { padding: 12px 20px 20px; color: rgba(255,255,255,0.8); font-size: 14px; line-height: 1.5; text-align: center; flex-shrink: 0;}
#trDeleteSegmentModal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(15, 23, 42, 0.55); z-index: 1100;}
#trDeleteSegmentModal .modal-content { width: min(520px, 100%); background: #ffffff; border-radius: 16px; box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22); overflow: hidden;}
#trDeleteSegmentModal .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px 16px; border-bottom: 1px solid #e5e7eb;}
#trDeleteSegmentModal .modal-header h3 { margin: 0; font-size: 20px; font-weight: 700; color: #111827;}
#trDeleteSegmentModal .modal-header.danger h3 { color: #991b1b;}
#trDeleteSegmentModal .modal-body { padding: 20px 24px;}
#trDeleteSegmentModal .modal-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0 24px 24px;}
#trDeleteSegmentModal .close { color: #9ca3af; font-size: 28px; line-height: 1; cursor: pointer; transition: color 0.15s ease;}
#trDeleteSegmentModal .close:hover { color: #4b5563;}
#trDeleteSegmentModal .close:active { color: #111827;}
#trDeleteSegmentModal .btn-secondary,#trDeleteSegmentModal .btn-danger { border: none; border-radius: 10px; padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease;}
#trDeleteSegmentModal .btn-secondary { background: #f3f4f6; color: #374151;}
#trDeleteSegmentModal .btn-secondary:hover { background: #e5e7eb;}
#trDeleteSegmentModal .btn-secondary:active { transform: translateY(1px);}
#trDeleteSegmentModal .btn-danger { background: #dc2626; color: #ffffff;}
#trDeleteSegmentModal .btn-danger:hover { background: #b91c1c;}
#trDeleteSegmentModal .btn-danger:active { transform: translateY(1px);}
#trDeleteSegmentModal .delete-warning { padding: 16px 18px; border-radius: 12px; background: #fef2f2; border-left: 4px solid #dc2626; color: #7f1d1d;}
#trDeleteSegmentModal .delete-warning p { margin: 0 0 12px;}
#trDeleteSegmentModal .delete-warning p:last-child { margin-bottom: 0;}
#trDeleteSegmentModal .tr-delete-segment-meta { font-size: 14px; color: #991b1b;}
#trDeleteSegmentModal .tr-delete-segment-list { margin: 0 0 12px; padding-left: 20px;}
#trDeleteSegmentModal .tr-delete-segment-list li + li { margin-top: 6px;}
#trDeleteSegmentModal .tr-delete-segment-size { font-size: 13px; color: #7f1d1d;}
/* Unified timeline view */.tr-unified { display: flex; flex-direction: column; gap: 12px;}
.tr-audio-players { display: flex; flex-wrap: wrap; gap: 12px; padding: 16px; background: #f9fafb; border-radius: 12px; border: 1px solid #e5e7eb;}
.tr-audio-player { flex: 1; min-width: 200px;}
.tr-audio-player audio { width: 100%;}
.tr-audio-player-label { font-size: 12px; color: #6b7280; margin-bottom: 4px;}
.tr-purge-notice { padding: 0.5em 0.75em; margin-bottom: 0.5em; font-size: 0.8em; color: #888; background: #f8f8f8; border-radius: 4px; border-left: 3px solid #ccc;}
.tr-entry { display: flex; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background 0.15s;}
.tr-entry:hover { background: #f9fafb;}
.tr-entry-audio { border-left: 3px solid #86efac;}
.tr-entry-audio:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: -1px;}
.tr-entry-audio:active { background: #f0f9ff;}
.tr-entry.tr-entry-active { background: #f0f9ff; border-left-color: #3b82f6;}
.tr-entry-screen { border-left: 3px solid #facc15;}
.tr-entry-time { flex-shrink: 0; width: 48px; font-size: 12px; color: #6b7280; font-family: ui-monospace, monospace;}
.tr-entry-content { flex: 1; min-width: 0;}
.tr-entry-text { font-size: 14px; line-height: 1.5;}
.tr-entry-meta { font-size: 11px; color: #9ca3af; margin-top: 2px;}
.tr-entry-thumb { flex-shrink: 0; width: 120px; height: 68px; border-radius: 6px; object-fit: cover; border: 1px solid #e5e7eb; background: #f3f4f6; cursor: pointer; transition: box-shadow 0.15s ease, border-color 0.15s ease;}
.tr-entry-thumb:hover { box-shadow: 0 2px 8px rgba(0,0,0,.12); border-color: #d1d5db;}
.tr-entry-thumb.loading { animation: tr-pulse 1.5s ease-in-out infinite;}
@keyframes tr-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; }}
.tr-entry-screen .tr-entry-content { display: flex; gap: 12px; align-items: center;}
.tr-entry-desc { flex: 1; font-size: 13px; color: #374151; line-height: 1.5;}
.tr-entry-badge { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500; background: #dbeafe; color: #1d4ed8; margin-right: 6px;}
.tr-entry-badge-monitor { background: #f3e8ff; color: #7c3aed;}
.tr-speaker-label { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 2px;}
.tr-speaker-label a { color: #374151; text-decoration: none;}
.tr-speaker-label a:hover { color: #374151; text-decoration: underline;}
.tr-speaker-label a:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 2px; border-radius: 2px;}
.tr-speaker-label a:active { color: #1f2937;}
.tr-speaker-label-owner { color: #4f46e5;}
.tr-speaker-label-owner a { color: #4f46e5;}
.tr-speaker-label-owner a:active { color: #3730a3;}
.tr-speaker-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;}
.tr-speaker-dot-high { background: #22c55e;}
.tr-speaker-dot-medium { background: #eab308;}
.tr-panel > .surface-state { min-height: 100%;}
.tr-warning-notice { display: none; align-items: center; gap: 8px; padding: 8px 12px; font-size: 13px; color: #92400e; background: #fffbeb; border: 1px solid #fde68a; border-radius: 0 0 8px 8px; margin-top: auto; transition: opacity 0.3s ease;}
.tr-warning-notice.visible { display: flex;}
.tr-warning-notice svg { width: 16px; height: 16px; flex-shrink: 0; stroke: #f59e0b; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;}
/* Markdown content styling within screen entries */.tr-entry-desc h3 { display: none; /* Hide timestamp header - already shown in time column */}
.tr-entry-desc p { margin: 4px 0;}
.tr-entry-desc strong { color: #1f2937;}
.tr-entry-desc pre { background: #f3f4f6; border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px 12px; margin: 8px 0; overflow-x: auto; font-size: 12px;}
.tr-entry-desc code { font-family: ui-monospace, monospace; font-size: 12px;}
.tr-entry-desc pre code { background: none; padding: 0;}
/* Basic frame groups - collapsed by default */.tr-group { border-left: 3px solid #e5e7eb; border-radius: 8px; margin: 4px 0; background: #fafafa;}
.tr-group-header { display: flex; align-items: center; gap: 12px; padding: 8px 12px; cursor: pointer; user-select: none;}
.tr-group-header:hover { background: #f3f4f6;}
.tr-group-header:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: -1px;}
.tr-group-header:active { background: #e5e7eb;}
.tr-group-chevron { width: 16px; height: 16px; color: #9ca3af; transition: transform 0.15s; flex-shrink: 0;}
.tr-group.expanded .tr-group-chevron { transform: rotate(90deg);}
.tr-group-time { font-size: 12px; color: #6b7280; font-family: ui-monospace, monospace; flex-shrink: 0; line-height: 16px;}
.tr-group-count { font-size: 12px; color: #9ca3af; line-height: 16px;}
.tr-group-grid { display: none; padding: 8px 12px 12px; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));}
.tr-group.expanded .tr-group-grid { display: grid;}
.tr-group-item { position: relative; cursor: pointer; border-radius: 6px; overflow: hidden; border: 1px solid #e5e7eb; transition: box-shadow 0.15s, transform 0.15s;}
.tr-group-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.12);}
.tr-group-item:focus-visible { outline: 2px solid var(--accent, #4a9eff); outline-offset: 1px;}
.tr-group-item:active { box-shadow: 0 1px 2px rgba(0,0,0,.08); transform: scale(0.97);}
.tr-group-item img,.tr-group-item canvas { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #f3f4f6;}
.tr-group-item canvas.loading { animation: tr-pulse 1.5s ease-in-out infinite;}
.tr-group-item-badge { position: absolute; bottom: 4px; left: 4px; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500; background: rgba(0,0,0,0.6); color: #fff; max-width: calc(100% - 8px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;}</style>
<div class="tr-wrap"> <div class="tr-card"> <!-- Left timeline --> <div id="trTimeline" class="tr-timeline" aria-label="Day timeline"> <div class="tr-timeline-label">day</div> <div class="tr-grid" id="trGrid"></div> <div class="tr-labels" id="trLabels"></div> <div class="tr-segments" id="trSegments"></div> <div class="tr-sel-wrap" id="trSelWrap"> <div class="tr-sel" data-handle="move"> <div class="tr-bumper tr-bumper-top" data-handle="start" title="Drag to adjust start"></div> <div class="tr-bumper tr-bumper-bottom" data-handle="end" title="Drag to adjust end"></div> </div> </div> <div class="tr-timeline-legend"> <span><span class="tr-legend-dot" style="background: #86efac;"></span>audio</span> <span><span class="tr-legend-dot" style="background: #facc15;"></span>screen</span> </div> </div>
<!-- Middle zoom timeline --> <div class="tr-zoom" id="trZoom"> <div class="tr-zoom-timeline-label">detail</div> <div class="tr-zoom-grid" id="trZoomGrid"></div> <div class="tr-zoom-labels" id="trZoomLabels"></div> <div class="tr-zoom-segments" id="trZoomSegments"></div> </div>
<!-- Right content panel --> <div class="tr-content"> <div class="tr-header"> <div> <h2 class="tr-title">Transcripts</h2> <div class="tr-range-text" id="trRangeText"></div> <span class="tr-nav-hint" id="trNavHint">[ ] to navigate</span> </div> <button type="button" id="trDeleteBtn" class="tr-delete-btn" title="Delete segment"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <polyline points="3 6 5 6 21 6"></polyline> <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> </svg> </button> </div> <div class="tr-tabs" id="trTabs"></div> <div id="trWarningNotice" class="tr-warning-notice"> <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 id="trWarningText"></span> </div> <div class="tr-panel" id="trPanel"> <div class="surface-state surface-state--empty"> <div class="surface-state-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg></div> <h2 class="surface-state-heading">your day at a glance</h2> <p class="surface-state-desc">select a segment from the timeline to view its transcript</p> </div> </div> </div> </div></div>
<div id="trDeleteSegmentModal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trDeleteSegmentModalTitle"> <div class="modal-content"> <div class="modal-header danger"> <h3 id="trDeleteSegmentModalTitle">Delete segment?</h3> <span class="close" id="trDeleteSegmentModalClose" onclick="closeDeleteSegmentModal()">×</span> </div> <div class="modal-body"> <div id="trDeleteSegmentModalBody" class="delete-warning"></div> </div> <div class="modal-footer"> <button type="button" class="btn-secondary" onclick="closeDeleteSegmentModal()">Cancel</button> <button type="button" id="trDeleteSegmentModalConfirm" class="btn-danger" onclick="confirmDeleteSegment()">Delete</button> </div> </div></div>
<script>(() => { const escapeHtml = window.AppServices.escapeHtml; // Timeline bounds - computed dynamically from content const DEFAULT_START = 8 * 60; // 8:00 AM default const DEFAULT_END = 20 * 60; // 8:00 PM default const MIN_SPAN = 12 * 60; // Minimum 12-hour span const BUFFER = 30; // 30-minute buffer on each side
let timelineStart = DEFAULT_START; let timelineEnd = DEFAULT_END;
const STEP = 15; const DEFAULT_LEN = 60; const MIN_LEN = 15;
const day = '{{ day }}';
// Elements - main timeline const timeline = document.getElementById('trTimeline'); const grid = document.getElementById('trGrid'); const labels = document.getElementById('trLabels'); const segmentsLane = document.getElementById('trSegments'); const selWrap = document.getElementById('trSelWrap'); const sel = selWrap.querySelector('.tr-sel');
// Elements - zoom timeline const zoom = document.getElementById('trZoom'); const zoomGrid = document.getElementById('trZoomGrid'); const zoomLabels = document.getElementById('trZoomLabels'); const zoomSegments = document.getElementById('trZoomSegments');
// Elements - content panel const titleEl = document.querySelector('.tr-title'); const rangeText = document.getElementById('trRangeText'); const tabsContainer = document.getElementById('trTabs'); tabsContainer.addEventListener('keydown', e => { if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; e.preventDefault(); const tabs = [...tabsContainer.querySelectorAll('.tr-tab')]; const idx = tabs.indexOf(e.target); if (idx < 0) return; const next = e.key === 'ArrowRight' ? tabs[(idx + 1) % tabs.length] : tabs[(idx - 1 + tabs.length) % tabs.length]; activateTab(next.dataset.tab); next.focus(); }); const panel = document.getElementById('trPanel'); const deleteBtn = document.getElementById('trDeleteBtn'); const deleteSegmentModal = document.getElementById('trDeleteSegmentModal'); const deleteSegmentModalBody = document.getElementById('trDeleteSegmentModalBody'); const deleteSegmentModalConfirm = document.getElementById('trDeleteSegmentModalConfirm'); const emptyIcons = { 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>', 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>', 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>', 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>', 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>' };
// State let height = timeline.clientHeight; let ppm = height / (timelineEnd - timelineStart); let range = { start: 9 * 60, end: 10 * 60 }; let drag = null; let allSegments = []; let selectedSegment = null; let updateNowPosition = null; let deleteModalReturnFocus = null; let deleteModalPrevOverflow = '';
// Zoom state let zoomHeight = zoom.clientHeight; let zoomPpm = 0;
// Modal state - all screen frames for navigation let allScreenFrames = []; let currentFrameIndex = -1;
// ======================================== // FrameCapture - Client-side thumbnail + on-demand full frame decoder // ======================================== class FrameCapture { constructor() { // Map of video URL -> { video, ready, width, height, thumbs } this.videos = new Map(); // Pending thumbnail promises per frame this.pendingThumbs = new Map(); this.warnedUrls = new Set(); }
// Load video and wait for metadata loadVideo(url) { if (this.videos.has(url)) { const entry = this.videos.get(url); if (entry.ready) return Promise.resolve(entry); return entry.promise; }
const video = document.createElement('video'); video.preload = 'metadata'; video.muted = true; video.playsInline = true; video.crossOrigin = 'anonymous';
const promise = new Promise((resolve, reject) => { video.onloadedmetadata = () => { const entry = this.videos.get(url); entry.ready = true; entry.width = video.videoWidth; entry.height = video.videoHeight; resolve(entry); }; video.onerror = () => { const mediaError = video.error; if (mediaError?.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) { const entry = this.videos.get(url); entry.unsupported = true; entry.ready = true; if (!this.warnedUrls.has(url)) { this.warnedUrls.add(url); console.warn(`Video format not supported by this browser: ${url}`); } resolve(entry); return; } reject(new Error(`Failed to load video: ${url}`)); }; video.src = url; });
this.videos.set(url, { video, ready: false, unsupported: false, width: 0, height: 0, promise, thumbs: new Map(), queue: Promise.resolve() }); return promise; }
async _withVideoQueue(videoUrl, task) { const entry = await this.loadVideo(videoUrl); const run = entry.queue.then(task, task); entry.queue = run.catch(() => {}); return run; }
async _seekTo(video, timestamp) { return new Promise((resolve, reject) => { const onSeeked = () => { video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onError); resolve(); }; const onError = () => { video.removeEventListener('seeked', onSeeked); video.removeEventListener('error', onError); reject(new Error('Video seek failed')); }; video.addEventListener('seeked', onSeeked); video.addEventListener('error', onError); video.currentTime = timestamp; }); }
async captureThumbnail(videoUrl, frameId, width, height) { const entry = await this.loadVideo(videoUrl); if (entry.unsupported) return null; const cacheKey = `${videoUrl}|${frameId}`;
if (entry.thumbs.has(frameId)) { return entry.thumbs.get(frameId); }
if (this.pendingThumbs.has(cacheKey)) { return this.pendingThumbs.get(cacheKey); }
const pending = this._withVideoQueue(videoUrl, async () => { const video = entry.video; const timestamp = Math.max(0, frameId - 1); await this._seekTo(video, timestamp);
let bitmap; if (width && height) { bitmap = await createImageBitmap(video, { resizeWidth: width, resizeHeight: height, resizeQuality: 'high' }); } else { bitmap = await createImageBitmap(video); }
const canvas = document.createElement('canvas'); canvas.width = width || bitmap.width; canvas.height = height || bitmap.height; const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height); if (bitmap && typeof bitmap.close === 'function') { bitmap.close(); } entry.thumbs.set(frameId, canvas); return canvas; });
this.pendingThumbs.set(cacheKey, pending); try { return await pending; } finally { this.pendingThumbs.delete(cacheKey); } }
async captureFullFrame(videoUrl, frameId) { const entry = await this.loadVideo(videoUrl); if (entry.unsupported) return null; return this._withVideoQueue(videoUrl, async () => { const video = entry.video; const timestamp = Math.max(0, frameId - 1); await this._seekTo(video, timestamp); return createImageBitmap(video); }); }
async prefetchThumbnails(videoUrl, frameIds, onProgress = null, signal = null) { if (!frameIds || frameIds.length === 0) return null;
const sorted = [...new Set(frameIds)].sort((a, b) => a - b); for (let i = 0; i < sorted.length; i += 1) { if (signal?.aborted) return this.videos.get(videoUrl); const frameId = sorted[i]; await this.captureThumbnail(videoUrl, frameId, 120, 68); if (onProgress) onProgress(i + 1, sorted.length); }
return this.videos.get(videoUrl); }
// Clear all loaded videos and cached thumbnails clear() { for (const entry of this.videos.values()) { entry.thumbs.clear(); entry.video.src = ''; entry.video.load(); } this.videos.clear(); this.pendingThumbs.clear(); this.warnedUrls.clear(); }
_drawUnsupportedPlaceholder(canvas, width, height) { canvas.width = width; canvas.height = height;
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#f3f4f6'; ctx.fillRect(0, 0, width, height);
const minDim = Math.min(width, height); const iconWidth = minDim * 0.42; const iconHeight = iconWidth * 0.68; const radius = Math.max(4, iconWidth * 0.08); const strokeWidth = Math.max(2, minDim * 0.03); const standHeight = Math.max(6, iconHeight * 0.22); const textSize = Math.max(10, Math.min(height > 120 ? 24 : 14, width / (height > 120 ? 9 : 12))); const gap = Math.max(10, height * 0.06); const totalHeight = iconHeight + standHeight + gap + textSize; const iconX = (width - iconWidth) / 2; const iconY = Math.max(height * 0.14, (height - totalHeight) / 2);
ctx.strokeStyle = '#9ca3af'; ctx.lineWidth = strokeWidth; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.beginPath(); ctx.moveTo(iconX + radius, iconY); ctx.lineTo(iconX + iconWidth - radius, iconY); ctx.quadraticCurveTo(iconX + iconWidth, iconY, iconX + iconWidth, iconY + radius); ctx.lineTo(iconX + iconWidth, iconY + iconHeight - radius); ctx.quadraticCurveTo(iconX + iconWidth, iconY + iconHeight, iconX + iconWidth - radius, iconY + iconHeight); ctx.lineTo(iconX + radius, iconY + iconHeight); ctx.quadraticCurveTo(iconX, iconY + iconHeight, iconX, iconY + iconHeight - radius); ctx.lineTo(iconX, iconY + radius); ctx.quadraticCurveTo(iconX, iconY, iconX + radius, iconY); ctx.stroke();
const standY = iconY + iconHeight + standHeight * 0.45; const standWidth = iconWidth * 0.28; ctx.beginPath(); ctx.moveTo(width / 2, iconY + iconHeight); ctx.lineTo(width / 2, standY); ctx.moveTo(width / 2 - standWidth / 2, standY); ctx.lineTo(width / 2 + standWidth / 2, standY); ctx.stroke();
ctx.beginPath(); ctx.moveTo(iconX + iconWidth * 0.18, iconY + iconHeight * 0.2); ctx.lineTo(iconX + iconWidth * 0.82, iconY + iconHeight * 0.84); ctx.stroke();
ctx.fillStyle = '#6b7280'; ctx.font = `${textSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText('Format not supported', width / 2, standY + gap * 0.55);
canvas.classList.remove('loading'); }
// Draw thumbnail to canvas (no overlays - those are only for full frame view) async drawThumbnail(canvas, videoUrl, frameId, options = {}) { const { width, height } = options;
try { const thumb = await this.captureThumbnail(videoUrl, frameId, width, height); if (!thumb) { this._drawUnsupportedPlaceholder(canvas, width || 120, height || 68); return false; }
canvas.width = width || thumb.width; canvas.height = height || thumb.height;
const ctx = canvas.getContext('2d'); ctx.drawImage(thumb, 0, 0, canvas.width, canvas.height);
canvas.classList.remove('loading'); return true; } catch (err) { canvas.classList.remove('loading'); console.warn('Thumbnail draw failed:', err); return false; } }
// Draw full-resolution frame to canvas (no caching) async drawFull(canvas, videoUrl, frameId, options = {}) { const { boxCoords, participants, aruco } = options;
try { const bitmap = await this.captureFullFrame(videoUrl, frameId); if (!bitmap) { this._drawUnsupportedPlaceholder(canvas, 640, 360); return false; }
canvas.width = bitmap.width; canvas.height = bitmap.height;
const ctx = canvas.getContext('2d'); ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
this._applyOverlays(ctx, canvas, bitmap.width, bitmap.height, { boxCoords, participants, aruco });
if (bitmap && typeof bitmap.close === 'function') { bitmap.close(); } canvas.classList.remove('loading'); return true; } catch (err) { canvas.classList.remove('loading'); console.warn('Full frame draw failed:', err); return false; } }
// Compute mask polygon from ArUco corner tag markers // Corner tag IDs: 6=TL, 7=TR, 2=BR, 4=BL // Each marker has corners in order [TL, TR, BR, BL] _computeArucoMaskPolygon(aruco) { if (!aruco || !aruco.masked || !aruco.markers) return null;
const cornerTagIds = { 6: 0, 7: 1, 2: 2, 4: 3 }; // id -> which corner to use const tagCorners = {};
for (const marker of aruco.markers) { if (marker.id in cornerTagIds && marker.corners?.length === 4) { const cornerIdx = cornerTagIds[marker.id]; tagCorners[marker.id] = marker.corners[cornerIdx]; } }
// Need all 4 corner tags if (!(6 in tagCorners && 7 in tagCorners && 2 in tagCorners && 4 in tagCorners)) { return null; }
// Return polygon: TL, TR, BR, BL return [tagCorners[6], tagCorners[7], tagCorners[2], tagCorners[4]]; }
_applyOverlays(ctx, canvas, sourceWidth, sourceHeight, options = {}) { const { boxCoords, participants, aruco } = options; const scaleX = canvas.width / sourceWidth; const scaleY = canvas.height / sourceHeight;
// Apply ArUco mask first (so other overlays draw on top) const maskPolygon = this._computeArucoMaskPolygon(aruco); if (maskPolygon) { ctx.fillStyle = '#000000'; ctx.beginPath(); ctx.moveTo(maskPolygon[0][0] * scaleX, maskPolygon[0][1] * scaleY); for (let i = 1; i < maskPolygon.length; i++) { ctx.lineTo(maskPolygon[i][0] * scaleX, maskPolygon[i][1] * scaleY); } ctx.closePath(); ctx.fill(); }
if (boxCoords && boxCoords.length === 4) { const [xMin, yMin, xMax, yMax] = boxCoords; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 3; ctx.strokeRect( xMin * scaleX, yMin * scaleY, (xMax - xMin) * scaleX, (yMax - yMin) * scaleY ); }
if (participants && participants.length > 0) { for (const p of participants) { const x = (p.left / 100) * canvas.width; const y = (p.top / 100) * canvas.height; const w = (p.width / 100) * canvas.width; const h = (p.height / 100) * canvas.height;
const colors = { speaking: '#fbbf24', active: '#4ade80', muted: '#f87171', presenting: '#60a5fa', unknown: '#9ca3af' }; ctx.strokeStyle = colors[p.status] || colors.unknown; ctx.lineWidth = 2; ctx.setLineDash([5, 5]); ctx.strokeRect(x, y, w, h); ctx.setLineDash([]);
const labelY = y + h + 16; ctx.font = '11px system-ui, sans-serif'; const textWidth = ctx.measureText(p.name).width; ctx.fillStyle = colors[p.status] || colors.unknown; ctx.fillRect(x, y + h + 2, textWidth + 8, 16);
ctx.fillStyle = '#000'; ctx.fillText(p.name, x + 4, labelY - 3); } } } }
// Global frame capture instance let frameCapture = new FrameCapture();
// Utilities const y = (m) => (m - timelineStart) * ppm; const mFromY = (py) => Math.max(timelineStart, Math.min(timelineEnd, Math.round(py / ppm) + timelineStart)); const snap = (m) => Math.round(m / STEP) * STEP; const hhmm = (m) => String(Math.floor(m / 60)).padStart(2, '0') + ':' + String(m % 60).padStart(2, '0');
// Zoom utilities - map minutes to pixels within the zoomed range const zoomY = (m) => (m - range.start) * zoomPpm;
function parseTime(timeStr) { const [hh, mm] = timeStr.split(':').map(Number); return hh * 60 + mm; }
// Format cost in cents with exact USD in title // Returns {text: "3c", title: "$0.0312"} or {text: "", title: ""} if null/zero function formatCost(costUSD) { if (costUSD === null || costUSD === undefined) { return { text: '', title: '' }; } const cents = Math.round(costUSD * 100); const exactUSD = '$' + costUSD.toFixed(4); return { text: cents + 'c', title: exactUSD }; }
// Format bytes as human-readable size (KB, MB, GB) function formatSize(bytes) { if (!bytes) return ''; if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'; if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }
// Build range text with cost and total media size function updateRangeText() { if (!selectedSegment || !segmentData) return; const seg = selectedSegment; let parts = [`${seg.start} - ${seg.end}`];
const costInfo = formatCost(segmentData.cost); if (costInfo.text) { parts.push(`<span title="${costInfo.title}">${costInfo.text}</span>`); }
const sizes = segmentData.media_sizes; if (sizes) { let total = 0; const breakdown = []; if (sizes.audio) { total += sizes.audio; breakdown.push('audio ' + formatSize(sizes.audio)); } if (sizes.screen) { total += sizes.screen; breakdown.push('screen ' + formatSize(sizes.screen)); } if (total) { const title = breakdown.join(', '); parts.push(`<span title="${title}">${formatSize(total)}</span>`); } }
rangeText.innerHTML = parts.join(' · '); }
function computeTimelineBounds(ranges) { // Compute dynamic timeline bounds from content ranges // Returns {start, end} in minutes, snapped to hours const allRanges = [...(ranges.audio || []), ...(ranges.screen || [])];
if (allRanges.length === 0) { return { start: DEFAULT_START, end: DEFAULT_END }; }
// Find min/max times across all ranges let minTime = Infinity; let maxTime = -Infinity; for (const [start, end] of allRanges) { const s = parseTime(start); const e = parseTime(end); if (s < minTime) minTime = s; if (e > maxTime) maxTime = e; }
// Add buffer and snap to hours let start = Math.floor((minTime - BUFFER) / 60) * 60; let end = Math.ceil((maxTime + BUFFER) / 60) * 60;
// Enforce minimum span - extend end if needed if (end - start < MIN_SPAN) { end = start + MIN_SPAN; }
// Clamp to valid day range (0:00 - 24:00) start = Math.max(0, start); end = Math.min(24 * 60, end);
return { start, end }; }
function addSegmentIndicator(type, startMin, endMin, column) { const el = document.createElement('div'); el.className = 'tr-seg ' + (type === 'screen' ? 'tr-seg-screen' : 'tr-seg-audio'); el.style.top = y(startMin) + 'px'; el.style.height = Math.max(2, y(endMin) - y(startMin)) + 'px'; el.classList.add(column === 1 ? 'tr-seg-col-1' : 'tr-seg-col-0'); // Zero-padded HH:MM label const _h = Math.floor(startMin / 60); const _m = startMin % 60; const _label = `Segment ${String(_h).padStart(2, '0')}:${String(_m).padStart(2, '0')}`; el.setAttribute('role', 'button'); el.setAttribute('tabindex', '0'); el.setAttribute('aria-label', _label); el.title = hhmm(startMin) + ' – ' + hhmm(endMin) + ' (' + (endMin - startMin) + ' min, ' + type + ')'; el.addEventListener('click', e => { e.stopPropagation(); const midMin = (startMin + endMin) / 2; const start = snap(midMin - DEFAULT_LEN / 2); range = { start, end: start + DEFAULT_LEN }; renderTimeline(); updateZoom(); }); el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const midMin = (startMin + endMin) / 2; const start = snap(midMin - DEFAULT_LEN / 2); range = { start, end: start + DEFAULT_LEN }; renderTimeline(); updateZoom(); } }); segmentsLane.appendChild(el); }
function buildGrid() { grid.innerHTML = ''; labels.innerHTML = '';
for (let h = timelineStart / 60; h <= timelineEnd / 60; h++) { const hourLine = document.createElement('div'); hourLine.className = 'tr-grid-hour'; hourLine.style.top = y(h * 60) + 'px'; grid.appendChild(hourLine);
const lab = document.createElement('div'); lab.className = 'tr-label'; lab.style.top = y(h * 60) + 'px'; lab.textContent = String(h).padStart(2, '0') + ':00'; labels.appendChild(lab);
if (h < timelineEnd / 60) { [15, 30, 45].forEach(m => { const q = document.createElement('div'); q.className = 'tr-grid-quarter'; q.style.top = y(h * 60 + m) + 'px'; grid.appendChild(q); }); } } }
function renderTimeline() { selWrap.style.top = y(range.start) + 'px'; selWrap.style.height = (y(range.end) - y(range.start)) + 'px'; }
function buildZoomGrid() { zoomGrid.innerHTML = ''; zoomLabels.innerHTML = '';
const rangeLen = range.end - range.start; // Determine label interval based on range length let labelInterval = 5; // default 5 min if (rangeLen > 120) labelInterval = 15; else if (rangeLen > 60) labelInterval = 10;
for (let m = range.start; m <= range.end; m++) { const yPos = zoomY(m);
if (m % 60 === 0) { const line = document.createElement('div'); line.className = 'tr-grid-hour'; line.style.top = yPos + 'px'; zoomGrid.appendChild(line); } else if (m % 15 === 0) { const line = document.createElement('div'); line.className = 'tr-grid-quarter'; line.style.top = yPos + 'px'; zoomGrid.appendChild(line); }
if (m % labelInterval === 0) { const lab = document.createElement('div'); lab.className = 'tr-zoom-label'; lab.style.top = yPos + 'px'; lab.textContent = hhmm(m); zoomLabels.appendChild(lab); } } }
function filterSegmentsInRange() { return allSegments.filter(seg => { const segStart = parseTime(seg.start); const segEnd = parseTime(seg.end); return segEnd > range.start && segStart < range.end; }); }
function buildZoomSegments() { const filtered = filterSegmentsInRange(); const streams = [...new Set(filtered.map(s => s.stream))].sort(); const colCount = streams.length; zoomSegments.innerHTML = '';
if (filtered.length === 0) { document.getElementById('trNavHint').classList.remove('visible'); zoomSegments.innerHTML = window.SurfaceState.empty({ icon: emptyIcons.nothing, heading: 'no segments in this range', desc: 'widen the time range or pick a different day' }); return; }
document.getElementById('trNavHint').classList.add('visible'); filtered.forEach(seg => { const segStart = parseTime(seg.start); const segEnd = parseTime(seg.end);
// Clamp to visible range const visStart = Math.max(segStart, range.start); const visEnd = Math.min(segEnd, range.end);
const pill = document.createElement('div'); pill.className = 'tr-zoom-pill'; pill.setAttribute('role', 'button'); pill.setAttribute('tabindex', '0');
if (colCount > 1) { const colIdx = streams.indexOf(seg.stream); const colWidth = 100 / colCount; const gap = 0.5; pill.style.left = (colIdx * colWidth + gap / 2) + '%'; pill.style.right = 'auto'; pill.style.width = (colWidth - gap) + '%'; }
// Determine pill type based on content const hasAudio = seg.types.includes('audio'); const hasScreen = seg.types.includes('screen'); if (hasAudio && hasScreen) { pill.classList.add('tr-zoom-pill-both'); } else if (hasAudio) { pill.classList.add('tr-zoom-pill-audio'); } else { pill.classList.add('tr-zoom-pill-screen'); } const typeLabel = (hasAudio && hasScreen) ? 'audio and screen' : hasAudio ? 'audio' : 'screen';
if (selectedSegment && selectedSegment.key === seg.key) { pill.classList.add('tr-active'); }
pill.style.top = zoomY(visStart) + 'px'; pill.style.height = Math.max(4, zoomY(visEnd) - zoomY(visStart)) + 'px'; const duration = Math.round(visEnd - visStart); const typeDesc = (hasAudio && hasScreen) ? 'audio + screen' : hasAudio ? 'audio' : 'screen'; pill.title = seg.start + ' – ' + seg.end + ' · ' + duration + ' min · ' + typeDesc; pill.setAttribute('aria-label', 'Segment ' + seg.start + ' \u2013 ' + seg.end + ', ' + typeLabel); pill.dataset.key = seg.key;
pill.addEventListener('click', () => selectSegment(seg)); pill.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectSegment(seg); } }); zoomSegments.appendChild(pill); }); }
function selectSegment(seg, updateHash = true, requestedTabId = null) { selectedSegment = seg;
// Update URL hash for shareable links if (updateHash) { const tab = activeTab || 'transcript'; history.replaceState(null, '', `#${seg.key}/${tab}`); }
// Update active state in zoom view zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => { pill.classList.toggle('tr-active', pill.dataset.key === seg.key); });
titleEl.textContent = seg.stream; rangeText.textContent = `${seg.start} - ${seg.end}`;
// Show delete button when segment is selected deleteBtn.classList.add('visible');
// Load transcript content loadSegmentContent(seg, requestedTabId); }
// Step through segments with [ ] keys function navigateSegment(delta) { if (allSegments.length === 0) return; let currentIdx = selectedSegment ? allSegments.findIndex(s => s.key === selectedSegment.key) : -1; let nextIdx = currentIdx === -1 ? (delta > 0 ? 0 : allSegments.length - 1) : currentIdx + delta; if (nextIdx < 0 || nextIdx >= allSegments.length) return; const seg = allSegments[nextIdx]; const segStart = parseTime(seg.start); const segEnd = parseTime(seg.end); if (segStart < range.start || segEnd > range.end) { const rangeLen = range.end - range.start; const segMid = (segStart + segEnd) / 2; let newStart = snap(Math.max(timelineStart, segMid - rangeLen / 2)); newStart = Math.min(newStart, timelineEnd - rangeLen); range = { start: newStart, end: newStart + rangeLen }; renderTimeline(); updateZoom(); } selectSegment(seg); }
// Unified timeline state let segmentData = null; let currentVideoFiles = {}; // filename -> video URL mapping let groupEntriesByIdx = new Map(); let activeTab = null; let currentSegmentAbort = null; let tabPanes = {}; // tabId -> pane element let screenDecoded = false;
function loadSegmentContent(seg, requestedTabId = null) { currentSegmentAbort?.abort(); currentSegmentAbort = new AbortController(); const signal = currentSegmentAbort.signal; const segmentToken = seg.key;
// Clear old data, videos, tabs, and show loading message immediately segmentData = null; currentVideoFiles = {}; tabPanes = {}; activeTab = null; screenDecoded = false; frameCapture.clear(); tabsContainer.classList.remove('visible'); tabsContainer.innerHTML = ''; document.getElementById('trWarningNotice').classList.remove('visible'); panel.innerHTML = window.SurfaceState.loading({ text: 'Loading segment...' }); panel.classList.add('loading');
fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { signal }) .then(r => r.json()) .then(data => { if (!selectedSegment || selectedSegment.key !== segmentToken) { return; } panel.classList.remove('loading'); segmentData = data; updateRangeText(); buildTabBar(data); activateTab('transcript'); if ( requestedTabId && [...tabsContainer.querySelectorAll('.tr-tab')].some( tab => tab.dataset.tab === requestedTabId ) ) { activateTab(requestedTabId); } const warningNotice = document.getElementById('trWarningNotice'); if (data.warnings > 0) { document.getElementById('trWarningText').textContent = data.warnings + ' warning' + (data.warnings === 1 ? '' : 's') + ' during processing'; warningNotice.classList.add('visible'); } else { warningNotice.classList.remove('visible'); } }) .catch(err => { if (err.name === 'AbortError') { return; } if (!selectedSegment || selectedSegment.key !== segmentToken) { return; } panel.classList.remove('loading'); tabsContainer.classList.remove('visible'); tabsContainer.innerHTML = ''; panel.innerHTML = window.SurfaceState.error({ icon: emptyIcons.transcript, heading: 'couldn\'t load this segment', desc: 'something went wrong loading the transcript. try selecting the segment again, or refresh the page.' }); }); }
function prepareScreenFrames(data, targetEl, segmentToken, signal) { if (screenDecoded) { return Promise.resolve(); }
const isStaleSegment = () => !selectedSegment || selectedSegment.key !== segmentToken; if (isStaleSegment()) { return Promise.resolve(); }
currentVideoFiles = data.video_files || {};
const nonBasicByVideo = new Map(); (data.chunks || []).forEach(chunk => { if (chunk.type !== 'screen') return; if (chunk.basic === true) return; const filename = chunk.source_ref?.filename; const frameId = chunk.source_ref?.frame_id; if (!filename || !frameId) return; if (!nonBasicByVideo.has(filename)) { nonBasicByVideo.set(filename, new Set()); } nonBasicByVideo.get(filename).add(frameId); });
const totalFrames = Array.from(nonBasicByVideo.values()).reduce( (sum, frames) => sum + frames.size, 0 ); const perVideoProgress = new Map(); let lastStatusUpdate = 0;
const updateLoadingStatus = (done) => { if (isStaleSegment()) return; const statusEl = targetEl.querySelector('[data-role="loading-status"]'); if (!statusEl) return; if (!totalFrames) { statusEl.textContent = 'Loading screen entries...'; return; } const decoded = Array.from(perVideoProgress.values()).reduce((sum, count) => sum + count, 0); const pct = Math.min(100, Math.round((decoded / totalFrames) * 100)); statusEl.textContent = done ? 'Rendering screen entries...' : `Decoding key frames ${decoded}/${totalFrames} (${pct}%)...`; };
const makeProgressHandler = (videoUrl) => (count) => { if (isStaleSegment()) return; const now = Date.now(); perVideoProgress.set(videoUrl, count); if (now - lastStatusUpdate > 150) { lastStatusUpdate = now; updateLoadingStatus(false); } };
updateLoadingStatus(false);
const decodeJobs = []; Object.entries(currentVideoFiles).forEach(([filename, url]) => { const frameIds = Array.from(nonBasicByVideo.get(filename) || []); if (frameIds.length > 0) { decodeJobs.push( frameCapture.prefetchThumbnails( url, frameIds, makeProgressHandler(url), signal ) ); } });
if (decodeJobs.length === 0) { screenDecoded = true; return Promise.resolve(); }
return Promise.all(decodeJobs) .then(() => { if (isStaleSegment()) return; screenDecoded = true; updateLoadingStatus(true); }) .catch(() => { if (isStaleSegment()) return; screenDecoded = true; updateLoadingStatus(true); }); }
function buildTabBar(data) { tabsContainer.innerHTML = ''; tabsContainer.setAttribute('role', 'tablist'); tabsContainer.classList.remove('visible'); panel.innerHTML = ''; tabPanes = {}; activeTab = null; screenDecoded = false;
const addTab = (tabId, label) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tr-tab'; btn.dataset.tab = tabId; btn.textContent = label; btn.setAttribute('role', 'tab'); btn.id = 'tr-tab-' + tabId; btn.setAttribute('aria-selected', 'false'); btn.setAttribute('aria-controls', 'tr-tabpanel-' + tabId); btn.setAttribute('tabindex', '-1'); btn.addEventListener('click', () => activateTab(tabId)); tabsContainer.appendChild(btn); };
addTab('transcript', 'Transcript'); if (data.audio_file) { addTab('audio', 'Audio'); } if ((data.chunks || []).some(chunk => chunk.type === 'screen')) { addTab('screen', 'Screen'); }
const mdStems = Object.keys(data.md_files || {}).sort((a, b) => a.localeCompare(b)); mdStems.forEach(stem => addTab(`md-${stem}`, stem));
tabsContainer.classList.add('visible'); }
function activateTab(tabId) { if (!segmentData || tabId === activeTab) { return; }
tabsContainer.querySelectorAll('.tr-tab').forEach(tab => { const isActive = tab.dataset.tab === tabId; tab.classList.toggle('active', isActive); tab.setAttribute('aria-selected', String(isActive)); tab.setAttribute('tabindex', isActive ? '0' : '-1'); });
Object.values(tabPanes).forEach(pane => pane.classList.remove('active'));
let pane = tabPanes[tabId]; if (!pane) { pane = document.createElement('div'); pane.className = 'tr-tab-pane'; pane.dataset.tab = tabId; pane.setAttribute('role', 'tabpanel'); pane.setAttribute('tabindex', '0'); pane.id = 'tr-tabpanel-' + tabId; pane.setAttribute('aria-labelledby', 'tr-tab-' + tabId); panel.appendChild(pane); tabPanes[tabId] = pane;
if (tabId === 'transcript') { renderSegmentTimeline(segmentData, true, true, pane); } else if (tabId === 'audio') { renderSegmentTimeline(segmentData, true, false, pane); } else if (tabId === 'screen') { const segmentToken = selectedSegment?.key; pane.innerHTML = window.SurfaceState.loading({ text: 'Loading screen entries...' }); prepareScreenFrames( segmentData, pane, segmentToken, currentSegmentAbort?.signal ) .then(() => { if (!selectedSegment || selectedSegment.key !== segmentToken) { return; } if (tabPanes[tabId] !== pane) { return; } renderSegmentTimeline(segmentData, false, true, pane); }) .catch(() => { if (!selectedSegment || selectedSegment.key !== segmentToken) { return; } if (tabPanes[tabId] !== pane) { return; } pane.innerHTML = window.SurfaceState.error({ icon: emptyIcons.screen, heading: 'couldn\'t load screen entries', desc: 'something went wrong decoding the screen data. try selecting the segment again.' }); }); } else if (tabId.startsWith('md-')) { const stem = tabId.slice(3); const content = (segmentData.md_files || {})[stem] || ''; pane.innerHTML = `<div class="tr-md-content">${window.AppServices.renderMarkdown(content)}</div>`; } }
pane.classList.add('active'); activeTab = tabId; if (selectedSegment) { history.replaceState(null, '', `#${selectedSegment.key}/${tabId}`); } }
function renderSegmentTimeline(data, showAudio, showScreen, targetEl) { const chunks = (data.chunks || []).filter(c => { if (c.type === 'audio' && !showAudio) return false; if (c.type === 'screen' && !showScreen) return false; return true; });
const textOnlyScreen = showScreen && Object.keys(currentVideoFiles).length === 0;
// Build flat list of all screen frames for modal navigation allScreenFrames = chunks.filter(c => c.type === 'screen'); currentFrameIndex = -1;
if (chunks.length === 0) { const tabType = !showScreen ? 'audio' : !showAudio ? 'screen' : 'transcript'; const tabEmptyMap = { transcript: { icon: emptyIcons.transcript, heading: 'no transcript entries', desc: 'this segment has no transcript content' }, audio: { icon: emptyIcons.audio, heading: 'no audio entries', desc: 'this segment has no audio content' }, screen: { icon: emptyIcons.screen, heading: 'no screen entries', desc: 'this segment has no screen captures' } }; const emptyInfo = tabEmptyMap[tabType] || tabEmptyMap.transcript; targetEl.innerHTML = window.SurfaceState.empty(emptyInfo); return; }
// Group sequential basic screen frames together const displayItems = textOnlyScreen ? chunks : groupBasicScreenFrames(chunks); groupEntriesByIdx = new Map();
let html = `<div class="tr-unified" role="list" aria-label="Transcript entries, ${displayItems.length} items">`;
// Audio player section (if we have audio) if (data.audio_file && showAudio) { html += '<div class="tr-audio-players" role="presentation">'; html += '<div class="tr-audio-player">'; html += '<div class="tr-audio-player-label">segment audio</div>'; html += `<audio data-role="segment-audio" controls preload="metadata"><source src="${data.audio_file}" type="audio/flac">Your browser does not support audio.</audio>`; html += '</div></div>'; }
if (data.media_purged && !data.audio_file && showAudio) { html += '<div class="tr-purge-notice" role="presentation">Raw observed media removed per retention policy</div>'; }
// Render items (chunks or groups) displayItems.forEach((item, idx) => { if (item.type === 'screen-group') { groupEntriesByIdx.set(idx, item.entries || []); // Render collapsed group of basic frames html += renderScreenGroup(item, idx); } else if (item.type === 'audio') { const timeStr = item.time || ''; 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}">`; html += `<div class="tr-entry-time">${timeStr}</div>`; html += '<div class="tr-entry-content">'; html += '<span class="sr-only">Audio: </span>'; if (item.speaker_label) { const sl = item.speaker_label; const dotClass = sl.confidence === 'high' ? 'tr-speaker-dot-high' : 'tr-speaker-dot-medium'; const labelClass = sl.is_owner ? 'tr-speaker-label tr-speaker-label-owner' : 'tr-speaker-label'; const displayName = sl.is_owner ? 'You' : escapeHtml(sl.name); const entityHref = '/app/entities#' + encodeURIComponent(sl.entity_id); 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>`; } html += `<div class="tr-entry-text">${escapeHtml(item.markdown)}</div>`; html += '</div></div>'; } else if (item.type === 'screen') { if (textOnlyScreen) { const timeStr = item.time || ''; const markdown = window.AppServices.renderMarkdown(item.markdown) || 'Screen activity'; html += '<div class="tr-entry" role="listitem">'; html += `<div class="tr-entry-time">${timeStr}</div>`; html += '<div class="tr-entry-content">'; html += '<span class="sr-only">Screen: </span>'; html += `<div class="tr-screen-text">${markdown}</div>`; html += '</div></div>'; } else { // Enhanced screen frame - render fully html += renderEnhancedScreenEntry(item, idx); } } });
html += '</div>'; targetEl.innerHTML = html;
// Get audio element reference const paneAudioEl = targetEl.querySelector('audio[data-role="segment-audio"]');
// Add click handlers for audio entries to seek targetEl.querySelectorAll('.tr-entry-audio').forEach(entry => { entry.addEventListener('click', () => { if (paneAudioEl && segmentData?.audio_file) { const timestamp = parseInt(entry.dataset.timestamp, 10); const baseTimestamp = chunks[0]?.timestamp || timestamp; const offsetSec = (timestamp - baseTimestamp) / 1000; paneAudioEl.currentTime = Math.max(0, offsetSec); paneAudioEl.play(); } }); });
// Add keyboard handler for audio entries targetEl.querySelectorAll('.tr-entry-audio').forEach(entry => { entry.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); entry.click(); } }); });
// Playback highlight: track current entry during audio playback if (paneAudioEl) { const baseTimestamp = chunks[0]?.timestamp || 0; let activeEntry = null;
paneAudioEl.addEventListener('timeupdate', () => { const currentMs = baseTimestamp + (paneAudioEl.currentTime * 1000); const entries = targetEl.querySelectorAll('.tr-entry-audio[data-timestamp]'); let best = null; for (const el of entries) { const ts = parseInt(el.dataset.timestamp, 10); if (ts <= currentMs) best = el; else break; } if (best === activeEntry) return; if (activeEntry) activeEntry.classList.remove('tr-entry-active'); activeEntry = best; if (activeEntry) { activeEntry.classList.add('tr-entry-active'); activeEntry.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } });
paneAudioEl.addEventListener('ended', () => { if (activeEntry) { activeEntry.classList.remove('tr-entry-active'); activeEntry = null; } }); }
// Add click handlers for enhanced screen entries to open modal targetEl.querySelectorAll('.tr-entry-screen').forEach(entry => { const thumb = entry.querySelector('.tr-entry-thumb'); if (thumb) { thumb.addEventListener('click', (e) => { e.stopPropagation(); const frameIdx = parseInt(entry.dataset.frameIdx, 10); if (!isNaN(frameIdx)) openImageModal(frameIdx); }); } });
// Add click handlers for group headers to expand/collapse targetEl.querySelectorAll('.tr-group-header').forEach(header => { header.addEventListener('click', () => { const groupEl = header.parentElement; const isExpanded = groupEl.classList.toggle('expanded'); header.setAttribute('aria-expanded', String(isExpanded)); if (!isExpanded) return; if (groupEl.dataset.prefetched === 'true') return; const groupIdx = parseInt(groupEl.dataset.idx, 10); if (isNaN(groupIdx)) return; const entries = groupEntriesByIdx.get(groupIdx) || []; prefetchGroupThumbnails( entries, groupEl, targetEl, currentSegmentAbort?.signal ); }); header.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); header.click(); } }); });
// Add click handlers for group grid items to open modal targetEl.querySelectorAll('.tr-group-item').forEach(item => { item.addEventListener('click', () => { const frameIdx = parseInt(item.dataset.frameIdx, 10); if (!isNaN(frameIdx)) openImageModal(frameIdx); }); item.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); item.click(); } }); });
// Set up lazy loading for canvas thumbnails using IntersectionObserver if (!textOnlyScreen) { setupLazyCanvasLoading(targetEl); } }
function prefetchGroupThumbnails(entries, groupEl, targetEl, signal) { const frameIdsByVideo = new Map(); for (const entry of entries) { const filename = entry.source_ref?.filename; const frameId = entry.source_ref?.frame_id; if (!filename || !frameId) continue; if (!frameIdsByVideo.has(filename)) { frameIdsByVideo.set(filename, new Set()); } frameIdsByVideo.get(filename).add(frameId); }
const jobs = []; for (const [filename, frameIds] of frameIdsByVideo.entries()) { const url = currentVideoFiles[filename]; if (!url) continue; jobs.push( frameCapture.prefetchThumbnails(url, Array.from(frameIds), null, signal) ); }
if (jobs.length > 0) { groupEl.dataset.prefetched = 'true'; Promise.all(jobs).finally(() => { setupLazyCanvasLoading(targetEl); }); } }
// Lazy load canvas thumbnails when they become visible function setupLazyCanvasLoading(targetEl = panel) { const canvases = targetEl.querySelectorAll('canvas[data-video-url]'); if (canvases.length === 0) return;
const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const canvas = entry.target; observer.unobserve(canvas); loadCanvasThumbnail(canvas); } } }, { rootMargin: '100px' });
canvases.forEach(canvas => observer.observe(canvas)); }
// Load a single canvas thumbnail function loadCanvasThumbnail(canvas) { const videoUrl = canvas.dataset.videoUrl; const frameId = parseInt(canvas.dataset.frameId, 10);
if (!videoUrl || isNaN(frameId)) { canvas.classList.remove('loading'); return; }
// Draw at thumbnail size (120x68) - no overlays on thumbnails frameCapture.drawThumbnail(canvas, videoUrl, frameId, { width: 120, height: 68 }); }
function groupBasicScreenFrames(chunks) { // Group sequential basic screen frames, keep audio and enhanced screens separate const result = []; let currentGroup = null;
for (const chunk of chunks) { const isBasicScreen = chunk.type === 'screen' && chunk.basic === true;
if (isBasicScreen) { // Add to current group or start new one if (!currentGroup) { currentGroup = { type: 'screen-group', entries: [], startTime: chunk.time, endTime: chunk.time }; } currentGroup.entries.push(chunk); currentGroup.endTime = chunk.time; } else { // Flush any pending group if (currentGroup) { result.push(currentGroup); currentGroup = null; } result.push(chunk); } }
// Flush final group if (currentGroup) { result.push(currentGroup); }
return result; }
function findFrameIndex(chunk) { // Find this chunk's index in allScreenFrames by matching source_ref const ref = chunk.source_ref; return allScreenFrames.findIndex(f => f.source_ref?.filename === ref?.filename && f.source_ref?.frame_id === ref?.frame_id ); }
// Get video URL for a chunk function getVideoUrlForChunk(chunk) { const filename = chunk.source_ref?.filename; return filename ? currentVideoFiles[filename] : null; }
function renderScreenGroup(group, idx) { const count = group.entries.length; const timeRange = group.startTime === group.endTime ? group.startTime : `${group.startTime} - ${group.endTime}`; const countText = count === 1 ? '1 frame' : `${count} frames`;
let html = `<div class="tr-group" data-idx="${idx}" role="listitem">`; html += `<div class="tr-group-header" role="button" tabindex="0" aria-expanded="false" aria-controls="tr-group-grid-${idx}">`; 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>`; html += `<span class="tr-group-time">${timeRange}</span>`; html += `<span class="tr-group-count">${countText}</span>`; html += '</div>';
// Grid of thumbnails (hidden until expanded) html += `<div class="tr-group-grid" id="tr-group-grid-${idx}">`; for (const entry of group.entries) { const videoUrl = getVideoUrlForChunk(entry); const frameId = entry.source_ref?.frame_id; const analysis = entry.source_ref?.analysis || {}; const category = analysis.primary || 'unknown'; const description = analysis.visual_description || category; const frameIdx = findFrameIndex(entry);
if (videoUrl && frameId) { html += `<div class="tr-group-item" data-frame-idx="${frameIdx}" title="${escapeHtml(description)}" role="button" tabindex="0">`; html += `<canvas class="loading" data-video-url="${escapeHtml(videoUrl)}" data-frame-id="${frameId}"></canvas>`; html += `<span class="tr-group-item-badge">${escapeHtml(category)}</span>`; html += '</div>'; } } html += '</div>';
html += '</div>'; return html; }
function renderEnhancedScreenEntry(chunk, idx) { const timeStr = chunk.time || ''; const monitor = chunk.source_ref?.monitor || ''; const videoUrl = getVideoUrlForChunk(chunk); const frameId = chunk.source_ref?.frame_id; const frameIdx = findFrameIndex(chunk);
let html = `<div class="tr-entry tr-entry-screen" data-idx="${idx}" data-frame-idx="${frameIdx}" data-type="screen" role="listitem">`; html += `<div class="tr-entry-time">${timeStr}</div>`; html += '<div class="tr-entry-content">'; html += '<span class="sr-only">Screen: </span>';
if (videoUrl && frameId) { html += `<canvas class="tr-entry-thumb loading" data-video-url="${escapeHtml(videoUrl)}" data-frame-id="${frameId}"></canvas>`; }
html += '<div class="tr-entry-desc">'; if (monitor) { const monitorPos = getMonitorPosition(monitor); if (monitorPos) html += `<span class="tr-entry-badge tr-entry-badge-monitor">${monitorPos}</span>`; } if (chunk.markdown) { html += window.AppServices.renderMarkdown(chunk.markdown); } html += '</div>';
html += '</div></div>'; return html; }
function openImageModal(frameIndex) { if (frameIndex < 0 || frameIndex >= allScreenFrames.length) return;
currentFrameIndex = frameIndex; let maskHidden = false; // Track if user has revealed masked content const triggerElement = document.activeElement; const prevOverflow = document.body.style.overflow;
const modal = document.createElement('div'); modal.className = 'tr-screenshot-modal'; modal.id = 'trImageModal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Screenshot viewer');
const drawFrame = (canvas, f, showMask) => { const videoUrl = getVideoUrlForChunk(f); const frameId = f.source_ref?.frame_id; const boxCoords = f.source_ref?.box_2d; const aruco = f.source_ref?.aruco; const participants = f.source_ref?.participants || [];
if (videoUrl && frameId) { frameCapture.drawFull(canvas, videoUrl, frameId, { boxCoords, participants, aruco: showMask ? aruco : null // Pass null to skip mask }); } else { canvas.classList.remove('loading'); } };
const updateModalContent = () => { const f = allScreenFrames[currentFrameIndex]; const monitor = f.source_ref?.monitor || ''; const monitorPos = getMonitorPosition(monitor); const analysis = f.source_ref?.analysis || {}; const category = analysis.primary || ''; const description = analysis.visual_description || ''; const aruco = f.source_ref?.aruco; const isMasked = aruco?.masked && !maskHidden; const hasPrev = currentFrameIndex > 0; const hasNext = currentFrameIndex < allScreenFrames.length - 1;
modal.innerHTML = ` <div class="tr-modal-nav${hasPrev ? '' : ' disabled'}" data-dir="prev" title="Previous frame (Left arrow)" role="button" tabindex="${hasPrev ? '0' : '-1'}"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg> </div> <div class="tr-modal-center"> <div class="tr-modal-header"> ${monitorPos ? `<span class="tr-modal-badge tr-modal-badge-monitor">${monitorPos}</span>` : ''} ${category ? `<span class="tr-modal-badge tr-modal-badge-category">${escapeHtml(category)}</span>` : ''} ${isMasked ? '<span class="tr-modal-badge tr-modal-badge-masked" title="Click image to reveal">Masked</span>' : ''} <button class="tr-modal-close" title="Close (Esc)" aria-label="Close">×</button> </div> <div class="tr-modal-img-wrap"> <canvas id="trModalCanvas" class="loading${isMasked ? ' tr-masked-canvas' : ''}"></canvas> </div> ${description ? `<div class="tr-modal-description">${escapeHtml(description)}</div>` : ''} </div> <div class="tr-modal-nav${hasNext ? '' : ' disabled'}" data-dir="next" title="Next frame (Right arrow)" role="button" tabindex="${hasNext ? '0' : '-1'}"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg> </div> `;
// Draw frame to modal canvas const canvas = modal.querySelector('#trModalCanvas'); drawFrame(canvas, f, !maskHidden);
// Add click-to-reveal handler for masked frames if (aruco?.masked) { canvas.addEventListener('click', () => { if (!maskHidden) { maskHidden = true; canvas.classList.remove('tr-masked-canvas'); canvas.classList.add('loading'); modal.querySelector('.tr-modal-badge-masked')?.remove(); drawFrame(canvas, f, false); } }); } };
const navigateFrame = (delta) => { const newIndex = currentFrameIndex + delta; if (newIndex >= 0 && newIndex < allScreenFrames.length) { currentFrameIndex = newIndex; maskHidden = false; // Reset mask state when navigating updateModalContent(); modal.querySelector('.tr-modal-close')?.focus(); } };
const closeModal = () => { modal.remove(); document.removeEventListener('keydown', handleKeys); document.body.style.overflow = prevOverflow; if (triggerElement && document.contains(triggerElement)) triggerElement.focus(); currentFrameIndex = -1; };
const handleKeys = (e) => { if (e.key === 'Escape') closeModal(); else if (e.key === 'ArrowLeft') { e.preventDefault(); navigateFrame(-1); } else if (e.key === 'ArrowRight') { e.preventDefault(); navigateFrame(1); } else if (e.key === 'Tab') { const focusable = [...modal.querySelectorAll('button:not([disabled]), [tabindex="0"]')]; if (focusable.length === 0) return; const currentIndex = focusable.indexOf(document.activeElement); if (e.shiftKey) { e.preventDefault(); focusable[currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1].focus(); } else { e.preventDefault(); focusable[currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1].focus(); } } };
// Event delegation for modal clicks modal.addEventListener('click', (e) => { const target = e.target.closest('.tr-modal-close, .tr-modal-nav:not(.disabled)'); if (target) { if (target.classList.contains('tr-modal-close')) closeModal(); else if (target.classList.contains('tr-modal-nav')) { navigateFrame(target.dataset.dir === 'prev' ? -1 : 1); } } else if (e.target === modal || e.target.classList.contains('tr-modal-img-wrap')) { closeModal(); } });
document.body.style.overflow = 'hidden'; document.body.appendChild(modal); updateModalContent(); modal.querySelector('.tr-modal-close')?.focus(); document.addEventListener('keydown', handleKeys); }
function updateZoom() { zoom.setAttribute('aria-label', 'Detail timeline (' + hhmm(range.start) + '\u2013' + hhmm(range.end) + ')'); zoomHeight = zoom.clientHeight - 24; // account for padding const rangeLen = range.end - range.start; if (rangeLen > 0) { zoomPpm = zoomHeight / rangeLen; buildZoomGrid(); buildZoomSegments(); } }
// Resize observers // Account for 12px padding top and bottom const PADDING = 24;
new ResizeObserver(() => { height = timeline.clientHeight - PADDING; ppm = height / (timelineEnd - timelineStart); buildGrid(); renderTimeline(); if (updateNowPosition) updateNowPosition(); }).observe(timeline);
new ResizeObserver(() => { updateZoom(); }).observe(zoom);
// Load combined transcript data fetch(`/app/transcripts/api/day/${day}`) .then(r => { if (!r.ok) throw new Error(`Day data failed: ${r.status}`); return r.json(); }) .then(data => { // Apply dynamic timeline bounds from ranges const bounds = computeTimelineBounds(data); timelineStart = bounds.start; timelineEnd = bounds.end; timeline.setAttribute('aria-label', 'Day timeline (' + hhmm(timelineStart) + '\u2013' + hhmm(timelineEnd) + ')');
// Recalculate pixels-per-minute with new bounds height = timeline.clientHeight - PADDING; ppm = height / (timelineEnd - timelineStart);
// Set initial selection range within bounds // Center on current time if viewing today, otherwise midpoint const now = new Date(); const todayStr = String(now.getFullYear()) + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0'); let center; if (day === todayStr) { const nowMin = now.getHours() * 60 + now.getMinutes(); center = Math.max(timelineStart, Math.min(timelineEnd, nowMin)); } else { center = (timelineStart + timelineEnd) / 2; } range = { start: snap(center - DEFAULT_LEN / 2), end: snap(center + DEFAULT_LEN / 2) }; if (range.start < timelineStart) { range = { start: timelineStart, end: timelineStart + DEFAULT_LEN }; } if (range.end > timelineEnd) { range = { start: timelineEnd - DEFAULT_LEN, end: timelineEnd }; }
// Build the grid and render timeline buildGrid(); renderTimeline();
// Add segment indicators from ranges (data.audio || []).forEach(rg => { const [s, e] = rg.map(parseTime); addSegmentIndicator('audio', s, e, 0); }); (data.screen || []).forEach(rg => { const [s, e] = rg.map(parseTime); addSegmentIndicator('screen', s, e, 1); });
// Now-marker for today if (day === todayStr) { const marker = document.createElement('div'); marker.className = 'tr-now-marker'; marker.setAttribute('aria-label', 'Current time'); const lbl = document.createElement('span'); lbl.className = 'tr-now-label'; lbl.textContent = 'now'; marker.appendChild(lbl); timeline.appendChild(marker);
updateNowPosition = function() { const n = new Date(); const nowMin = n.getHours() * 60 + n.getMinutes(); if (nowMin < timelineStart || nowMin > timelineEnd) { marker.style.display = 'none'; } else { marker.style.display = ''; marker.style.top = y(nowMin) + 'px'; } }; updateNowPosition(); setInterval(updateNowPosition, 60000); }
// Store segments and update zoom allSegments = data.segments || []; updateZoom(); if (allSegments.length === 0) { panel.innerHTML = window.SurfaceState.empty({ icon: emptyIcons.nothing, heading: 'nothing captured', desc: 'no recordings were found for this day' }); }
// Check for hash fragment to auto-select segment const rawHash = window.location.hash.slice(1); const [segKey, tabIdFromHash] = rawHash.split('/', 2); if (segKey) { const seg = allSegments.find(s => s.key === segKey); if (seg) { const segStart = parseTime(seg.start); const segEnd = parseTime(seg.end); const rangeLen = range.end - range.start; const segMid = (segStart + segEnd) / 2; let newStart = snap(Math.max(timelineStart, segMid - rangeLen / 2)); newStart = Math.min(newStart, timelineEnd - rangeLen); range = { start: newStart, end: newStart + rangeLen }; renderTimeline(); updateZoom(); selectSegment(seg, false, tabIdFromHash); } } }) .catch(err => { console.error('Failed to load transcript data:', err); zoomSegments.innerHTML = ''; panel.innerHTML = window.SurfaceState.error({ icon: emptyIcons.transcript, heading: 'couldn\'t load transcripts', desc: 'the data service may be offline. try refreshing the page.' }); });
// Handle browser back/forward window.addEventListener('hashchange', () => { const rawHash = window.location.hash.slice(1); const [segKey, tabIdFromHash] = rawHash.split('/', 2); if (!segKey) { return; }
const seg = allSegments.find(s => s.key === segKey); if (!seg) { return; }
if (!selectedSegment || selectedSegment.key !== segKey) { selectSegment(seg, false, tabIdFromHash); return; }
if ( tabIdFromHash && [...tabsContainer.querySelectorAll('.tr-tab')].some( tab => tab.dataset.tab === tabIdFromHash ) ) { activateTab(tabIdFromHash); } });
// Keyboard navigation for segment stepping document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && deleteSegmentModal.style.display !== 'none') { e.preventDefault(); closeDeleteSegmentModal(); return; } const tag = e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return; if (document.getElementById('trImageModal')) return; if (deleteSegmentModal.style.display !== 'none') return; if (e.key === ']') { e.preventDefault(); navigateSegment(1); } else if (e.key === '[') { e.preventDefault(); navigateSegment(-1); } });
window.addEventListener('click', (event) => { if (event.target === deleteSegmentModal) { closeDeleteSegmentModal(); } });
// Click on timeline to set range timeline.addEventListener('click', (e) => { if (e.target.closest('.tr-sel')) return; const box = timeline.getBoundingClientRect(); const py = e.clientY - box.top; let mid = snap(mFromY(py)); let start = Math.max(timelineStart, Math.min(timelineEnd - DEFAULT_LEN, mid - DEFAULT_LEN / 2)); start = snap(start); range = { start, end: snap(start + DEFAULT_LEN) }; renderTimeline(); updateZoom(); });
// Drag handlers for main selection function onPointerMove(ev) { if (!drag) return; ev.preventDefault(); const dy = ev.clientY - drag.y0; const dMin = Math.round((dy / ppm) / STEP) * STEP;
if (drag.mode === 'move') { const len = drag.r0.end - drag.r0.start; let s = drag.r0.start + dMin; s = Math.max(timelineStart, Math.min(timelineEnd - len, s)); s = snap(s); range = { start: s, end: snap(s + len) }; } else if (drag.mode === 'start') { let s = drag.r0.start + dMin; s = Math.max(timelineStart, Math.min(drag.r0.end - MIN_LEN, s)); range = { start: snap(s), end: snap(drag.r0.end) }; } else if (drag.mode === 'end') { let e = drag.r0.end + dMin; e = Math.min(timelineEnd, Math.max(drag.r0.start + MIN_LEN, e)); range = { start: snap(drag.r0.start), end: snap(e) }; } renderTimeline(); updateZoom(); }
function onPointerUp() { drag = null; document.body.classList.remove('tr-dragging'); window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); }
function beginDrag(mode) { return (ev) => { ev.stopPropagation(); ev.preventDefault(); document.body.classList.add('tr-dragging'); drag = { mode, y0: ev.clientY, r0: { ...range } }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); }; }
sel.addEventListener('pointerdown', beginDrag('move')); sel.querySelector('[data-handle="start"]').addEventListener('pointerdown', beginDrag('start')); sel.querySelector('[data-handle="end"]').addEventListener('pointerdown', beginDrag('end'));
function getMonitorPosition(monitor) { if (!monitor) return null; // Extract position from monitor string (e.g., "center_DP-3" -> "Center") const pos = monitor.split('_')[0]; if (!pos) return null; // Capitalize first letter return pos.charAt(0).toUpperCase() + pos.slice(1); }
function showDeleteSegmentModal() { if (!selectedSegment) return;
const seg = selectedSegment; const totalBytes = (segmentData?.media_sizes?.audio || 0) + (segmentData?.media_sizes?.screen || 0); const sizeLine = totalBytes > 0 ? `<p class="tr-delete-segment-size">Current raw media size: ${formatSize(totalBytes)}</p>` : '';
deleteSegmentModalBody.innerHTML = ` <p class="tr-delete-segment-meta"><strong>${escapeHtml(seg.stream)}</strong> · <strong>${escapeHtml(day)}</strong> · <strong>${escapeHtml(seg.start)} - ${escapeHtml(seg.end)}</strong></p> <p>You'll have about 10 seconds to undo this after confirming.</p> <p>This removes:</p> <ul class="tr-delete-segment-list"> <li>audio</li> <li>screen recording</li> <li>transcripts</li> <li>derived insights</li> </ul> ${sizeLine} `;
deleteModalReturnFocus = document.activeElement; deleteModalPrevOverflow = document.body.style.overflow; deleteSegmentModal.style.display = 'flex'; document.body.style.overflow = 'hidden'; deleteSegmentModalConfirm.focus(); }
function closeDeleteSegmentModal(restoreFocus = true) { if (deleteSegmentModal.style.display === 'none') return;
deleteSegmentModal.style.display = 'none'; deleteSegmentModalBody.innerHTML = ''; document.body.style.overflow = deleteModalPrevOverflow; deleteModalPrevOverflow = ''; if (restoreFocus && deleteModalReturnFocus && document.contains(deleteModalReturnFocus)) { deleteModalReturnFocus.focus(); } deleteModalReturnFocus = null; }
async function confirmDeleteSegment() { if (!selectedSegment) return;
const seg = selectedSegment; closeDeleteSegmentModal(false); await performDeleteSegment(seg); }
function sortSegmentsByKey() { allSegments.sort((a, b) => a.key.localeCompare(b.key)); }
function refreshSegmentIndicators() { return fetch(`/app/transcripts/api/ranges/${day}`) .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges')) .then(data => { segmentsLane.innerHTML = ''; (data.audio || []).forEach(rg => { const [s, e] = rg.map(parseTime); addSegmentIndicator('audio', s, e, 0); }); (data.screen || []).forEach(rg => { const [s, e] = rg.map(parseTime); addSegmentIndicator('screen', s, e, 1); }); }) .catch(() => { // Range indicators may be stale, but the main segment state is already updated. }); }
async function performDeleteSegment(seg) { try { const response = await fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { method: 'DELETE' });
if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.error || 'Failed to delete segment'); }
const data = await response.json();
// Remove segment from local state allSegments = allSegments.filter(s => s.key !== seg.key);
// Clear selection and UI clearSegmentSelection();
// Re-render zoom timeline buildZoomSegments();
refreshSegmentIndicators();
let pendingNotificationId = null; pendingNotificationId = window.AppServices.notifications.show({ app: 'transcripts', icon: '🗑️', title: 'Deleting segment…', message: 'Cancels in 10s', dismissible: true, autoDismiss: 10000, buttons: [{ label: 'Cancel', dismiss: false, onClick: () => cancelSegmentDelete(data.pending, seg, pendingNotificationId) }] });
if (data.search_index_warning === true) { window.AppServices.notifications.show({ app: 'transcripts', icon: '⚠️', title: 'Search index may be stale', message: 'Search results may be briefly stale until the supervisor is restarted and the index is rebuilt.', dismissible: true, autoDismiss: 10000 }); }
} catch (err) { const notice = document.createElement('div'); notice.className = 'tr-warning-notice visible'; notice.style.borderColor = '#fca5a5'; notice.style.background = '#fef2f2'; notice.style.color = '#991b1b'; 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>'; panel.insertBefore(notice, panel.firstChild); setTimeout(() => { notice.style.opacity = '0'; setTimeout(() => notice.remove(), 300); }, 5000); } }
async function cancelSegmentDelete(pendingId, seg, notificationId) { try { const response = await fetch(`/app/transcripts/api/cancel-delete/${pendingId}`, { method: 'POST' }); const data = await response.json().catch(() => ({}));
if (notificationId !== null) { window.AppServices.notifications.dismiss(notificationId); }
if (response.ok) { if (!allSegments.some(existing => existing.key === seg.key)) { allSegments.push(seg); sortSegmentsByKey(); } buildZoomSegments(); refreshSegmentIndicators(); window.AppServices.notifications.show({ app: 'transcripts', icon: '↩️', title: 'Delete cancelled', autoDismiss: 3000 }); return; }
if (response.status === 410) { window.AppServices.notifications.show({ app: 'transcripts', icon: '⏱️', title: 'Too late — already deleted', autoDismiss: 3000 }); return; }
throw new Error(data.error || 'Failed to cancel delete'); } catch (err) { window.AppServices.notifications.show({ app: 'transcripts', icon: '⚠️', title: 'Couldn\'t cancel delete', message: err.message || 'Please try again.', autoDismiss: 3000 }); } }
window.closeDeleteSegmentModal = closeDeleteSegmentModal; window.confirmDeleteSegment = confirmDeleteSegment;
// Clear selection and reset UI state function clearSegmentSelection() { selectedSegment = null; segmentData = null; currentVideoFiles = {}; activeTab = null; tabPanes = {}; screenDecoded = false; frameCapture.clear(); allScreenFrames = []; currentFrameIndex = -1; groupEntriesByIdx.clear();
// Stop and clear audio player reference panel.querySelectorAll('audio').forEach(audio => audio.pause());
// Hide delete button deleteBtn.classList.remove('visible');
// Clear URL hash history.replaceState(null, '', window.location.pathname);
// Reset UI titleEl.textContent = 'Transcripts'; rangeText.textContent = ''; tabsContainer.innerHTML = ''; tabsContainer.classList.remove('visible'); document.getElementById('trWarningNotice').classList.remove('visible'); panel.innerHTML = window.SurfaceState.empty({ icon: emptyIcons.day, heading: 'your day at a glance', desc: 'select a segment from the timeline to view its transcript' });
// Clear active state in zoom view zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => { pill.classList.remove('tr-active'); }); }
// Delete segment handler deleteBtn.addEventListener('click', () => { showDeleteSegmentModal(); });
})();</script>