2023-12-18 10:56:07 +01:00
class Annotation {
constructor ( tag , t _in , t _out , comment ) {
this . tag = tag ;
this . t _in = Number . parseFloat ( t _in ) ;
this . t _out = Number . parseFloat ( t _out ) ;
this . comment = comment ;
}
}
class StrokeGroup {
constructor ( group _element , player ) {
this . g = group _element ;
this . player = player ;
}
setStrokes ( strokes ) {
const pathEls = this . g . querySelectorAll ( 'path' ) ;
let indexes = Object . keys ( strokes ) ;
for ( let pathEl of pathEls ) {
const i = pathEl . dataset . path _i ;
if ( ! indexes . includes ( pathEl . dataset . path _i ) ) {
pathEl . parentNode . removeChild ( pathEl ) ;
} else {
// check in and outpoint using pathEl.dataset
if ( strokes [ i ] . getSliceId ( ) != pathEl . dataset . slice ) {
const d = this . points2D ( strokes [ i ] . points ) ;
pathEl . dataset . slice = strokes [ i ] . getSliceId ( ) ;
pathEl . setAttribute ( 'd' , d ) ;
}
}
// this has now been processed
indexes . splice ( indexes . indexOf ( i ) , 1 ) ;
}
// new strokes
indexes . forEach ( index => {
const stroke = strokes [ index ] ;
let pathEl = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' ) ;
pathEl . style . stroke = stroke . color ;
pathEl . classList . add ( 'path' ) ;
pathEl . dataset . path _i = index ;
pathEl . dataset . slice = stroke . getSliceId ( ) ;
this . g . appendChild ( pathEl ) ;
const d = this . points2D ( stroke . points ) ;
pathEl . setAttribute ( 'd' , d ) ;
} ) ;
}
// convert array of points to a d-attribute
points2D ( strokes ) {
// strokes to a d attribute for a path
let d = "" ;
let last _stroke = undefined ;
let cmd = "" ;
for ( let stroke of strokes ) {
if ( ! last _stroke ) {
d += ` M ${ stroke [ 0 ] } , ${ stroke [ 1 ] } ` ;
cmd = 'M' ;
} else {
if ( last _stroke [ 2 ] == 1 ) {
d += " m" ;
cmd = 'm' ;
} else if ( cmd != 'l' ) {
d += ' l ' ;
cmd = 'l' ;
}
let rel _stroke = [ stroke [ 0 ] - last _stroke [ 0 ] , stroke [ 1 ] - last _stroke [ 1 ] ] ;
d += ` ${ rel _stroke [ 0 ] } , ${ rel _stroke [ 1 ] } ` ;
}
last _stroke = stroke ;
}
return d ;
}
setPrecomputedStrokes ( strokeDs ) {
const pathEls = this . g . querySelectorAll ( 'path' ) ;
for ( let pathEl of pathEls ) {
pathEl . parentNode . removeChild ( pathEl ) ;
}
strokeDs . forEach ( ( strokeD , index ) => {
let pathEl = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' ) ;
// pathEl.style.stroke = stroke.color;
// pathEl.classList.add('path');
pathEl . setAttribute ( 'd' , strokeD ) ;
this . g . appendChild ( pathEl ) ;
} ) ;
}
}
class Stroke {
constructor ( color , points ) {
this . color = color ;
this . points = points ; // [[x1,y1,t1], [x2,y2,t2], ...]
}
getSliceId ( ) {
return 'all' ;
}
}
class StrokeSlice {
constructor ( stroke , i _in , i _out ) {
this . stroke = stroke ; // Stroke
this . i _in = typeof i _in === 'undefined' ? 0 : i _in ;
this . i _out = typeof i _out === 'undefined' ? this . stroke . points . length - 1 : i _out ;
}
getSliceId ( ) {
return ` ${ this . i _in } - ${ this . i _out } ` ;
}
// compatible with Stroke()
get points ( ) {
return this . stroke . points . slice ( this . i _in , this . i _out + 1 ) ;
}
// compatible with Stroke()
get color ( ) {
return this . stroke . color ;
}
}
const CropOptions = {
Fit _Selection : 'selection' ,
Follow _Drawing : 'follow' ,
Whole _Drawing : 'whole' ,
} ;
const CropDescriptions = {
selection : 'Crop to annotation' ,
follow : 'Follow drawing canvas' ,
whole : 'Show whole drawing' ,
}
class Annotator extends EventTarget {
constructor ( wrapperEl , tagFile , fileurl , config ) {
fileurl = fileurl . replace ( "&" , "&" ) ; // little hack: tornadoweb does this automatically for some reason
super ( ) ;
this . config = {
is _player : config && config . hasOwnProperty ( 'is_player' ) ? config . is _player : false , // in player mode annotations are not loaded, nor is the annotator shown
crop _to _fit : config && config . hasOwnProperty ( 'crop_to_fit' ) ? config . crop _to _fit : false , // DEPRECATED don't animate viewport, but show the whole drawing
crop : config && config . hasOwnProperty ( 'crop' ) && Object . values ( CropOptions ) . indexOf ( config . crop ) !== - 1 ? config . crop : CropOptions . Fit _Selection , // don't animate viewport, but show the whole drawing
autoplay : config && config . hasOwnProperty ( 'autoplay' ) ? config . autoplay : false , // immediately start playback
url _prefix : config && config . hasOwnProperty ( 'url_prefix' ) ? config . url _prefix : '' ,
}
this . formatter = wNumb ( {
decimals : 2 ,
edit : ( time ) => {
let neg = "" ;
if ( time < 0 ) {
neg = "-" ;
time *= - 1 ;
}
const s = Math . floor ( time / 1000 ) ;
const minutes = String ( Math . floor ( s / 60 ) ) . padStart ( 2 , '0' ) ;
const seconds = String ( s - minutes * 60 ) . padStart ( 2 , '0' ) ;
// show miliseconds only in annotator
const ms = ! this . config . is _player ? "." + String ( Math . floor ( ( time / 1000 - s ) * 1000 ) ) . padStart ( 3 , '0' ) : "" ;
return ` ${ neg } ${ minutes } : ${ seconds } ${ ms } ` ;
} ,
undo : ( tc ) => {
let [ rest , ms ] = tc . split ( /[\.\,]/ ) ;
ms = parseFloat ( typeof ms === "undefined" ? 0 : ms ) ;
let factor = 1000 ;
rest . split ( ':' ) . reverse ( ) . forEach ( ( v , i ) => {
ms += v * factor ;
factor *= 60 ;
} ) ;
return ` ${ ms } ` ;
}
} ) ;
this . wrapperEl = wrapperEl ;
this . svgEl = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' ) ;
this . wrapperEl . appendChild ( this . svgEl ) ;
this . wrapperEl . classList . add ( this . config . is _player ? "svganim_player" : "svganim_annotator" ) ;
this . wrapperEl . classList . add ( "crop-" + this . config . crop ) ;
this . controlsEl = document . createElement ( 'div' ) ;
this . controlsEl . classList . add ( 'controls' )
this . wrapperEl . appendChild ( this . controlsEl ) ;
this . playbackControlsEl = document . createElement ( 'div' ) ;
this . playbackControlsEl . classList . add ( 'controls--playback' )
this . controlsEl . appendChild ( this . playbackControlsEl ) ;
this . playheadEl = document . createElement ( 'input' ) ;
this . playheadEl . type = "range" ;
this . playheadEl . min = 0 ;
this . playheadEl . step = 0.01 ;
this . playbackControlsEl . appendChild ( this . playheadEl ) ;
this . playheadEl . addEventListener ( "input" , ( ev ) => {
this . scrubTo ( ev . target . value ) ;
} ) ;
this . playheadEl . addEventListener ( 'keydown' , ( ev ) => {
ev . preventDefault ( ) ; // we don't want to use arrow keys, as these are captured in the overall keydown event
} )
this . timeCodeEl = document . createElement ( 'input' ) ;
this . timeCodeEl . type = 'numeric' ;
this . timeCodeEl . classList . add ( 'timecode' ) ;
this . timeCodeEl . disabled = true ;
this . playbackControlsEl . appendChild ( this . timeCodeEl ) ;
this . playPauseEl = document . createElement ( 'button' ) ;
this . playPauseEl . classList . add ( 'paused' ) ;
this . playbackControlsEl . appendChild ( this . playPauseEl ) ;
this . playPauseEl . addEventListener ( "click" , ( ev ) => {
this . playPause ( )
} )
this . playPauseEl . addEventListener ( 'keydown' , ( ev ) => {
ev . preventDefault ( ) ; // we don't want to spacebar, as this is captured in the overall keydown event
} )
if ( ! this . config . is _player ) {
this . scrubberEl = document . createElement ( 'div' ) ;
this . scrubberEl . classList . add ( 'scrubber' )
this . controlsEl . appendChild ( this . scrubberEl ) ;
this . annotationsEl = document . createElement ( 'div' ) ;
this . annotationsEl . classList . add ( 'annotations' )
this . controlsEl . appendChild ( this . annotationsEl ) ;
} else {
const extraEl = document . createElement ( 'details' ) ;
extraEl . classList . add ( 'controls--extra' ) ;
const summaryEl = document . createElement ( 'summary' ) ;
summaryEl . innerHTML = "…" ;
extraEl . appendChild ( summaryEl ) ;
const extraControlsEl = document . createElement ( 'ul' ) ;
const toggleFutureHeaderEl = document . createElement ( 'li' ) ;
toggleFutureHeaderEl . classList . add ( 'config-header' , 'config-future' ) ;
toggleFutureHeaderEl . innerText = "Preview drawing" ;
extraControlsEl . appendChild ( toggleFutureHeaderEl ) ;
// TODO: add handlers to change text
const toggleFutureEl = document . createElement ( 'li' ) ;
toggleFutureEl . classList . add ( 'config-future' ) ;
toggleFutureEl . innerText = "Show"
toggleFutureEl . addEventListener ( 'click' , ( ) => this . wrapperEl . classList . toggle ( 'hide-drawing-preview' ) ) ;
extraControlsEl . appendChild ( toggleFutureEl ) ;
const toggleCropHeaderEl = document . createElement ( 'li' ) ;
toggleCropHeaderEl . classList . add ( 'config-header' ) ;
toggleCropHeaderEl . innerText = "Crop" ;
extraControlsEl . appendChild ( toggleCropHeaderEl ) ;
this . toggleCropPlayerEl = document . createElement ( 'li' ) ;
this . toggleCropPlayerEl . innerText = CropDescriptions [ this . config . crop ] ;
this . toggleCropPlayerEl . addEventListener ( 'click' , ( ) => this . toggleCrop ( ) ) ;
extraControlsEl . appendChild ( this . toggleCropPlayerEl ) ;
extraEl . appendChild ( extraControlsEl ) ;
this . playbackControlsEl . appendChild ( extraEl ) ;
const fullScreenEl = document . createElement ( 'div' ) ;
fullScreenEl . classList . add ( 'controls-fs' ) ;
fullScreenEl . innerHTML = "⛶" ;
fullScreenEl . addEventListener ( 'click' , ( ) => {
if ( document . fullscreenElement ) {
document . exitFullscreen ( ) ;
} else {
// console.log(this.wrapperEl, this.shadowRoot);
this . wrapperEl . requestFullscreen ( ) ;
}
} )
this . playbackControlsEl . appendChild ( fullScreenEl ) ;
}
this . inPointPosition = [ 0 , 0 ] ;
this . inPointTimeMs = null ;
this . outPointPosition = null ;
this . outPointTimeMs = null ;
this . _currentTimeMs = 0 ;
this . videoIsPlaying = false ;
const groups = [ 'background' , 'before' , 'annotation' , 'after' ]
this . strokeGroups = { } ;
groups . forEach ( group => {
let groupEl = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'g' ) ;
groupEl . classList . add ( group )
this . svgEl . appendChild ( groupEl ) ;
this . strokeGroups [ group ] = new StrokeGroup ( groupEl , this ) ;
} ) ;
this . annotations = [ ] ;
if ( this . config . is _player ) {
this . load ( fileurl ) ;
} else {
this . loadTags ( tagFile ) . then ( ( ) => {
this . tagsEl = document . createElement ( 'ul' ) ;
this . tagsEl . classList . add ( 'tags' ) ;
const addTags = ( tags , tagsEl ) => {
tags . forEach ( ( tag ) => {
let tagLiEl = document . createElement ( 'li' ) ;
let tagEl = document . createElement ( 'div' ) ;
tagEl . classList . add ( 'tag' ) ;
tagEl . dataset . tag = tag . id ;
tagEl . innerText = tag . hasOwnProperty ( 'name' ) ? tag . name : tag . id ;
tagEl . addEventListener ( 'click' , ( e ) => {
this . addTag ( tag . id , this . inPointPosition , this . outPointPosition ) ;
} ) ;
tagEl . title = tag . hasOwnProperty ( 'description' ) ? tag . description : "" ;
let signEl = document . createElement ( 'span' ) ;
signEl . classList . add ( 'annotation-' + tag . id ) ;
signEl . style . backgroundColor = this . getColorForTag ( tag . id ) ;
tagEl . prepend ( signEl ) ;
tagLiEl . appendChild ( tagEl ) ;
if ( tag . hasOwnProperty ( 'children' ) ) {
const subEl = document . createElement ( 'ul' ) ;
subEl . classList . add ( 'subtags' ) ;
addTags ( tag . children , subEl ) ;
tagLiEl . appendChild ( subEl ) ;
}
tagsEl . appendChild ( tagLiEl ) ;
} ) ;
} ;
addTags ( this . tags , this . tagsEl ) ;
let tagEl = document . createElement ( 'li' ) ;
tagEl . classList . add ( 'tag' ) ;
tagEl . classList . add ( 'annotation-rm' ) ;
tagEl . dataset . tag = 'rm' ;
tagEl . title = "Remove annotation" ;
tagEl . innerHTML = "🚮" ; // ×
tagEl . addEventListener ( 'click' , ( e ) => {
if ( this . selectedAnnotation ) {
this . removeAnnotation ( this . selectedAnnotationI ) ;
}
} ) ;
this . tagsEl . appendChild ( tagEl ) ;
this . wrapperEl . appendChild ( this . tagsEl ) ;
this . commentEl = document . createElement ( 'input' ) ;
this . commentEl . type = 'text' ;
this . commentEl . classList . add ( 'annotation-comment' ) ;
this . commentEl . title = "Add comment to annotation" ;
this . commentEl . placeholder = "comment" ;
this . commentEl . value = "" ;
this . commentEl . addEventListener ( 'keyup' , ( e ) => {
if ( e . key == 'Escape' ) {
this . commentEl . blur ( ) // deselect annotation, and deselect commentEl
} else {
e . stopPropagation ( ) ; // prevent keyup event to propagate and set i/o points
}
} ) ;
this . commentEl . addEventListener ( 'input' , ( e ) => {
e . stopPropagation ( ) ; // prevent keyup event
if ( this . selectedAnnotation ) {
this . selectedAnnotation . comment = this . commentEl . value ;
this . updateAnnotations ( true )
}
} ) ;
this . controlsEl . appendChild ( this . commentEl ) ;
this . load ( fileurl ) ;
} ) ;
}
}
getColorForTag ( tag _id ) {
const tag = this . tagMap [ tag _id ] ;
// console.log(tag_id, tag);
if ( tag && tag . hasOwnProperty ( 'color' ) && tag . color ) {
return tag . color ;
}
if ( tag && tag . hasOwnProperty ( 'parent' ) && tag . parent ) {
return this . getColorForTag ( tag [ 'parent' ] . id ) ;
}
return 'black' ;
}
updateAnnotations ( save ) {
if ( this . config . is _player ) {
return false ;
}
this . annotationsEl . innerHTML = "" ;
for ( let annotation _i in this . annotations ) {
const annotation = this . annotations [ annotation _i ] ;
this . annotationEl = document . createElement ( 'div' ) ;
const prerollDiff = Number . parseFloat ( this . audioOffset < 0 ? this . audioOffset * - 1000 : 0 ) ;
// console.log('diff', prerollDiff, annotation.t_in, typeof annotation.t_in, this.duration,annotation.t_in + prerollDiff, (annotation.t_in + prerollDiff) / this.duration);
const left = ( ( annotation . t _in + prerollDiff ) / ( this . duration * 1000 ) ) * 100 ;
const right = 100 - ( ( annotation . t _out + prerollDiff ) / ( this . duration * 1000 ) ) * 100 ;
this . annotationEl . style . left = left + '%' ;
this . annotationEl . style . right = right + '%' ;
this . annotationEl . style . backgroundColor = this . getColorForTag ( annotation . tag ) ;
this . annotationEl . classList . add ( 'annotation-' + annotation . tag ) ;
if ( this . selectedAnnotationI == annotation _i ) {
this . annotationEl . classList . add ( 'selected' ) ;
}
this . annotationEl . title = ` [ ${ annotation . tag } ] ${ annotation . comment } ` ;
this . annotationEl . addEventListener ( 'mouseover' , ( e ) => {
} ) ;
this . annotationEl . addEventListener ( 'mouseout' , ( e ) => {
} ) ;
this . annotationEl . addEventListener ( 'click' , ( e ) => {
if ( this . selectedAnnotationI == annotation _i ) {
this . deselectAnnotation ( false ) ;
} else {
this . selectAnnotation ( annotation _i ) ;
}
} ) ;
this . annotationsEl . appendChild ( this . annotationEl ) ;
}
this . tagsEl . querySelectorAll ( '.tag' ) . forEach ( tagEl => {
if ( this . selectedAnnotation && this . selectedAnnotation . tag == tagEl . dataset . tag ) {
tagEl . classList . add ( 'selected' )
} else {
tagEl . classList . remove ( 'selected' )
}
} ) ;
if ( save ) {
this . updateState ( ) ;
}
}
selectAnnotation ( annotation _i ) {
this . selectedAnnotationI = annotation _i ;
this . selectedAnnotation = this . annotations [ annotation _i ] ;
this . slider . set ( [ this . selectedAnnotation . t _in , this . selectedAnnotation . t _out ] ) ;
this . inPointPosition = this . findPositionForTime ( this . selectedAnnotation . t _in ) ;
this . outPointPosition = this . findPositionForTime ( this . selectedAnnotation . t _out ) ;
this . inPointTimeMs = this . selectedAnnotation . t _in ;
this . outPointTimeMs = this . selectedAnnotation . t _out ;
this . _seekByTimeMs ( this . selectedAnnotation . t _in ) ;
// draw full stroke of annotation:
this . drawStrokePosition ( this . inPointPosition , this . outPointPosition ) ;
this . updateAnnotations ( false ) ; //selects the right tag & highlights the annotation
this . wrapperEl . classList . add ( 'selected-annotation' ) ;
this . commentEl . value = this . selectedAnnotation . comment ;
}
deselectAnnotation ( keep _position ) {
if ( this . selectedAnnotation ) {
this . _seekByTimeMs ( this . selectedAnnotation . t _out ) ;
}
this . wrapperEl . classList . remove ( 'selected-annotation' ) ;
this . commentEl . value = "" ;
this . commentEl . blur ( ) ; // make sure we're not typing anymore
this . selectedAnnotationI = null ;
this . selectedAnnotation = null ;
if ( ! keep _position ) {
this . setUpAnnotator ( ) ;
}
this . updateAnnotations ( false ) ; // selects the right tag & highlights the annotation
}
setInPoint ( time _ms ) {
this . setInOutPoint ( time _ms , this . outPointTimeMs ) ;
}
setOutPoint ( time _ms ) {
this . setInOutPoint ( this . inPointTimeMs , time _ms ) ;
}
setInOutPoint ( in _ms , out _ms ) {
this . inPointPosition = this . findPositionForTime ( in _ms ) ;
this . inPointTimeMs = in _ms ;
this . outPointPosition = this . findPositionForTime ( out _ms ) ;
this . outPointTimeMs = out _ms ;
// this._seekByTimeMs(this.audioOffset < 0 ? this.audioOffset * 1000 : 0);
// draw full stroke of annotation
console . debug ( 'setInOut' ) ;
this . drawStrokePosition ( this . inPointPosition , this . outPointPosition ) ;
console . debug ( [ ` ${ this . inPointTimeMs } ` , ` ${ this . outPointTimeMs } ` ] )
this . slider . set ( [ this . inPointTimeMs , this . outPointTimeMs ] ) ;
// console.debug(this.selectedAnnotation);
if ( this . selectedAnnotation ) {
this . selectedAnnotation . t _in = in _ms ;
this . selectedAnnotation . t _out = out _ms ;
this . updateAnnotations ( false ) ;
}
}
resetInOutPoint ( ) {
this . inPointPosition = [ 0 , 0 ] ;
this . inPointTimeMs = null ;
this . outPointPosition = null ;
this . outPointTimeMs = null ;
this . _seekByTimeMs ( this . audioOffset < 0 ? this . audioOffset * 1000 : 0 ) ;
// draw full stroke of annotation
console . debug ( 'reset!' ) ;
this . drawStrokePosition ( this . inPointPosition , [ Infinity , Infinity ] ) ;
this . setUpAnnotator ( ) ;
}
load ( file ) {
const request = new Request ( file , {
method : 'GET' ,
} ) ;
this . wrapperEl . classList . add ( 'loading' ) ;
fetch ( request )
. then ( response => response . json ( ) )
. then ( data => {
if ( ! this . config . is _player ) {
const metadata _req = new Request ( ` /annotations/ ${ data . file } ` , {
method : 'GET' ,
} ) ;
return fetch ( metadata _req )
. then ( response => response . ok ? response . json ( ) : null )
. then ( metadata => {
if ( metadata !== null ) {
metadata . annotations = metadata . annotations . map ( ( a ) => new Annotation ( a . tag , a . t _in , a . t _out , a . hasOwnProperty ( 'comment' ) ? a . comment : "" ) )
}
return this . loadStrokes ( data , metadata )
} )
. catch ( e => console . error ( e ) ) ;
} else {
return this . loadStrokes ( data , null ) ;
}
} )
. then ( ( ) => {
// play on click for player
if ( this . config . is _player ) {
this . svgEl . addEventListener ( 'click' , ( ev ) => {
console . debug ( 'clicked for play/pause' ) ;
this . playPause ( ) ;
} ) ;
}
// autoplay if necessary
if ( this . config . autoplay ) {
this . play ( ) ; // play should remove loading
} else {
this . wrapperEl . classList . remove ( 'loading' ) ;
}
} )
. catch ( e => console . debug ( e ) ) ;
}
updateState ( ) {
const state = {
'title' : this . title ,
'file' : this . filename ,
'annotations' : this . annotations ,
'audio' : {
'file' : this . audioFile ,
'offset' : this . audioOffset ,
}
}
const newState = JSON . stringify ( state ) ;
if ( newState == this . state ) {
return ;
}
this . wrapperEl . classList . remove ( 'saved' ) ;
this . wrapperEl . classList . add ( 'unsaved' ) ;
this . state = newState ;
// autosave on state change:
this . save ( newState ) ;
}
setSaved ( state ) {
if ( this . state != state ) {
console . debug ( 'already outdated' ) ;
}
else {
this . wrapperEl . classList . add ( 'saved' ) ;
this . wrapperEl . classList . remove ( 'unsaved' ) ;
}
}
save ( state ) {
const request = new Request ( "/annotations/" + this . filename , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json'
} ,
body : state
} ) ;
fetch ( request )
. then ( ( response ) => {
if ( response . ok ) {
this . setSaved ( state ) ;
}
else {
throw Error ( 'Something went wrong' ) ;
}
} )
. catch ( ( error ) => {
console . error ( error ) ;
} ) ;
}
removeAnnotation ( annotation _i ) {
this . deselectAnnotation ( true ) ;
this . annotations . splice ( annotation _i , 1 ) ;
this . updateAnnotations ( true ) ;
}
addTag ( tag ) {
if ( this . selectedAnnotation ) {
this . selectedAnnotation . tag = tag ;
this . updateAnnotations ( true ) ;
} else {
// TODO this.slider values for in and out
const [ t _in , t _out ] = this . slider . get ( ) ;
if ( this . slider ) {
this . slider . destroy ( ) ;
}
this . annotations . push ( new Annotation ( tag , t _in , t _out , "" ) ) ;
this . updateAnnotations ( true ) ;
this . _currentTimeMs = t _out ;
this . _updatePlayhead ( ) ;
this . setUpAnnotator ( ) ;
}
}
setUpAnnotator ( ) {
this . playheadEl . min = this . audioOffset < 0 ? this . audioOffset * 1000 : 0 ;
this . playheadEl . max = this . getEndTimeMs ( ) ;
this . _updatePlayhead ( ) ;
this . inPointPosition = this . findPositionForTime ( this . currentTime ) ;
this . inPointTimeMs = this . _currentTimeMs ;
this . outPointPosition = this . findPositionForTime ( this . lastFrameTime ) ; // TODO: simplify to get the last frame indexes directly
this . outPointTimeMs = this . getEndTimeMs ( ) ;
if ( ! this . config . is _player ) {
this . buildAnnotator ( ) ;
}
this . drawStrokePosition ( this . inPointPosition , this . outPointPosition ) ;
}
buildAnnotator ( ) {
if ( this . scrubberEl . noUiSlider ) {
this . slider . destroy ( ) ;
}
// console.log(this._currentTimeMs, )
const sliderMin = this . audioOffset < 0 ? this . audioOffset * 1000 : 0 ;
const sliderMax = this . getEndTimeMs ( ) ;
this . slider = noUiSlider . create ( this . scrubberEl , {
start : [ this . _currentTimeMs , this . getEndTimeMs ( ) ] ,
connect : true ,
range : {
'min' : sliderMin ,
'max' : sliderMax ,
} ,
keyboardDefaultStep : ( sliderMax - sliderMin ) / 1000 ,
keyboardPageMultiplier : 10 , // page up/down 10s
tooltips : [
this . formatter ,
this . formatter
] ,
// pips: {
// mode: 'range',
// density: 3,
// format: this.formatter
// }
} ) ;
this . slider . on ( "slide" , ( values , handle ) => {
this . videoIsPlaying = false ;
this . inPointPosition = this . findPositionForTime ( values [ 0 ] ) ;
this . inPointTimeMs = Number . parseFloat ( values [ 0 ] ) ;
this . outPointPosition = this . findPositionForTime ( values [ 1 ] ) ;
this . outPointTimeMs = Number . parseFloat ( values [ 1 ] ) ;
this . drawStrokePosition ( this . inPointPosition , this . outPointPosition ) ;
// console.log(this.selectedAnnotation);
if ( this . selectedAnnotation ) {
this . selectedAnnotation . t _in = Number . parseFloat ( values [ 0 ] ) ;
this . selectedAnnotation . t _out = Number . parseFloat ( values [ 1 ] ) ;
this . updateAnnotations ( false ) ;
}
} ) ;
this . slider . on ( "end" , ( values , handle ) => {
if ( this . selectedAnnotation ) {
this . updateAnnotations ( true ) ;
}
this . _seekByTimeMs ( values [ 0 ] ) ;
this . play ( ) ;
// this.playAudioSegment(values[0], values[1]);
} ) ;
this . slider . getTooltips ( ) . forEach ( ( ttEl , i ) => {
// console.log(ttEl, i);
ttEl . addEventListener ( 'click' , ( e ) => {
let ttInputEl = document . createElement ( 'input' ) ;
ttInputEl . value = ttEl . innerHTML
ttEl . innerHTML = "" ;
ttEl . appendChild ( ttInputEl ) ;
ttInputEl . focus ( ) ;
const submit = ( ) => {
console . debug ( ttInputEl . value ) ;
const tcMs = this . formatter . from ( ttInputEl . value ) ;
let points = this . slider . get ( ) ;
points [ i ] = tcMs ;
console . debug ( points ) ;
this . slider . set ( points ) ;
} ;
ttInputEl . addEventListener ( 'keydown' , ( keyE ) => {
keyE . stopPropagation ( ) ; //prevent movement of tooltip
if ( keyE . key == "Enter" ) {
submit ( ) ;
}
} )
ttInputEl . addEventListener ( 'click' , ( clickE ) => {
clickE . stopPropagation ( ) ; //prevent retrigger on selectino
} )
ttInputEl . addEventListener ( 'blur' , submit ) ;
} ) ;
} )
}
loadStrokes ( drawing , metadata ) {
this . audioOffset = 0 ;
if ( metadata ) {
this . annotations = metadata . annotations ;
}
if ( ( metadata && metadata . hasOwnProperty ( 'audio' ) ) || ( drawing . hasOwnProperty ( 'audio' ) && drawing . audio ) ) {
if ( metadata && metadata . hasOwnProperty ( 'audio' ) ) {
this . audioFile = this . config . url _prefix + metadata . audio . file
this . audioOffset = Number . parseFloat ( metadata . audio . offset ) ;
} else {
this . audioFile = this . config . url _prefix + drawing . audio . file
this . audioOffset = Number . parseFloat ( drawing . audio . offset ) ;
}
this . _currentTimeMs = this . audioOffset < 0 ? this . audioOffset * 1000 : 0 ;
this . _updatePlayhead ( ) ;
}
this . title = null ;
if ( metadata && metadata . hasOwnProperty ( 'title' ) ) {
this . title = metadata . title ;
}
else if ( drawing . hasOwnProperty ( 'title' ) ) {
this . title = drawing . title ;
}
this . filename = drawing . file ;
this . strokes = drawing . shape . map ( s => new Stroke ( s [ 'color' ] , s [ 'points' ] ) ) ;
this . backgroundStrokes = drawing . hasOwnProperty ( 'background' ) ? drawing . background : [ ] ;
this . backgroundBoundingBox = drawing . hasOwnProperty ( 'background_bounding_box' ) ? drawing . background _bounding _box : null ;
this . viewboxes = drawing . viewboxes ;
this . currentPathI = null ;
this . currentPointI = null ;
this . currentViewboxI = null ;
this . dimensions = drawing . dimensions ;
this . bounding _box = drawing . bounding _box ;
this . updateViewbox ( ) ;
// let bgEl = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
// bgEl.setAttribute("x", 0);
// bgEl.setAttribute("y", 0);
// bgEl.setAttribute("width", this.dimensions[0]);
// bgEl.setAttribute("height", this.dimensions[1]);
// bgEl.classList.add('background');
// this.svgEl.prepend(bgEl);
this . firstFrameTime = this . strokes . length == 0 ? 0 : this . strokes [ 0 ] . points [ 0 ] [ 3 ] ;
this . lastFrameTime = this . getFinalFrameTime ( ) ;
this . playheadEl . max = this . lastFrameTime ;
this . nextFrameTimeout = null ;
this . nextViewboxTimeout = null ;
this . _setPausedFlag ( true ) ;
if ( this . backgroundStrokes && this . backgroundStrokes . length ) {
this . strokeGroups [ 'background' ] . setPrecomputedStrokes ( this . backgroundStrokes )
}
return this . setupAudioConfig ( ) . then ( ( ) => {
// this.setUpAnnotator()
let keyEl ;
if ( this . config . is _player ) {
keyEl = this . wrapperEl ;
} else {
keyEl = document . body ; // always capture
this . updateAnnotations ( false ) ;
}
keyEl . addEventListener ( 'keyup' , ( ev ) => {
if ( ev . key == ' ' ) {
this . playPause ( ) ;
}
// shift+arrow keys, jump playhead (search position)
// FIXME doesn't keep playback after initial load. Only after unfocussing the window, and refocussing it, do the keys capture.
// Probably a wrong order
if ( ev . key == 'ArrowLeft' && ev . shiftKey ) {
const p = this . _paused ;
const diff = ev . ctrlKey ? 10000 : 1000 ;
this . scrubTo ( this . _currentTimeMs - diff ) ;
if ( ! p ) { this . play ( ) ; } // scrubTo() causes a pause();
}
if ( ev . key == 'ArrowRight' && ev . shiftKey ) {
const p = this . _paused ;
const diff = ev . ctrlKey ? 10000 : 1000 ;
this . scrubTo ( this . _currentTimeMs + diff ) ;
if ( ! p ) { this . play ( ) ; } // scrubTo() causes a pause();
}
// additional keys only for annotation mode
if ( ! this . config . is _player ) {
if ( ev . key == 'i' ) {
this . setInPoint ( this . currentTime * 1000 ) ;
}
if ( ev . key == 'o' ) {
this . setOutPoint ( this . currentTime * 1000 ) ;
}
if ( ev . key == 'I' ) {
// shift+i == jump to in point
this . scrubTo ( this . inPointTimeMs ) ;
}
if ( ev . key == 'O' ) {
// shift+o == jump to end point
this . scrubTo ( this . outPointTimeMs ) ;
}
if ( ev . key == 'Escape' ) {
if ( this . selectedAnnotation ) {
this . deselectAnnotation ( ) ;
} else {
this . resetInOutPoint ( ) ;
}
}
}
} ) ;
} ) ;
// this.playStrokePosition(0, 1);
}
loadTags ( tagFile ) {
// tags config
const request = new Request ( tagFile ) ;
return fetch ( request )
. then ( response => response . json ( ) )
. then ( rootTag => {
this . tags = rootTag . children ;
this . tagMap = { } ;
const addTagsToMap = ( tags , parent ) => {
tags . forEach ( ( tag ) => {
tag [ 'parent' ] = typeof parent != "undefined" ? parent : null ;
this . tagMap [ tag . id ] = tag ;
if ( tag . hasOwnProperty ( "children" ) ) {
addTagsToMap ( tag . children , tag ) ;
}
} ) ;
} ;
addTagsToMap ( this . tags ) ;
} ) ;
}
setupAudioConfig ( ) {
// audio config
return new Promise ( ( resolve , reject ) => {
this . audioEl = document . createElement ( 'audio' ) ;
if ( ! this . config . is _player )
this . audioEl . setAttribute ( 'controls' , true ) ;
this . audioEl . addEventListener ( 'canplaythrough' , ( ev ) => {
console . debug ( 'loaded audio' ) ;
// this.audioEl.play();
} ) ;
if ( this . config . is _player ) {
this . wrapperEl . prepend ( this . audioEl ) ;
}
else {
let mdConfigEl = document . createElement ( 'div' ) ;
mdConfigEl . classList . add ( 'metadataconfig' )
this . wrapperEl . appendChild ( mdConfigEl ) ;
let titleEl = document . createElement ( 'div' ) ;
titleEl . classList . add ( 'drawing-title' ) ;
titleEl . innerText = this . title ? ? "[add title]"
titleEl . title = this . title ? ? "[click to add title for this diagram]"
titleEl . addEventListener ( 'click' , ( ev ) => {
const title = prompt ( "Change the title for the drawing" , this . title ? ? "" ) ;
if ( title === null ) return ; //cancel
titleEl . innerText = title . length ? title : "[add title]" ;
this . title = title . length ? title : null ;
this . updateState ( ) ;
} )
mdConfigEl . appendChild ( titleEl ) ;
let audioConfigEl = document . createElement ( 'div' ) ;
audioConfigEl . classList . add ( 'audioconfig' )
mdConfigEl . appendChild ( audioConfigEl ) ;
audioConfigEl . appendChild ( this . audioEl ) ;
let audioSelectEl = document . createElement ( 'select' ) ;
audioSelectEl . classList . add ( 'audioselect' ) ;
audioConfigEl . appendChild ( audioSelectEl ) ;
fetch ( '/audio' )
. then ( response => response . json ( ) )
. then ( data => {
data . unshift ( '' ) ; // add empty, to deselect any file
data . forEach ( audioFile => {
let optionEl = document . createElement ( 'option' ) ;
optionEl . selected = this . audioFile == audioFile ;
optionEl . innerText = audioFile ;
audioSelectEl . appendChild ( optionEl ) ;
} ) ;
} )
audioSelectEl . addEventListener ( 'change' , ( ev ) => {
this . setAudioFile ( ev . target . value ) ;
} ) ;
let audioOffsetTextEl = document . createElement ( 'label' ) ;
audioOffsetTextEl . innerText = "Offset (s)" ;
audioConfigEl . appendChild ( audioOffsetTextEl ) ;
let audioOffsetEl = document . createElement ( 'input' ) ;
audioOffsetEl . setAttribute ( 'type' , 'number' ) ;
audioOffsetEl . setAttribute ( 'step' , '.01' ) ;
audioOffsetEl . value = this . audioOffset ? ? 0 ;
audioOffsetEl . addEventListener ( 'change' , ( ev ) => {
this . setAudioOffset ( ev . target . value ) ;
} ) ;
audioOffsetTextEl . appendChild ( audioOffsetEl ) ;
}
this . audioEl . addEventListener ( 'loadedmetadata' , ( ev ) => {
// resolve the 'set up audio' when metadata has loaded
this . setUpAnnotator ( ) ; // if offset is negative, annotator starts at negative time
resolve ( ) ;
} )
if ( this . audioFile ) {
this . audioEl . setAttribute ( 'src' , this . audioFile ) ;
} else {
this . setUpAnnotator ( ) ;
resolve ( ) ;
}
} ) ;
}
setAudioFile ( audioFile ) {
this . audioFile = audioFile ;
this . audioEl . setAttribute ( 'src' , this . audioFile ) ;
// this.audioEl.play();
// TODO update playhead
// TODO update this.duration after load
this . updateState ( ) ;
}
setAudioOffset ( audioOffset ) {
this . audioOffset = Number . parseFloat ( audioOffset ) ;
// TODO update playhead
// TODO update this.duration
this . setUpAnnotator ( ) ; // if offset is negative, annotator starts at negative time
this . updateState ( ) ;
}
/ * *
* @ param float time time is ms
* @ returns float
* /
getAudioTime ( time ) {
return Number . parseFloat ( time ) - ( this . audioOffset * 1000 ? ? 0 ) ;
}
/ * *
*
* @ param float t _in in point time , in ms
* @ param float t _out out point time , in ms
* /
playAudioSegment ( t _in , t _out ) {
if ( this . audioStartTimeout ) clearTimeout ( this . audioStartTimeout ) ;
if ( this . audioEndTimeout ) clearTimeout ( this . audioEndTimeout ) ;
// TODO, handle playback delay
const t _start = this . getAudioTime ( t _in ) ; // in ms
const t _diff = ( t _out ? ? this . audioEl . duration * 1000 ) - t _in ; // in ms
this . audioEl . pause ( ) ;
if ( t _start < 0 ) {
if ( t _diff <= t _start * - 1 ) {
console . debug ( 'no audio playback in segment' , t _start , t _diff ) ;
} else {
console . debug ( 'delay audio playback' , t _start , t _diff ) ;
// a negative audiooffset delays playback from the start
// this.audioStartTimeout = setTimeout((e) => this.audioEl.play(), t*-1000);
this . audioStartTimeout = setTimeout ( ( e ) => { this . audioEl . currentTime = 0 ; this . audioEl . play ( ) ; } , t _start * - 1 ) ; // triggers play with "seeked" event
// this.audioEl.currentTime = 0;
}
} else {
if ( this . audioEl . currentTime !== t _start / 1000 ) {
console . debug ( this . audioEl . currentTime , t _start / 1000 ) ;
this . audioEl . currentTime = t _start / 1000 ;
}
this . audioEl . play ( ) ;
// this.audioEl.play(); // play is done in "seeked" evenlistener
console . debug ( this . audioEl . currentTime , t _start , t _in , t _out ) ;
}
this . audioIsPlaying = true ; // also state as playing in preroll
this . audioEndTimeout = setTimeout ( ( e ) => {
this . audioEl . pause ( ) ;
this . audioIsPlaying = false ;
console . debug ( 'done playing audio' ) ;
} , t _diff ) ;
}
_scrubAudio ( time _ms ) {
this . audioEl . currentTime = Math . max ( 0 , this . getAudioTime ( time _ms ) ) / 1000 ;
}
getFinalFrameTime ( ) {
if ( this . strokes . length == 0 ) return null ; // when no strokes are loaded (eg. for annotation)
const points = this . strokes [ this . strokes . length - 1 ] . points ;
return points [ points . length - 1 ] [ 3 ] ;
}
getStrokesSliceForPathRange ( in _point , out _point ) {
// get paths for given range. Also, split path at in & out if necessary.
let slices = { } ;
for ( let i = in _point [ 0 ] ; i <= out _point [ 0 ] ; i ++ ) {
const stroke = this . strokes [ i ] ;
if ( typeof stroke === 'undefined' ) {
// out point can be Infinity. So interrupt whenever the end is reached
break ;
}
const in _i = ( in _point [ 0 ] === i ) ? in _point [ 1 ] : 0 ;
const out _i = ( out _point [ 0 ] === i ) ? out _point [ 1 ] : Infinity ;
slices [ i ] = new StrokeSlice ( stroke , in _i , out _i ) ;
}
return slices ;
}
// TODO: when drawing, have a group active & inactive.
// active is getPathRange(currentIn, currentOut)
// inactive is what comes before and after.
// then, playing the video is just running pathRanghe(0, playhead)
drawStrokePosition ( in _point , out _point , show _all ) {
if ( typeof show _all === 'undefined' )
show _all = true ;
this . strokeGroups [ 'before' ] . setStrokes ( this . getStrokesSliceForPathRange ( [ 0 , 0 ] , in _point ) ) ;
this . strokeGroups [ 'annotation' ] . setStrokes ( this . getStrokesSliceForPathRange ( in _point , out _point ) ) ;
this . strokeGroups [ 'after' ] . setStrokes ( this . getStrokesSliceForPathRange ( out _point , [ Infinity , Infinity ] ) ) ;
// // an inpoint is set, so we're annotating
// // make everything coming before translucent
// if (this.inPointPosition !== null) {
// const [inPath_i, inPoint_i] = this.inPointPosition;
// // returns a static NodeList
// const currentBeforeEls = this.svgEl.querySelectorAll(`.before_in`);
// for (let currentBeforeEl of currentBeforeEls) {
// currentBeforeEl.classList.remove('before_in');
// }
// for (let index = 0; index < inPath_i; index++) {
// const pathEl = this.svgEl.querySelector(`.path${ index }`);
// if (pathEl) {
// pathEl.classList.add('before_in');
// }
// }
// }
// this.currentPathI = path_i;
// this.currentPointI = point_i;
// const path = this.strokes[path_i];
// // console.log(path);
// let pathEl = this.svgEl.querySelector(`.path${ path_i }`);
// if (!pathEl) {
// pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
// pathEl.style.stroke = path.color;
// pathEl.classList.add('path' + path_i)
// this.svgEl.appendChild(pathEl)
// }
// const stroke = path.points.slice(0, point_i);
// const d = this.strokes2D(stroke);
// pathEl.setAttribute('d', d);
// this.scrubberElOld.value = path.points[point_i][3];
// this.currentTime = path.points[point_i][3];
}
setViewboxPosition ( box _i ) {
if ( this . currentViewboxI == box _i ) {
return ;
}
this . currentViewboxI = box _i
if ( ! this . config . crop _to _fit ) {
this . updateViewbox ( ) ;
}
}
updateViewbox ( ) {
if ( this . config . crop == CropOptions . Fit _Selection ) {
this . svgEl . setAttribute ( 'viewBox' , ` ${ this . bounding _box . x } ${ this . bounding _box . y } ${ this . bounding _box . width } ${ this . bounding _box . height } ` ) ;
} else if ( this . config . crop == CropOptions . Whole _Drawing && this . backgroundBoundingBox ) {
this . svgEl . setAttribute ( 'viewBox' , ` ${ this . backgroundBoundingBox . x } ${ this . backgroundBoundingBox . y } ${ this . backgroundBoundingBox . width } ${ this . backgroundBoundingBox . height } ` ) ;
} else {
let x , y , w , h ;
if ( this . currentViewboxI !== null ) {
x = this . viewboxes [ this . currentViewboxI ] . x ,
y = this . viewboxes [ this . currentViewboxI ] . y ,
w = this . dimensions [ 0 ] ,
h = this . dimensions [ 1 ] ;
} else {
x = 0 ,
y = 0 ,
w = this . dimensions [ 0 ] ,
h = this . dimensions [ 1 ] ;
}
this . svgEl . setAttribute ( 'viewBox' , ` ${ x } ${ y } ${ w } ${ h } ` ) ;
}
}
setCrop ( crop _option ) {
if ( Object . values ( CropOptions ) . indexOf ( crop _option ) === - 1 ) {
console . error ( 'invalid crop option' , crop _option ) ;
crop _option = CropOptions . Fit _Selection ;
}
this . config . crop = crop _option ;
for ( let option of Object . values ( CropOptions ) ) {
if ( this . config . crop == option ) {
this . wrapperEl . classList . add ( 'crop-' + option ) ;
} else {
this . wrapperEl . classList . remove ( 'crop-' + option ) ;
}
}
this . toggleCropPlayerEl . innerText = CropDescriptions [ this . config . crop ] ;
this . updateViewbox ( ) ;
}
toggleCrop ( ) {
console . log ( this . config . crop , Object . values ( CropOptions ) , Object . values ( CropOptions ) . indexOf ( this . config . crop ) )
const i = ( Object . values ( CropOptions ) . indexOf ( this . config . crop ) + 1 ) % Object . keys ( CropOptions ) . length ;
const newCrop = Object . values ( CropOptions ) [ i ] ;
console . log ( i , newCrop ) ;
this . setCrop ( newCrop ) ;
}
getNextPosition ( path _i , point _i ) {
const path = this . strokes [ path _i ] ;
let next _path , next _point ;
if ( path . points . length > point _i + 1 ) {
next _path = path _i ;
next _point = point _i + 1 ;
// setTimeout(() => this.playStroke(next_path, next_point), dt);
} else if ( this . strokes . length > path _i + 1 ) {
next _path = path _i + 1 ;
next _point = 0 ;
// use starttime instead of diff, to prevent floating
} else {
return [ null , null ] ;
}
// when an outpoint is set, stop playing there
if ( this . outPointPosition && ( next _path > this . outPointPosition [ 0 ] ||
( next _path == this . outPointPosition [ 0 ] && next _point > this . outPointPosition [ 1 ] ) ) ) {
console . debug ( '> out point' , this . outPointPosition )
return [ null , null ] ;
}
return [ next _path , next _point ] ;
}
playStrokePosition ( path _i , point _i , allow _interrupt ) {
if ( this . strokes . length === 0 ) {
console . debug ( 'No video to play back' ) ;
this . videoIsPlaying = false ;
return ;
}
if ( allow _interrupt ) {
if ( ! this . videoIsPlaying ) {
console . debug ( 'not playing because of interrupt' ) ;
return ;
}
} else {
this . videoIsPlaying = true ;
}
this . drawStrokePosition ( this . inPointPosition , [ path _i , point _i ] ) ;
const [ next _path , next _point ] = this . getNextPosition ( path _i , point _i ) ;
if ( next _path === null ) {
console . debug ( 'done playing video' ) ;
this . videoIsPlaying = false ;
return ;
}
const t = this . strokes [ next _path ] . points [ next _point ] [ 3 ] ; // - path.points[point_i][3];
// calculate interval based on playback start to avoid drifting of time
const dt = t - ( window . performance . now ( ) - this . startTimeMs ) ;
this . nextFrameTimeout = setTimeout ( ( ) => this . playStrokePosition ( next _path , next _point , true ) , dt ) ;
}
playViewboxPosition ( box _i , allow _interrupt ) {
if ( allow _interrupt ) {
if ( ! this . videoIsPlaying ) {
console . debug ( 'not playing because of interrupt' ) ;
return ;
}
}
// else {
// this.videoIsPlaying = true;
// }
this . setViewboxPosition ( box _i ) ;
const next _box _i = box _i + 1 ;
if ( this . viewboxes . length <= next _box _i ) {
console . debug ( 'done playing viewbox' ) ;
return ;
}
const t = this . viewboxes [ next _box _i ] . t ;
// calculate interval based on playback start to avoid drifting of time
const dt = t - ( window . performance . now ( ) - this . startTimeMs ) ;
this . nextViewboxTimeout = setTimeout ( ( ) => this . playViewboxPosition ( next _box _i , true ) , dt ) ;
}
scrubTo ( ms ) {
// const [path_i, point_i] = this.findPositionForTime(ms);
// console.log(path_i, point_i);
this . pause ( ) ;
this . _seekByTime ( ms / 1000 ) ;
// this.playHead = ms;
}
playPause ( ) {
if ( this . paused ) {
this . play ( ) ;
} else {
this . pause ( )
}
}
/ * *
* Compatibility with HTMLMediaElement API
* @ returns None
* /
pause ( ) {
this . _interruptPlayback ( ) ;
}
_interruptPlayback ( ) {
clearTimeout ( this . nextFrameTimeout ) ;
clearTimeout ( this . nextViewboxTimeout ) ;
clearTimeout ( this . audioEndTimeout ) ;
clearTimeout ( this . audioStartTimeout ) ;
clearTimeout ( this . startVideoTimeout ) ;
this . audioEl . pause ( ) ;
this . videoIsPlaying = false ;
this . audioIsPlaying = false ;
this . _setPausedFlag ( true ) ;
2023-12-19 08:34:15 +01:00
this . dispatchEvent ( new CustomEvent ( 'pause' , { } ) ) ;
2023-12-18 10:56:07 +01:00
}
/ * *
* Compatibility with HTMLMediaElement API
* @ returns Promise
* /
play ( ) {
return new Promise ( ( resolve , reject ) => {
this . _interruptPlayback ( ) ;
if ( this . _currentTimeMs > this . outPointTimeMs ) {
this . _seekByTimeMs ( this . inPointTimeMs ) ;
} else {
this . _seekByTimeMs ( this . _currentTimeMs ) ; // prevent playback issue for initial load
}
this . _setPausedFlag ( false ) ;
const startPlayback = ( ) => {
console . debug ( 'start playback' ) ;
this . wrapperEl . classList . remove ( 'loading' ) ; // no loading anymore
this . startTimeMs = window . performance . now ( ) - this . _currentTimeMs ;
// strokes
if ( this . _currentTimeMs < 0 ) {
this . startVideoTimeout = setTimeout ( ( e ) => this . playStrokePosition ( this . currentPathI , this . currentPointI ) , this . _currentTimeMs * - 1 ) ;
} else {
this . playStrokePosition ( this . currentPathI , this . currentPointI ) ;
}
// viewboxes
// const nextViewboxI = Math.max(this.currentViewboxI++, this.viewboxes.length-1);
this . playViewboxPosition ( this . currentViewboxI ) ;
// audio
// TODO: use this.audioEl.readyState == 4 : play immediately, otherwise after event
this . playAudioSegment ( this . _currentTimeMs , this . outPointTimeMs ) ;
// this.playStrokePosition(this.currentPathI, this.currentPointI);
this . dispatchEvent ( new CustomEvent ( 'play' , { } ) ) ;
this . _animationFrame ( ) ;
resolve ( ) ;
}
if ( this . audioEl . src . length && this . audioEl . readyState !== 4 ) { // not ready to play after seeking audio.
console . debug ( 'wait for audio before playback' ) ;
this . wrapperEl . classList . add ( 'loading' ) ;
this . audioEl . addEventListener ( 'canplaythrough' , ( ) => {
startPlayback ( )
} , { once : true } ) ; // only once
} else {
startPlayback ( ) ;
}
} ) ;
}
_setPausedFlag ( paused ) {
this . _paused = ! ! paused ; //convert to boolean
if ( paused ) {
this . playPauseEl . classList . remove ( 'playing' ) ;
this . playPauseEl . classList . add ( 'paused' ) ;
} else {
this . playPauseEl . classList . remove ( 'paused' ) ;
this . playPauseEl . classList . add ( 'playing' ) ;
}
}
get paused ( ) {
return this . _paused ;
}
_updatePlayhead ( ) {
this . playheadEl . value = this . _currentTimeMs ;
this . timeCodeEl . value = this . formatter . to ( this . _currentTimeMs ) ;
}
// on playback, run every windowAnimationFrame
_animationFrame ( timestamp ) {
// TODO, move time at end of playStrokePosition to here
const nextTime = window . performance . now ( ) - this . startTimeMs ;
const endTime = this . outPointTimeMs ? ? this . duration * 1000 ;
let interrupt = false ;
if ( nextTime > endTime ) {
this . _currentTimeMs = endTime ;
interrupt = true ;
} else {
this . _currentTimeMs = nextTime ;
}
this . _updatePlayhead ( ) ;
if ( ! interrupt && ( this . videoIsPlaying || this . audioIsPlaying ) ) {
window . requestAnimationFrame ( ( timestamp ) => this . _animationFrame ( timestamp ) ) ;
} else {
console . debug ( 'finished playback' ) ;
this . _interruptPlayback ( true ) ;
// this.resetPlayhead(); // Disable to not jump to start on pause. TODO: check if this causes issues e.g. on end
}
}
resetPlayhead ( ) {
this . _seekByTimeMs ( this . inPointTimeMs ) ;
if ( this . selectedAnnotation ) {
// show the hole selected annotation
this . drawStrokePosition ( this . inPointPosition , this . outPointPosition ) ;
}
}
/ * *
* Note that both t _in and t _out can be negative
* @ param float | Array t _in in point time , in ms or array with path / frame points
* @ param float | Array t _out out point time , in ms or array with path / frame points
* /
playSegment ( in _point , out _point ) {
if ( ! Array . isArray ( in _point ) ) in _point = this . findPositionForTime ( in _point ) ;
if ( ! Array . isArray ( out _point ) ) out _point = this . findPositionForTime ( out _point ) ;
this . inPointPosition = in _point ;
this . outPointPosition = out _point ;
this . _seekByPoint ( in _point ) ;
this . play ( ) ;
}
_seekByPoint ( point ) {
this . dispatchEvent ( new CustomEvent ( 'seeking' , { } ) ) ;
this . _currentTimeMs = this . strokes [ point [ 0 ] ] . points [ point [ 1 ] ] [ 2 ] ;
this . audioEl . currentTime = this . getAudioTime ( this . _currentTimeMs ) / 1000 ;
[ this . currentPathI , this . currentPointI ] = point ;
this . _updatePlayhead ( ) ;
this . _updateFrame ( ) ;
// TODO set audio, wait for promise to finish
this . dispatchEvent ( new CustomEvent ( 'seeked' , { } ) ) ;
}
_seekByTimeMs ( time ) {
this . _seekByTime ( Number . parseFloat ( time ) / 1000 ) ;
}
_seekByTime ( time ) {
this . dispatchEvent ( new CustomEvent ( 'seeking' , { detail : time } ) ) ;
this . _currentTimeMs = Number . parseFloat ( time ) * 1000 ;
this . audioEl . currentTime = this . getAudioTime ( this . _currentTimeMs ) / 1000 ;
[ this . currentPathI , this . currentPointI ] = this . findPositionForTime ( this . _currentTimeMs ) ;
this . _updatePlayhead ( ) ;
this . _updateFrame ( ) ;
this . dispatchEvent ( new CustomEvent ( 'seeked' , { detail : this . currentTime } ) ) ;
}
_updateFrame ( ) {
this . drawStrokePosition ( this . inPointPosition , [ this . currentPathI , this . currentPointI ] ) ;
this . setViewboxPosition ( this . findViewboxForTime ( this . _currentTimeMs ) ) ;
}
/ * *
* For compatibility with HTMLMediaElement API convert seconds to ms of internal timer
* /
set currentTime ( time ) {
this . _seekByTime ( time ) ;
}
get currentTime ( ) {
return this . _currentTimeMs / 1000 ;
}
getEndTimeMs ( ) {
const videoDuration = this . getFinalFrameTime ( ) ;
const audioDuration = ( this . audioEl && this . audioEl . src ) ? this . audioEl . duration + this . audioOffset : 0 ;
return Math . max ( videoDuration , audioDuration * 1000 ) ;
}
get duration ( ) {
const prerollDuration = this . audioOffset < 0 ? this . audioOffset * - 1 : 0 ;
return prerollDuration + this . getEndTimeMs ( ) / 1000 ;
}
findPositionForTime ( ms ) {
ms = Math . min ( Math . max ( ms , 0 ) , this . lastFrameTime ) ;
// console.log('scrub to', ms)
let path _i = 0 ;
let point _i = 0 ;
this . strokes . every ( ( stroke , index ) => {
const startAt = stroke . points [ 0 ] [ 3 ] ;
const endAt = stroke . points [ stroke . points . length - 1 ] [ 3 ] ;
if ( startAt > ms ) {
return false ; // too far
}
if ( endAt > ms ) {
// we're getting close. Find the right point_i
path _i = index ;
stroke . points . every ( ( point , pi ) => {
if ( point [ 3 ] > ms ) {
// too far
return false ;
}
point _i = pi ;
return true ;
} ) ;
return false ;
} else {
// in case nothings comes after, we store the last best option thus far
path _i = index ;
point _i = stroke . points . length - 1 ;
return true ;
}
} ) ;
return [ path _i , point _i ] ;
}
findViewboxForTime ( ms ) {
ms = Math . min ( Math . max ( ms , 0 ) , this . lastFrameTime ) ;
// console.log('scrub to', ms)
let box _i = 0 ;
this . viewboxes . every ( ( viewbox , index ) => {
const startAt = viewbox . t ;
if ( startAt > ms ) {
return false ; // too far
} else {
// in case nothings comes after, we store the last best option thus far
box _i = index ;
return true ;
}
} ) ;
return box _i ;
}
}
class AnnotationPlayer extends HTMLElement {
constructor ( ) {
super ( ) ;
// We don't use constructor() because an element's attributes
// are unavailable until connected to the DOM.
// attributes:
// - data-crop (any of CropOptions)
// - autoplay
// - preload
// - data-poster-src
// - data-annotation-url
}
connectedCallback ( ) {
// Create a shadow root
this . attachShadow ( { mode : "open" } ) ;
const imgWrapEl = document . createElement ( 'div' ) ;
imgWrapEl . classList . add ( 'imgWrap' ) ;
const imgEl = document . createElement ( 'img' ) ;
imgWrapEl . appendChild ( imgEl ) ;
const playerEl = document . createElement ( 'div' ) ;
const url _prefix = this . hasAttribute ( 'data-url-prefix' ) ? this . getAttribute ( 'data-url-prefix' ) + '/' : '' ;
const config = {
is _player : true ,
crop : this . hasAttribute ( 'data-crop' ) ? this . getAttribute ( 'data-crop' ) : null ,
autoplay : true ,
url _prefix : url _prefix ,
stroke _color : this . hasAttribute ( 'stroke' ) ? this . getAttribute ( 'stroke' ) : null
}
const start = ( ) => {
imgWrapEl . style . display = 'none' ;
this . annotator = new Annotator (
playerEl ,
null , //"tags.json",
url _prefix + this . getAttribute ( 'data-annotation-url' ) ,
config
) ;
}
if ( this . hasAttribute ( 'data-poster-url' ) ) {
imgEl . src = url _prefix + this . getAttribute ( 'data-poster-url' ) ;
imgEl . addEventListener ( 'click' , start )
} else {
config . autoplay = false ;
start ( ) ;
}
playerEl . classList . add ( 'play' ) ;
const styleEl = document . createElement ( 'style' ) ;
styleEl . textContent = `
: host {
overflow : hidden ;
2023-12-18 15:28:52 +01:00
2023-12-18 10:56:07 +01:00
}
svg {
}
div . imgWrap {
cursor : pointer ;
}
div . imgWrap : : before {
color : white ;
content : '\u25B6' ;
background : rgba ( 0 , 0 , 0 , 0.5 ) ;
height : 30 px ;
border - radius : 50 % ;
width : 50 px ;
display : block ;
position : absolute ;
left : calc ( 50 % - 25 px ) ;
top : calc ( 50 % - 25 px ) ;
text - align : center ;
line - height : 47 px ;
height : 50 px ;
font - size : 20 px ;
pointer - events : none ;
cursor : pointer ;
}
div . imgWrap : hover : : before {
background : rgba ( 0 , 0 , 0 , 0.2 ) ;
}
div . play , div . imgWrap {
padding : 10 px ;
background : white ;
}
svg , img {
width : 100 % ;
height : 100 % ;
}
. play : not ( . loading ) . controls {
visibility : hidden ;
}
: host ( : hover ) . controls {
visibility : visible ! important ;
}
. controls -- playback {
display : flex ;
background : rgba ( 0 , 0 , 0 , . 5 ) ;
border - radius : 3 px ;
}
. timecode {
width : 30 px ;
font - size : 8 px ;
background : none ;
border : none ;
color : white ;
}
. controls -- playback input [ type = 'range' ] {
flex - grow : 1 ;
- webkit - appearance : none ;
background : none ;
}
input [ type = "range" ] : : - webkit - slider - runnable - track ,
input [ type = "range" ] : : - moz - range - track {
background : lightgray ;
height : 5 px ;
border - radius : 3 px ;
}
input [ type = "range" ] : : - moz - range - progress {
background - color : white ;
height : 5 px ;
border - radius : 3 px 0 0 3 px ;
}
input [ type = "range" ] : : - webkit - slider - thumb ,
input [ type = "range" ] : : - moz - range - thumb {
- webkit - appearance : none ;
height : 15 px ;
width : 15 px ;
background : white ;
margin - top : - 5 px ;
border - radius : 50 % ;
border : none ;
}
. controls button . paused ,
. controls button . playing {
order : - 1 ;
width : 30 px ;
height : 30 px ;
border : none ;
background : none ;
color : white ;
line - height : 1 ;
}
. controls . controls - fs {
width : 30 px ;
text - align : center ;
cursor : pointer ;
}
. controls . controls - fs : hover {
font - weight : bold ;
}
. controls button . paused : : before {
content : '⏵' ;
}
. controls button . playing : : before {
content : '⏸' ;
}
. loading . controls button : is ( . playing , . paused ) : : before {
content : '↺' ;
display : inline - block ;
animation : rotate 1 s infinite ;
}
@ keyframes rotate {
0 % {
transform : rotate ( 359 deg )
}
100 % {
transform : rotate ( 0 deg )
}
}
. controls {
position : absolute ! important ;
z - index : 100 ;
bottom : 10 px ;
left : 5 % ;
right : 0 ;
width : 90 % ;
color : white ;
}
svg . background {
fill : white
}
path {
fill : none ;
stroke : var ( -- disactive - path ) ;
stroke - width : 1 mm ;
stroke - linecap : round ;
}
g . before path {
opacity : 0.5 ;
stroke : var ( -- disactive - path ) ! important ;
}
g . after path ,
path . before _in {
opacity : . 1 ;
stroke : var ( -- disactive - path ) ! important ;
}
. hide - drawing - preview g . after path , . hide - drawing - preview path . before _in {
opacity : 0 ;
}
. background {
visibility : hidden
}
. play : not ( . crop - selection ) . background {
visibility : visible ;
}
. gray {
position : absolute ;
background : rgba ( 255 , 255 , 255 , 0.7 ) ;
}
details {
color : white ;
}
summary {
list - style : none ;
cursor : pointer ;
padding : 0 5 px ;
}
details > ul {
position : absolute ;
bottom : 35 px ;
background : rgba ( 0 , 0 , 0 , . 5 ) ;
border - radius : 3 px ;
right : 0 ;
padding : 5 px ;
margin : 0 ;
list - style : none ;
font - size : 10 pt ;
width : 150 px ;
}
details > ul li : not ( . config - header ) : hover {
cursor : pointer ;
text - decoration : underline ;
}
details . config - header {
font - weight : bold ;
}
. play : not ( . hide - drawing - preview ) details > ul li : first - child {
/*text-decoration: line-through;*/
font - weight : bold ;
}
. play . crop - selection details > ul li : nth - child ( 2 ) {
/*text-decoration: line-through;*/
/*font-weight:bold;*/
}
. play : not ( . crop - selection ) details . config - future {
display : none ;
}
2023-12-18 11:50:20 +01:00
. annotation path . path {
stroke : var ( -- override - color ) ! important ;
transition : stroke 1 s ;
}
2023-12-18 10:56:07 +01:00
` ;
2023-12-18 11:50:20 +01:00
// if(config.stroke_color) {
// // styleEl.textContent += `.annotation path.path{stroke: ${config.stroke_color} !important;}`
// styleEl.textContent += `:host{--override-color: ${config.stroke_color}; }`
// }
2023-12-18 10:56:07 +01:00
this . shadowRoot . appendChild ( styleEl ) ;
this . shadowRoot . appendChild ( imgWrapEl ) ;
this . shadowRoot . appendChild ( playerEl ) ;
}
setAnnotation ( annotation ) {
// this.annotation = annotation;
this . setAttribute ( 'data-annotation-url' , annotation . url )
this . setAttribute ( 'data-poster-url' , ` /annotation/ ${ annotation . id } .svg ` )
}
attributeChangedCallback ( name , oldValue , newValue ) {
// console.log(name, oldValue, newValue);
if ( name == 'data-crop' ) {
if ( ! this . annotator ) {
return ;
}
this . annotator . setCrop ( this . hasAttribute ( 'data-crop' ) ) ;
}
}
// required for attributeChangedCallback()
static get observedAttributes ( ) { return [ 'data-crop' ] ; }
}
window . customElements . define ( 'annotation-player' , AnnotationPlayer ) ;