HTML 5 Video Scripting mit Javascript

Javascript: Wiedergabepunktliste, Fortschrittsbalken, schnelles Vor- und Zurückspulen, Lautstärke regulieren.


Studien eines Reiherfluges am Ossiacher See

Unser Video unterteilt in einzelne Szenen

Hier ist er noch einmal unser Reiher (600 kbit/s, 848x480). Diesmal werden wir durch eine Abspieloption im Schneckentempo sowie Einzelbildschritte in den vollen Genuß des Videos kommen, sofern ihr Browser alle geforderten Funktionen bereits unterstützt.




Lautstärke: 100%

Bis auf das Zurückspulen und den Szenenselektor, den wir selber implementiert haben, werden alle hier gezeigten Funktionen direkt via Javascript unterstützt.





Referenz: Javascript Funktionen, Eigenschaften und Eventhandler des video-Tags

Für alle die etwas ungeduldig sind und wissen wollen, welche Methoden, Eigenschaften und Eventhandler das video-Objekt zur Verfügung stellt hier eine Referenz absteigend nach Präzedenz und Themenzusammenhang sortiert. Damit kann man schon einmal selber loslegen ohne viel fremden Code lesen zu müssen. Natürlich sind auch weiterführende Eigenschaften und Fähigkeiten aufgelistet um die bestehende Implementierung erweitern zu können.

grundlegende Eigenschaften und Fähigkeiten
video.play() mit dem Abspielen beginnen (kann zum Start zurückspringen wenn das Video bereits spielt.)
video.pause() Pausieren (ein video.play() direkt nach einem video.pause() ist unter Umständen wirkungslos.)
video.paused true wenn das Video angehalten ist, false wenn es spielt.
<video oncanplay='myfn()' Initialisiergsroutine myfn() wird aufgerufen sobald das Video geladen worden ist.
<video ontimeupdate='myfn()' Trigger der aufgerufen wird während das Video spielt und wanimmer die Wiedergabeposition geändert wird
<video onseeked='myfn()' angesteuertes Frame ist auslesbar und wird gezeigt
<video onerror='myfn()' Fehlerbehandlungsroutine einrichten
video.duration die Dauer des Videos in Sekunden
video.currentTime aktuelle Wiedergabeposition in Sekunden
video.videoWidth, video.videoHeight Breite und Höhe des gezeigten Bildausschnittes
video.playBackRate 1 ~ Originalwiedergabegeschwindigkeit; 0.5 ~ halbe, 2 ~ doppelte Geschwindigeit, -x: Zurückspulen
video.muted true wenn Ton ausgeschalten (kein Audio)
video.volume Lautstärke als Bruchteil der Gesamtlautstärke (Wert zwischen 0 und 1)

Ein video-Tag kann mit document.createElement("VIDEO") ins Dokument eingefügt werden; seine Eigenschaften können dann über video.src, .autoplay, .loop, .controls und .preload gesteuert werden (siehe Einführungsartikel). video.addEventListener('canplay', fnname, false) fügt schließlich noch eine entsprechende Ereignishandhabung ein. Die dadurch aufgerufene Funktion erwartet ein Eventobjekt e als Parameter; mit e.target hat man dann Zugriff auf das Videoelement. "error" wäre bspw. ein weiterer Eventlistener function(e) { alert(e.code); } (siehe nachfolgende Tabelle).

e.code
1 == MEDIA_ERROR_ABORTED der Benutzer hat den Ladevorgang abgebrochen
2 == MEDIA_ERROR_NETWORK Netzwerkfehler
3 == MEDIA_ERR_DECODE Fehler beim Dekodieren
4 == MEDIA_ERR_SRC_NOT_SUPPORTED Medienformat wird nicht unterstützt

über video.networkState kann man den Ladefortschritt feststellen

video.networkState
0 == NETWORK_EMPTY noch nicht initialisiert
1 == NETWORK_IDLE Audio/Video aktiv, Quelle ausgewählt; nicht im Ladezustand
2 == NETWORK_LOADING lädt die gewählte Quelle aktiv
3 == NETWORK_NO_SOURCE keine Quelle in passendem Format kann ausgemacht werden.

Passend zu dem Statusfeld video.networkState gibt es auch die Ereignisse loadstart, progress (downloading), suspend (herunterladen ausgesetzt oder fertig), abort (abgebrochen), error, emptied (reload/neu laden) und stalled (versucht Daten zu bekommen aber keine sind verfügbar).

