77// Colors come from the page when it sets them: --fig-bg, --fig-fg, --fig-muted, --fig-surface, --fig-border,
88// --fig-group (group frames; default --fig-bg), --fig-accent. The player redraws when the page switches theme ([data-theme] or the system setting).
99import { resolveStyle } from './styles.js' ;
10- import { timeline , frameAt , usesStage } from './scenes.js' ;
10+ import { timeline , frameAt , usesStage , TRAVEL } from './scenes.js' ;
1111import { drawContent , resolveFrame , boundsOf , litOutline , activeArrow , toSvg } from './render.js' ;
1212
1313const CSS = `
@@ -23,6 +23,11 @@ const CSS = `
2323.btn:hover { border-color: var(--fig-accent, #2563eb); }
2424.view { overflow-x: auto; overflow-y: hidden; }
2525svg { display: block; margin: 0 auto; }
26+ .foot { display: flex; flex-direction: column; align-items: center; gap: 2px; }
27+ .nav { display: flex; align-items: center; gap: 2px; font-size: .78em; color: var(--fig-muted, #6b7280); font-variant-numeric: tabular-nums; }
28+ .nav[hidden] { display: none; }
29+ .nav button { border: 0; background: none; color: inherit; cursor: pointer; font: inherit; font-size: 1.3em; line-height: 1; padding: 2px 7px; border-radius: 5px; }
30+ .nav button:hover { color: var(--fig-fg, inherit); background: var(--fig-surface, transparent); }
2631.cap { min-height: 1.45em; text-align: center; color: var(--fig-muted, #6b7280); font-size: .92em; line-height: 1.45; transition: opacity .3s; }
2732.full { position: fixed; inset: 0; z-index: 2147483000; padding: 14px 18px; background: var(--fig-bg, Canvas); }
2833.full .view { flex: 1; display: flex; align-items: center; overflow: auto; }
@@ -38,8 +43,13 @@ export class GiottoPlayer extends Base {
3843 super ( ) ;
3944 this . state = { si : 0 , t : 0 , playing : false , speed : 1 , hover : null , full : false , seen : true } ;
4045 const root = this . attachShadow ( { mode : 'open' } ) ;
41- root . innerHTML = `<style>${ CSS } </style><div class="wrap"><div class="bar" part="controls"></div><div class="view"><svg xmlns="http://www.w3.org/2000/svg" role="img"></svg></div><div class="cap" part="caption" aria-live="polite"></div></div>` ;
42- [ this . wrap , this . bar , this . view , this . svg , this . cap ] = [ '.wrap' , '.bar' , '.view' , 'svg' , '.cap' ] . map ( ( q ) => root . querySelector ( q ) ) ;
46+ root . innerHTML = `<style>${ CSS } </style><div class="wrap"><div class="bar" part="controls"></div><div class="view"><svg xmlns="http://www.w3.org/2000/svg" role="img"></svg></div><div class="foot"><div class="cap" part="caption" aria-live="polite"></div>`
47+ + '<div class="nav" part="steps" hidden><button title="Previous step">‹</button><span></span><button title="Next step">›</button></div></div></div>' ;
48+ [ this . wrap , this . bar , this . view , this . svg , this . cap , this . foot , this . nav ] = [ '.wrap' , '.bar' , '.view' , 'svg' , '.cap' , '.foot' , '.nav' ] . map ( ( q ) => root . querySelector ( q ) ) ;
49+ this . count = this . nav . querySelector ( 'span' ) ;
50+ const [ prev , next ] = this . nav . querySelectorAll ( 'button' ) ;
51+ prev . onclick = ( ) => this . step ( - 1 ) ;
52+ next . onclick = ( ) => this . step ( 1 ) ;
4353 this . svg . addEventListener ( 'pointermove' , ( ev ) => this . hoverAt ( ev ) ) ;
4454 this . svg . addEventListener ( 'pointerleave' , ( ) => this . setHover ( null ) ) ;
4555 this . onKey = ( ev ) => ev . key === 'Escape' && this . state . full && this . toggleFull ( false ) ;
@@ -133,7 +143,7 @@ export class GiottoPlayer extends Base {
133143 if ( ! this . box ) return ;
134144 const { w, h } = this . box ;
135145 let k ;
136- if ( this . state . full ) k = Math . min ( ( innerWidth - 36 ) / w , ( innerHeight - 28 - this . bar . offsetHeight - this . cap . offsetHeight - 12 ) / h ) ;
146+ if ( this . state . full ) k = Math . min ( ( innerWidth - 36 ) / w , ( innerHeight - 28 - this . bar . offsetHeight - this . foot . offsetHeight - 12 ) / h ) ;
137147 else k = Math . max ( 0.5 , Math . min ( 1 , this . view . clientWidth / w || 1 ) ) ;
138148 this . svg . setAttribute ( 'width' , Math . round ( w * k ) ) ;
139149 this . svg . setAttribute ( 'height' , Math . round ( h * k ) ) ;
@@ -166,6 +176,23 @@ export class GiottoPlayer extends Base {
166176 this . draw ( ) ;
167177 }
168178
179+ // One step (a beat) back or forward, across scene edges. Playing goes on from that step's start; paused
180+ // shows it with its packets just arrived, which is the moment the step is about.
181+ step ( dir ) {
182+ const st = this . state , scenes = this . tl . scenes ;
183+ const sc = scenes [ st . si ] , here = Math . max ( 0 , sc . beats . findLastIndex ( ( b ) => b . start <= st . t ) ) ;
184+ let si = st . si , bi = here + dir ;
185+ while ( bi < 0 || bi >= scenes [ si ] . beats . length ) {
186+ si = ( si + ( bi < 0 ? - 1 : 1 ) + scenes . length ) % scenes . length ;
187+ bi = bi < 0 ? scenes [ si ] . beats . length - 1 : 0 ;
188+ if ( si === st . si && ! scenes [ si ] . beats . length ) return ;
189+ }
190+ const b = scenes [ si ] . beats [ bi ] ;
191+ Object . assign ( st , { si, t : st . playing ? b . start : b . start + b . ms * TRAVEL } ) ;
192+ this . controls ( ) ;
193+ this . draw ( ) ;
194+ }
195+
169196 toggle ( ) {
170197 const st = this . state , sc = this . tl . scenes [ st . si ] ;
171198 st . playing = ! st . playing ;
@@ -217,6 +244,9 @@ export class GiottoPlayer extends Base {
217244 if ( e ) body += els . filter ( ( a ) => a . start ?. id === hover || a . end ?. id === hover ) . map ( ( a ) => activeArrow ( a , S ) ) . join ( '' ) + litOutline ( e , S ) ;
218245 }
219246 this . svg . innerHTML = body ;
247+ const beats = this . tl . scenes [ si ] ?. beats . length ?? 0 ;
248+ this . nav . hidden = ! beats ;
249+ if ( beats ) this . count . textContent = `${ frame . beat + 1 } / ${ beats } ` ;
220250 const say = frame ?. say || '' ;
221251 if ( this . cap . textContent !== say ) this . cap . textContent = say ;
222252 this . cap . style . opacity = frame ? Math . max ( 0.15 , frame . sayAlpha ) : 1 ;
0 commit comments