video.currentSrc bezeichnet die aktuelle Quelle, aus der das Video geladen wird. video.canPlayType(type) == '' / 'maybe' / 'probably' wenn das entsprechende Format gar nicht, vielleicht oder ziemlich sicher geladen werden kann.

Um ein Video automatisch starten zu lassen gibt es schließlich noch video.readyState:

video.readyState
0 == HAVE_NOTHING keine Daten
1 == HAVE_METADATA Länge, Dimension und andere Metadaten bekannt
2 == HAVE_CURRENT_DATA Es sind noch nicht wirklich genug Daten zum Weiterspielen vorhanden.
3 == HAVE_FUTURE_DATA genug Daten um mit dem Abspielen beginnen zu können
4 == HAVE_ENOUGH_DATA Mediastream sollte ohne Unterbrechung durchgespielt werden können.

Auch hier gibt es wieder passende Ereignisse nämlich loadedmetadata, loadeddata, waiting, playing, canplay, canplaythrough. Letztlich gibt es Ereignisse für alle Benutzerinteraktionen wie play, pause, seeking, timeupdate, ended, ratechange und durationchange.

Ebenso läßt sich feststellen, ob gewisse Filmabschnitte schon gepuffert sind, vom Benutzer bereits abgespielt wurden oder ansteuerbar sind: buffered, played und seekable sind TimeRanges Objekte. Die Anzahl an Zeitabschnitten kann mit timeranges.length abgefragt werden; nicht zu vergessen die Zeitabschnitte selbst, die man mit timeranges.start(i) und timeranges.end(i) bekommt. Zusätzlich gibt es noch die Boolean-Statusflags seeking und ended für das Springen an eine neue Position und das Ende der Wiedergabe. video.startTime gibt schließlich die Position an, ab der das Abspielen zuletzt begonnen hat.

Interessanterweise kann man sogar das aktuelle Videoframe pixelweise auslesen und nach gegebener Veränderung in einen anderen Canvas schreiben. Das geht allerdings nicht direkt, sondern man muß das aktuelle Frame zuerst mit drawImage aus dem Video herausholen und in einen unsichtbaren Canvas kopieren. Von dort kann man es dann pixelweise mit getImageData auslesen. Schließlich möchte man vielleicht ein neues Bild mit createImageData erzeugen und händisch befüllen. Die Pixel sind alle in einem eindimensionalen Array mit einem Eintrag für Rot, Grün, Blau und Alpha (Undurchsichtigkeit) hintereinander gespeichert. Danach kann dieses mit putImageData in einen sichtbaren Canvas kopiert werden.

var bgCanvas = document.createElement('canvas'); var bgc = bgCanvas.getContext('2d'); var redc = redCanvas.getContext('2d'), bgc.drawImage(video,0,0); pixAll = bgc.getImageData(0,0,bgCanvas.width,bgCanvas.height); pixRed = bgc.createImageData(bgCanvas.width,bgCanvas.height); for( i=0; i < pixAll.data.length; i+=4 ) { pixRed.data[i] = pixAll.data[i]; pixRed.data[i+1] = pixRed.data[i+2] = 0; pixRed.data[i+3] = 255; } redc.putImageData( pixRed, 0, 0 );


Sourcecodes des eingebetteten Videos

Schauen wir uns zunächst die HTML-Stuerelemente des eingebetteten Videos an. Hier ist nichts wirklich besonderes zu erkennen, außer daß wir die Druckknöpfe mit Unicodezeichen beschriftet haben. Einige Unicode-Symbolzeichen lassen sich etwa mit kcharselect, der Zeichentabelle von KDE auswählen, während man andere speziell für Multimediazwecke gedachte Zeichen besser nachschlägt. Diese haben so sprechende Namen wie RIGHT-POINTING TRIANGLE. Schauen Sie sich auch den Javascript oncanplay-Hook im video-Tag sowie die onchange und onclick Ereignisse der Slider, Optionslisten und Druckknöpfe an.

Wenn Sie nun mehrere Videos auf einer html-Seite haben wollen, so müssen Sie jedem Video eine eigene ID zuweisen. Der ID-Wert muß daraufhin für alle onclick und onchange Funktionsaufrufe ausgetauscht werden. Andere Elemente brauchen nicht geändert zu werden, da InitControls(this) diese automatisch aufgrund des Klassennamens ('class=') innerhalb des übergeordneten VideoControls-Containerelements findet.

<table class=VideoControls> <tr><td> <video id=ReiherVideo preload=auto oncanplay='InitControls(this)' style='min-width:848px;min-height:480px'> <source src='data/Reiher-848x480-13064.mp4' type='video/mp4'> <source src='data/Reiher-848x480-13000kb.webm' type='video/webm'> <source src='data/Reiher-848x480-20000.ogg' type='video/ogg'> <!--source src='data/Reiher-848x480-600kb.mp4' type='video/mp4'> <source src='data/Reiher-848x480-600kb.webm' type='video/webm'> <source src='data/Reiher-848x480-600kb.ogg' type='video/ogg'--> </video> <td> <select class='scenes' onchange='SelectScene(ReiherVideo,this)' size=8> <option value='0:00' selected>0:00 Abflug</option> <option value='0:03'>0:03 Reiher über freiem Wasser</option> <option value='0:07'>0:07 Landeanflug</option> <option value='0:08.6'>0:08 Landung</option> <option value='0:10'>0:10 sitzend (am Baumstamm)</option> </select> <br><br> <input type=button class=butStop value='&#x25FC;' onclick='RewindVideo(ReiherVideo);'> <input type=button class=fstbwdbut value='&#x00AB;' onclick='PlayPause(ReiherVideo,this,-2)'> <input type=button class=butStepBwd style='letter-spacing: -0.4ex;' value='&nbsp;&#x23D0;&lsaquo;&nbsp;' onclick='FrameStep(ReiherVideo,this,-0.04)'> <input type=button class=snailbut value='~' onclick='PlayPause(ReiherVideo,this,0.2)'> <input type=button class=playbutton value='&#x25B6;' onclick='PlayPause(ReiherVideo,this,1)'> <input type=button class=butStepFwd style='letter-spacing: -0.4ex;' value='&nbsp;&rsaquo;&#x23D0;&nbsp;' onclick='FrameStep(ReiherVideo,this,+0.04)'> <input type=button class=fstfwdbut value='&#x00BB;' onclick='PlayPause(ReiherVideo,this,2)'> <br> <table><tr> <td><input type=range class=volumeRange min=0 max=1 step=0.1 value=1 onchange='AdjustVolume(ReiherVideo,this.value)'> <td><input type=button value='&#x266B;' onclick='Mute(ReiherVideo,this)'> <td style="white-space: nowrap;">Lautstärke: <span class=volumeTxt> 100% </span> </table> <tr><td> <input type="range" class='videopos' style="width:100%" min=0 max=0 step=0.04 onchange='GotoPos(ReiherVideo,this.value)'> </table>

Beginnen wir mit dem Abspielen des Videos erst einmal ohne die Rückspulfunktion zu beachten. Hier haben wir eine Routine PlayPause, die wiederum PlayVideo und PauseVideo aufruft. Wir verwenden niemals video.play() und video.pause() direkt sondern eben PlayVideo und PauseVideo um die Beschriftung der Knöpfe anzupassen. video.play() sollte auch nur aufgerufen werden, wenn das Video noch nicht spielt, da es sonst an den Beginn zurückspringt. Auch ein aufeinanderfolgendes video.pause() und video.play() würde nicht das tun was wir wollen, da hierbei das Video pausiert bliebe (Scheinbar braucht es eine gewisse Zeit bis video.pause() Effekt trägt.).

Beim Anklicken eines Wiedergabezeitpunktes in der Liste rechts wird SelectScene aufgerufen, das eigentlich nur video.currentTime zu setzten braucht. Danach rufen wir wie nach jeder Änderung von video.currentTime UpdateTime auf um unseren Zeitbalken am unteren Rand des Videos anzupassen (einzige Ausnahme: GotoPos, wenn der Zeitbalken direkt angeklickt wird braucht dieser nicht mehr upgedated zu werden, da er ja schon vom Browser gesetzt worden ist.).

UpdateTime ist übrigens auch der Hook der ständig aufgerufen wird während das Video spielt. Er übergibt die Kontrolle an CheckForScene, das in der Liste der aktuellen Wiedergabezeitpunkte (Szenen) den aktuell gespielten heraussucht. Der DOM-SubTree, also alle option-Felder unseres Szenen-selectors, braucht aber nur dann abgegrast zu werden, wenn sich die aktuelle Szene verändert hat (curSceneStart, curSceneEnd).

Etwas trickreich ist auch die Implementierung der Abspielknöpfe, da sich hier immer der Knopf mit der aktuellen Wiedergabegeschwindigkeit (Vor-, Zurückspulen, Schneckentempo, normales Abspielen) in einen Pause-Knopf verwandelt. Das geht ganz einfach, indem die Beschriftung aller Knöpfe zurückgesetzt wird und dann der aktuelle in einen Pauseknopf verwandelt.

Das Erkennen der aktuellen Abspielgeschwindigkeit funktioniert mit ratedelta, da nach dem Setzen der video.playbackRate, diese geringfügig vom gesetzten Wert abweichen kann (ab- oder aufgerundet).

Nun zum Zurückspulen. Grundsätzlich funktioniert dieses so, daß wir video.currentTime 25 mal in der Sekunde mittels setInterval setzen. Das alleine würde allerdings noch nicht funktionieren. Ändert man die Videoposition nämlich schneller als der Browser das nächste Frame anzeigen kann, so ändert sich an der Anzeige im Video gar nichts. Der Browser wartet also bei mehreren Änderungen, die dicht hintereinander erfolgen, eine Zeit lang bevor er ein entsprechendes neu gesetztes Frame auch anzeigt. Das erkennt UpdateTime und setzt last_frame_shown.

Die Implementierung der anderen Steuerelemente wie Mute und AdjustVolume ist sehr geradlining und spricht wohl für sich selbst; deren Entdeckung will ich dem Leser üebrlassen.

Damit es hinsichtlich der Verwendbarkeit des präsentierten Codes keine Zweifel und Probleme gibt: er darf verwendet werden und ist unter LGPL lizensiert.

function getTime(opt) { minsec = opt.value.split(':'); return parseFloat( minsec[0]*60 + minsec[1] ); } function CheckForScene(rec,pos) { if( rec.curSceneStart <= pos && pos <= rec.curSceneEnd ) return; var scene, nextscene, sno, cn; rec.curSceneEnd = 0; nextscene = scene = null; for(sno in rec.scenes.childNodes) { cn = rec.scenes.childNodes[sno]; if(cn.tagName=="OPTION") { scene = nextscene; nextscene = cn; rec.curSceneStart = rec.curSceneEnd; rec.curSceneEnd = getTime(nextscene); if( rec.curSceneStart <= pos && pos < rec.curSceneEnd && scene ) { scene.selected = true; return; } }} rec.curSceneStart = rec.curSceneEnd; rec.curSceneEnd = rec.video.duration; scene = nextscene; if( rec.curSceneStart <= pos && pos <= rec.curSceneEnd && scene ) scene.selected = true; } var videos = {}; function getElement(base,clnm) { var ary = base.getElementsByClassName(clnm); if(ary.length!=1) return null; return ary[0]; } function InitControls(video) { var cur = video; do { cur = cur.parentNode; } while ( cur && cur.className != 'VideoControls' ); if(!cur) { alert("No container with class VideoControls found for video "+video.id); return; } if( video.id in videos ) return; var videopos = getElement(cur,'videopos'); var scenes = getElement(cur,'scenes'); if( !videopos || !scenes ) { alert("no distinct "+(videopos?"videopos":"")+(videopos&&scenes?" and ":"")+(scenes?"scenes":"")+" object found for video "+video.id); return; } videopos.max = video.duration; videopos.style.width = video.videoWidth; // video.offsetWidth || video.innerWidth; var StartScene = null, SecondScene = null; for( i in scenes.childNodes ) { var thisopt = scenes.childNodes[i]; if(thisopt.tagName=="OPTION") { if(!StartScene) StartScene = thisopt; else if(!SecondScene) SecondScene = thisopt; else break; } } if(!StartScene) { alert("no start scene defined for video "+video.id); return; } var sceneStart = getTime(StartScene), sceneEnd; if(SecondScene) sceneEnd = getTime(SecondScene); else sceneEnd = video.duration; StartScene.selected = true; var buttons = {}, bno, elmt, buttonClasses = ['butStop','fstbwdbut','butStepBwd','snailbut','playbutton','butStepFwd','fstfwdbut']; for( bno in buttonClasses ) { elmt = getElement(cur,buttonClasses[bno]); if(elmt) buttons[buttonClasses[bno]] = elmt; else alert("not found:"+buttonClasses[bno]); } var volumeTxt = getElement(cur,'volumeTxt'); volumeTxt.innerHTML = Math.round(video.volume*100)+'%' var volumeRange = getElement(cur,'volumeRange'); volumeRange.value = video.volume; //videopos.style.width = cur.offsetWidth || cur.innerWidth; videos[video.id] = { 'videopos' : videopos, 'scenes' : scenes, 'StartScene' : StartScene, 'SecondScene' : SecondScene, 'curScene' : StartScene, 'curSceneStart' : sceneStart, 'curSceneEnd' : sceneEnd, 'rewinding' : false, 'last_frame_shown' : true, 'rewindTimer' : null, 'rewindStartTime' : null, 'rewindStartPos' : null, 'ratedelta' : 0, 'volumeTxt' : volumeTxt, 'buttons' : buttons, 'video' : video }; //var j=0; for( i in videos ) j+=1; alert(j); video.addEventListener('timeupdate', UpdateTime, false) } var rewind_speed = 1; function UpdateTime(e) { var video = e.target; var rec = videos[video.id]; if( rec.last_frame_shown && rec.rewinding && ( rec.videopos.value < video.currentTime - 25/1000.0 || video.currentTime + 25/1000.0 < rec.videopos.value ) ) { video.currentTime = rec.videopos.value; rec.last_frame_shown = false; } else rec.last_frame_shown = true; rec.videopos.value = video.currentTime; CheckForScene(rec,video.currentTime); if( video.currentTime >= video.duration ) RestoreButtons(rec); } function ShowFrameWhileRewinding(rec) { var timediff = new Date().getTime() - rec.rewindStartTime; var newpos = rec.rewindStartPos - ( timediff * rewind_speed / 1000.0 ); if( newpos < 0 ) { newpos = 0; StopRewinding(rec); } if(rec.last_frame_shown) { rec.video.currentTime = newpos; rec.last_frame_shown = false; } rec.videopos.value = newpos; CheckForScene(rec,newpos); } function StartRewinding(rec,but) { rec.rewindStartTime = new Date().getTime(); // in ms rec.rewindStartPos = rec.video.currentTime; if(rec.rewindTimer===null) rec.rewindTimer = setInterval(ShowFrameWhileRewinding,1000/25,rec); rec.rewinding = true; RestoreButtons(rec); if(!(but.className in savedicon)) savedicon[but.className] = but.value; but.value = String.fromCharCode('0x25AE','0x25AE'); } function StopRewinding(rec) { clearInterval(rec.rewindTimer); rec.rewindTimer=null; rec.rewinding=false; RestoreButtons(rec); } var savedicon = {}; function PlayPause(video,but,rate) { var rec = videos[video.id]; if( rate < 0 ) { var playing = !video.paused || rec.rewinding; video.pause(); if( playing && rec.rewinding ) StopRewinding(rec); else StartRewinding(rec,but); } else { if(rec.rewinding) StopRewinding(rec); if(video.paused||video.playbackRate + rec.ratedelta != rate) PlayVideo(rec,video,but,rate); else PauseVideo(rec,video); } } function RestoreButtons(rec) { var but; for(butid in rec.buttons) { but = rec.buttons[butid]; if(butid in savedicon) but.value = savedicon[butid]; } } function PlayVideo(rec,video,but,rate) { if(video.paused) video.play(); video.playbackRate = rate; rec.ratedelta = rate - video.playbackRate; RestoreButtons(rec); if(!(but.className in savedicon)) savedicon[but.className] = but.value; but.value = String.fromCharCode('0x25AE','0x25AE'); } function PauseVideo(rec,video) { video.pause(); RestoreButtons(rec); } function FrameStep(video,but,step) { var rec = videos[video.id]; PauseVideo(rec,video); video.currentTime = video.currentTime + step; UpdateTime(video); } function RewindVideo(video) { var rec = videos[video.id]; if(rec.rewinding) StopRewinding(rec); else PauseVideo(rec,video); video.currentTime = 0; UpdateTime(video); } function GotoPos(video,newpos) { var rec = videos[video.id]; if(rec.rewinding) StopRewinding(rec); else if(!video.paused) PauseVideo(rec,video); video.currentTime = newpos; CheckForScene(rec,newpos); } function SelectScene(video,selector) { var minsec = selector.value.split(':'); video.currentTime = parseFloat( minsec[0]*60 + minsec[1] ); UpdateTime(video); } function Mute(video,but) { video.muted = !video.muted; but.style.color = video.muted ? 'silver' : 'black'; } function AdjustVolume(video,value) { var rec = videos[video.id]; video.volume = value; if(rec.volumeTxt) rec.volumeTxt.innerHTML = Math.round(value*100)+'%' }