Merge pull request #21 from CyberShadow/pull-20190727-205645

Many fixes and improvements
This commit is contained in:
Ali Zemani 2019-07-28 14:25:26 +04:30 committed by GitHub
commit a0984c1f46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 339 additions and 206 deletions

View file

@ -1,15 +1,12 @@
// Data
var segmentMap = SegmentMap; var segmentMap = SegmentMap;
var bv = bandersnatch.videos['80988062'].interactiveVideoMoments.value; var bv = bandersnatch.videos['80988062'].interactiveVideoMoments.value;
var choicePoints = bv.choicePointNavigatorMetadata.choicePointsMetadata.choicePoints; var choicePoints = bv.choicePointNavigatorMetadata.choicePointsMetadata.choicePoints;
var momentsBySegment = bv.momentsBySegment; var momentsBySegment = bv.momentsBySegment;
var segmentGroups = bv.segmentGroups; var segmentGroups = bv.segmentGroups;
var captions = {};
var currentSegment; // Persistent state
var currentMoment; var ls = window.localStorage || {};
var nextSegment = null;
var momentSelected = null;
var persistentState = bv.stateHistory;
var globalChoices = {};
function msToString(ms) { function msToString(ms) {
return new Date(ms).toUTCString().split(' ')[4]; return new Date(ms).toUTCString().split(' ')[4];
@ -19,76 +16,73 @@ function getCurrentMs() {
return Math.round(document.getElementById("video").currentTime * 1000.0); return Math.round(document.getElementById("video").currentTime * 1000.0);
} }
function generateJs(cond) { function preconditionToJS(cond) {
if (cond[0] == 'persistentState') { if (cond[0] == 'persistentState') {
return '!!persistentState["' + cond[1] + '"]'; return 'JSON.parse(ls["persistentState_' + cond[1] + '"])';
} else if (cond[0] == 'not') { } else if (cond[0] == 'not') {
return '!(' + generateJs(cond[1]) + ')'; return '!(' + preconditionToJS(cond[1]) + ')';
} else if (cond[0] == 'and') { } else if (cond[0] == 'and') {
var conds = []; return '(' + cond.slice(1).map(preconditionToJS).join(' && ') + ')';
for (var i = 1; i < cond.length; i++) {
conds.push('(' + generateJs(cond[i]) + ')');
}
return '(' + conds.join(' && ') + ')';
} else if (cond[0] == 'or') { } else if (cond[0] == 'or') {
var conds = []; return '(' + cond.slice(1).map(preconditionToJS).join(' || ') + ')';
for (var i = 1; i < cond.length; i++) { } else if (cond[0] == 'eql' && cond.length == 3) {
conds.push('(' + generateJs(cond[i]) + ')'); return '(' + cond.slice(1).map(preconditionToJS).join(' == ') + ')';
} } else if (cond === false) {
return '(' + conds.join(' || ') + ')'; return false;
} else if (cond === true) {
return true;
} else if (typeof cond === 'string') {
return cond;
} else { } else {
console.log('unsupported condition!', cond); console.log('unsupported condition!', cond);
return 'true'; return 'true';
} }
} }
function checkPrecondition(segmentId) { function evalPrecondition(precondition, text) {
let precondition = bv.preconditions[segmentId];
if (precondition) { if (precondition) {
let cond = generateJs(precondition); let cond = preconditionToJS(precondition);
let match = eval(cond); let match = eval(cond);
console.log('precondition', text, ':', cond, '==', match);
console.log(cond, '==', match);
return match; return match;
} }
return true; return true;
} }
function findSegment(id) { function checkPrecondition(preconditionId) {
if (id.startsWith('nsg-')) { return evalPrecondition(bv.preconditions[preconditionId], preconditionId);
id = id.substr(4); }
}
if (SegmentMap.segments[id]) {
// check precondition
return id;
}
if (segmentGroups[id]) { function resolveSegmentGroup(sg) {
for (v of segmentGroups[id]) { let results = [];
if (v.segmentGroup) { for (let v of segmentGroups[sg]) {
return findSegment(v.segmentGroup); if (v.precondition) {
} else if (v.segment) { if (!checkPrecondition(v.precondition))
// check precondition continue;
return v.segment; }
} else { if (v.segmentGroup) {
if (checkPrecondition(v)) results.push(resolveSegmentGroup(v.segmentGroup));
return v; } else if (v.segment) {
} // TODO: does the included precondition override or
// complement the segment precondition?
if (!checkPrecondition(v.segment))
continue;
results.push(v.segment);
} else {
if (!checkPrecondition(v))
continue;
results.push(v);
} }
} }
return id; console.log('segment group', sg, '=>', results);
} return results[0];
function getChoiceMs(choiceId) {
var segmentId = findSegment(choiceId);
return getSegmentMs(segmentId);
} }
/// Returns the segment ID at the given timestamp.
/// There will be exactly one segment for any timestamp within the video file.
function getSegmentId(ms) { function getSegmentId(ms) {
for (const [k, v] of Object.entries(SegmentMap.segments)) { for (const [k, v] of Object.entries(segmentMap.segments)) {
if (ms >= v.startTimeMs && ms < v.endTimeMs) { if (ms >= v.startTimeMs && ms < v.endTimeMs) {
return k; return k;
} }
@ -100,16 +94,17 @@ function getSegmentMs(segmentId) {
return segmentMap.segments[segmentId].startTimeMs; return segmentMap.segments[segmentId].startTimeMs;
} }
function getMoment(ms) { function getMoments(segmentId, ms) {
for (const [k, v] of Object.entries(momentsBySegment)) { let result = {};
for (r of v) let moments = momentsBySegment[segmentId] || [];
if (r.type == 'scene:cs_bs') { for (let i = 0; i < moments.length; i++) {
if (ms >= r.startMs && ms < r.endMs) { let m = moments[i];
return r; let momentId = segmentId + '/' + i;
} if (ms >= m.startMs && ms < m.endMs && evalPrecondition(m.precondition, 'moment ' + momentId)) {
} result[momentId] = m;
}
} }
return null; return result;
} }
function newList(id) { function newList(id) {
@ -129,9 +124,13 @@ function addItem(ul, text, url) {
ul.appendChild(li); ul.appendChild(li);
} }
var nextChoice = -1;
var nextSegment = null;
function setNextSegment(segmentId, comment) { function setNextSegment(segmentId, comment) {
console.log('setNextSegment', segmentId, comment); console.log('setNextSegment', segmentId, comment);
nextSegment = segmentId; nextSegment = segmentId;
nextChoice = -1;
var ul = newList("nextSegment"); var ul = newList("nextSegment");
var caption = 'nextSegment: ' + segmentId; var caption = 'nextSegment: ' + segmentId;
addItem(ul, comment ? caption + ' (' + comment + ')' : caption, addItem(ul, comment ? caption + ' (' + comment + ')' : caption,
@ -140,142 +139,267 @@ function setNextSegment(segmentId, comment) {
function addZones(segmentId) { function addZones(segmentId) {
var ul = newList("interactionZones"); var ul = newList("interactionZones");
var caption = 'currentSegment(' + segmentId + ')'; let caption = 'currentSegment(' + segmentId + ')';
addItem(ul, caption, 'javascript:playSegment("' + segmentId + '")'); addItem(ul, caption, 'javascript:playSegment("' + segmentId + '")');
var v = segmentMap.segments[segmentId]; var v = segmentMap.segments[segmentId];
if (v && v.ui && v.ui.interactionZones) { if (v && v.ui && v.ui.interactionZones) {
var index = 0; var index = 0;
for (z of v.ui.interactionZones) { for (var z of v.ui.interactionZones) {
var startMs = z[0]; var startMs = z[0];
var stopMs = z[1]; var stopMs = z[1];
var caption = segmentId + ' interactionZone ' + index; let caption = segmentId + ' interactionZone ' + index;
addItem(ul, caption, 'javascript:seek(' + startMs + ')'); addItem(ul, caption, 'javascript:seek(' + startMs + ')');
index++; index++;
} }
} }
var ul = newList("nextSegments"); ul = newList("nextSegments");
let defaultSegmentId = null;
for (const [k, v] of Object.entries(segmentMap.segments[segmentId].next)) { for (const [k, v] of Object.entries(segmentMap.segments[segmentId].next)) {
var caption = captions[k] ? captions[k] : k; let caption = k;
if (segmentMap.segments[segmentId].defaultNext == k) { if (segmentMap.segments[segmentId].defaultNext == k) {
caption = '[' + caption + ']'; caption = '[' + caption + ']';
setNextSegment(k); defaultSegmentId = k;
} }
addItem(ul, caption, 'javascript:playSegment("' + k + '")'); addItem(ul, caption, 'javascript:playSegment("' + k + '")');
} }
setNextSegment(defaultSegmentId);
} }
var currentChoiceMoment = null;
function addChoices(r) { function addChoices(r) {
currentChoiceMoment = r;
nextChoice = -1;
var ul = newList("choices"); var ul = newList("choices");
document.getElementById("choiceCaption").innerHTML = ''; document.getElementById("choiceCaption").innerHTML = '';
if (!r) return; if (!r) return;
index = 0;
for (x of r.choices) { nextChoice = r.defaultChoiceIndex;
console.log(x.id, 'choice saved');
globalChoices[x.id] = x;
let index = 0;
for (let x of r.choices) {
var caption = r.defaultChoiceIndex == index ? '[' + x.text + ']' : x.text; var caption = r.defaultChoiceIndex == index ? '[' + x.text + ']' : x.text;
addItem(ul, caption, 'javascript:choice("' + addItem(ul, caption, 'javascript:choice(' + index + ')');
(x.segmentId ? x.segmentId : (x.sg ? x.sg : x.id)) + '", "' + x.text + '", "' + x.id + '")');
index++; index++;
} }
document.getElementById("choiceCaption").innerHTML = choicePoints[r.id].description; if (r.id in choicePoints)
document.getElementById("choiceCaption").innerHTML = choicePoints[r.id].description;
} }
function momentStart(m, seeked) {
function updateProgressBar(ms, r) { console.log('momentStart', m, seeked);
var p = 0; if (m.choices) {
addChoices(m);
if (r && ms > r.startMs && ms < r.endMs) {
p = 100 - Math.floor((ms - r.startMs) * 100 / (r.endMs - r.startMs));
} }
if (!seeked)
applyImpression(m.impressionData);
}
document.getElementById("progress").style.width = p + '%'; function momentUpdate(m, ms) {
//console.log('momentUpdate', m);
if (m.choices) {
var p = 100 - ((ms - m.startMs) * 100.0 / (m.endMs - m.startMs));
document.getElementById("progress").style.width = p + '%';
}
}
function momentEnd(m, seeked) {
console.log('momentEnd', m, seeked);
if (m.choices) {
addChoices(null);
document.getElementById("progress").style.width = 0;
}
} }
var timerId = 0; var timerId = 0;
var lastMs = 0;
var switchFrom = null; var currentSegment;
var switchTo = null; var lastSegment = null;
var segmentTransition = false;
function ontimeout(nextSegment) { var lastMoments = [];
console.log('ontimeout', nextSegment);
if (switchFrom != currentSegment || switchTo != nextSegment) {
playSegment(nextSegment);
}
switchFrom = currentSegment;
switchTo = nextSegment;
}
function ontimeupdate(evt) { function ontimeupdate(evt) {
var ms = getCurrentMs(); var ms = getCurrentMs();
currentSegment = getSegmentId(ms);
var segmentId = getSegmentId(ms); if (timerId) {
clearTimeout(timerId);
// ontimeupdate resolution is about a second, better use timer timerId = 0;
clearTimeout(timerId);
if (segmentId && nextSegment && nextSegment != segmentId) {
var timeLeft = SegmentMap.segments[segmentId].endTimeMs - ms;
timerId = setTimeout(ontimeout, timeLeft, nextSegment);
} }
if (currentSegment != segmentId) { // Distinguish between the user seeking manually with <video> controls,
console.log('ontimeupdate', currentSegment, segmentId, ms, msToString(ms)); // and the video playing normally (past some timestamp / boundary).
currentSegment = segmentId; let timeElapsed = ms - lastMs;
addZones(segmentId); let seeked = timeElapsed < 0 || timeElapsed >= 2000;
currentMoment = null; lastMs = ms;
addChoices(0);
}
var r = getMoment(ms); // Recalculate title and hash only when we pass some meaningful timestamp.
if (r && momentSelected != r.id) { let placeChanged = false;
updateProgressBar(ms, r);
if (currentMoment != r.id) { // Handle segment change
currentMoment = r.id; if (lastSegment != currentSegment) {
console.log('interaction', currentMoment); console.log('ontimeupdate', lastSegment, '->', currentSegment, ms, msToString(ms), seeked);
addChoices(r); lastSegment = currentSegment;
if (!seeked) {
if (playNextSegment()) {
// playSegment decided to seek, which means that this
// currentSegment is invalid, and a recursive
// ontimeupdate invocation should have taken care of
// things already. Return.
return;
}
} }
} else { addZones(currentSegment);
currentMoment = null; placeChanged = true;
addChoices(0);
updateProgressBar(0);
} }
var naturalTransition = !seeked || segmentTransition;
segmentTransition = false;
var currentMoments = getMoments(currentSegment, ms);
for (let k in lastMoments)
if (!(k in currentMoments)) {
momentEnd(lastMoments[k], !naturalTransition);
placeChanged = true;
}
for (let k in lastMoments)
if (k in currentMoments)
momentUpdate(lastMoments[k], ms);
for (let k in currentMoments)
if (!(k in lastMoments)) {
momentStart(currentMoments[k], !naturalTransition);
placeChanged = true;
}
lastMoments = currentMoments;
if (placeChanged) {
let title = 'Bandersnatch';
title += ' - Chapter ' + currentSegment;
for (let k in currentMoments) {
let m = currentMoments[k];
if (m.type.substr(0, 6) == 'scene:') {
if (m.id && m.id in choicePoints && choicePoints[m.id].description)
title += ' - Choice "' + choicePoints[m.id].description + '"';
else
title += ' - Choice ' + (m.id || k);
}
}
document.title = title;
let hash = currentSegment;
// Pick the moment which starts closer to the current timestamp.
let bestMomentStart = segmentMap.segments[currentSegment].startTimeMs;
for (let k in currentMoments) {
let m = currentMoments[k];
if (m.startMs > bestMomentStart) {
hash = k;
bestMomentStart = m.startMs;
}
}
hash = '#' + hash;
lastHash = hash; // suppress onhashchange event
location.hash = hash;
ls.place = hash;
}
// ontimeupdate resolution is about a second. Augment it using timer.
let nextEvent = segmentMap.segments[currentSegment].endTimeMs;
for (let k in currentMoments) {
let m = currentMoments[k];
if (m.endMs < nextEvent)
nextEvent = m.endMs;
}
for (let m of momentsBySegment[currentSegment] || [])
if (ms < m.startMs && m.startMs < nextEvent)
nextEvent = m.startMs;
var timeLeft = nextEvent - ms;
if (timeLeft > 0)
timerId = setTimeout(ontimeupdate, timeLeft);
} }
function jumpForward(ms) { function playNextSegment() {
if (nextChoice >= 0) {
let x = currentChoiceMoment.choices[nextChoice];
if (x.segmentId)
nextSegment = x.segmentId;
else if (x.sg)
nextSegment = resolveSegmentGroup(x.sg);
else
nextSegment = null;
console.log('choice', nextChoice, 'nextSegment', nextSegment);
applyImpression(x.impressionData);
}
let breadcrumb = 'breadcrumb_' + nextSegment;
if (!(breadcrumb in ls))
ls[breadcrumb] = lastSegment;
segmentTransition = true;
let segment = nextSegment;
nextSegment = null;
if (segment)
return playSegment(segment, true);
return false;
}
function jumpForward() {
var ms = getCurrentMs(); var ms = getCurrentMs();
var segmentId = getSegmentId(ms); var segmentId = getSegmentId(ms);
var v = segmentMap.segments[segmentId];
var interactionMs = 0; var interactionMs = 0;
if (v && v.ui && v.ui.interactionZones) { let moments = momentsBySegment[segmentId] || [];
for (z of v.ui.interactionZones) { // Find the earliest moment within this segment after cursor
var startMs = z[0]; for (let m of moments)
var stopMs = z[1]; if (m.startMs >= ms && (interactionMs == 0 || m.startMs < interactionMs))
if (ms < startMs) interactionMs = m.startMs;
interactionMs = startMs;
}
}
if (interactionMs) { if (interactionMs) {
seek(interactionMs); seek(interactionMs);
} else { } else {
playSegment(nextSegment); playNextSegment();
} }
} }
function jumpBack() { function jumpBack() {
var ms = getCurrentMs(); var ms = getCurrentMs();
var segmentId = getSegmentId(ms); var segmentId = getSegmentId(ms);
var startMs = getSegmentMs(segmentId); let segment = segmentMap.segments[segmentId];
var previousSegment = getSegmentId(startMs - 1000);
console.log('jumpBack from-to', segmentId, previousSegment); var interactionMs = 0;
playSegment(previousSegment); let moments = momentsBySegment[segmentId] || [];
let inMoment = false;
// Find the latest moment within this segment before cursor
for (let m of moments) {
if (m.endMs < ms && m.startMs > interactionMs)
interactionMs = m.startMs;
if (m.startMs != segment.startTimeMs && m.startMs <= ms && ms < m.endMs)
inMoment = true;
}
if (interactionMs) {
seek(interactionMs);
} else if (inMoment) {
seek(segment.startTimeMs);
} else {
let breadcrumb = 'breadcrumb_' + segmentId;
if (breadcrumb in ls) {
// Jump to last moment in previous segment
segmentId = ls[breadcrumb];
segment = segmentMap.segments[segmentId];
interactionMs = segment.startTimeMs;
let moments = momentsBySegment[segmentId] || [];
for (let m of moments)
if (m.startMs > interactionMs)
interactionMs = m.startMs;
seek(interactionMs);
} else {
seek(0);
}
}
} }
function toggleFullScreen() { function toggleFullScreen() {
@ -310,120 +434,129 @@ function togglePlayPause() {
else v.pause(); else v.pause();
} }
function onload() { window.onload = function() {
var video_selector = document.getElementById("video"); var video_selector = document.getElementById("video");
var video_source_selector = document.getElementById("video-source"); var video_source_selector = document.getElementById("video-source");
var file_selector = document.getElementById("file-selector"); var file_selector = document.getElementById("file-selector");
function startPlayback() {
file_selector.style.display = 'none';
if (window.location.hash)
playHash(window.location.hash);
else if (ls.place)
playHash(ls.place);
else
playSegment(null);
video_selector.play();
}
if (video_source_selector.getAttribute("src") == '') { if (video_source_selector.getAttribute("src") == '') {
console.log('no video') console.log('no video');
file_selector.style.display = 'table'; file_selector.style.display = 'table';
document.getElementById("wrapper-video").style.display = 'none'; document.getElementById("wrapper-video").style.display = 'none';
} else {
startPlayback();
} }
document.getElementById('fileinput').addEventListener('change', function () { document.getElementById('fileinput').addEventListener('change', function () {
var file = this.files[0]; var file = this.files[0];
var fileUrl = URL.createObjectURL(file) var fileUrl = URL.createObjectURL(file);
video_selector.src = fileUrl; video_selector.src = fileUrl;
video_selector.play();
file_selector.style.display = 'none';
document.getElementById("wrapper-video").style.display = 'block'; document.getElementById("wrapper-video").style.display = 'block';
startPlayback();
}, false); }, false);
video_selector.ontimeupdate = ontimeupdate; video_selector.ontimeupdate = ontimeupdate;
var c = document.getElementById("c"); var c = document.getElementById("c");
c.ondblclick = toggleFullScreen; c.ondblclick = toggleFullScreen;
c.onclick = function () { video_selector.onclick = function (e) {
// can't togglePlayPause here, choice buttons stop the video togglePlayPause();
// should use preventdefault or something e.preventDefault();
// use spacebar for now
// mind that autoplay is disabled in latest chrome, so play after click
document.getElementById("video").play();
}; };
document.onkeypress = function (e) { document.onkeypress = function (e) {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
return;
if (e.code == 'KeyF') if (e.code == 'KeyF')
toggleFullScreen(); toggleFullScreen();
if (e.code == 'KeyR') if (e.code == 'KeyR')
playSegment(0); playSegment(0);
if (e.code == 'Space') if (e.code == 'Space')
togglePlayPause(); togglePlayPause();
} };
video_selector.onkeydown = function(e) {
if (e.code == 'Space')
e.preventDefault();
};
document.onkeydown = function (evt) { document.onkeydown = function (e) {
var v = document.getElementById("video"); if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)
return;
if (evt.key == 'ArrowLeft') { if (e.key == 'ArrowLeft')
jumpBack(); jumpBack();
} if (e.key == 'ArrowRight')
if (evt.key == 'ArrowRight') {
jumpForward(); jumpForward();
} if (e.key == 'ArrowUp')
} video_selector.playbackRate = video_selector.playbackRate * 2.0;
if (e.key == 'ArrowDown')
video_selector.playbackRate = video_selector.playbackRate / 2.0;
};
if (location.hash) { window.onhashchange = function() {
var segmentId = location.hash.slice(1); playHash(window.location.hash);
playSegment(segmentId); };
} };
}
function seek(ms) { function seek(ms) {
clearTimeout(timerId);
console.log('seek', ms); console.log('seek', ms);
momentSelected = null;
document.getElementById("video").currentTime = ms / 1000.0; document.getElementById("video").currentTime = ms / 1000.0;
ontimeupdate(null);
} }
function choice(choiceId, text, id) { function choice(choiceIndex) {
var segmentId = findSegment(choiceId); nextChoice = choiceIndex;
console.log('choice', choiceId, 'nextSegment', segmentId); newList("choices");
applyImpression(globalChoices[id]); if (!currentChoiceMoment.config.disableImmediateSceneTransition)
setNextSegment(segmentId, text); playNextSegment();
momentSelected = choiceId;
addChoices(0);
} }
function applyImpression(obj) { function applyImpression(impressionData) {
if (!obj) {
return;
}
impressionData = obj.impressionData;
if (impressionData && impressionData.type == 'userState') { if (impressionData && impressionData.type == 'userState') {
for (const [variable, value] of Object.entries(impressionData.data.persistent)) { for (const [variable, value] of Object.entries(impressionData.data.persistent)) {
console.log('persistentState set', variable, '=', value); console.log('persistentState set', variable, '=', value);
persistentState[variable] = value; ls["persistentState_" + variable] = JSON.stringify(value);
} }
} }
} }
function applyPlaybackImpression(segmentId) { function playSegment(segmentId, noSeek) {
let moments = momentsBySegment[segmentId]; if (!segmentId || typeof segmentId === "undefined")
segmentId = segmentMap.initialSegment;
var oldSegment = getSegmentId(getCurrentMs());
console.log('playSegment', oldSegment, '->', segmentId);
if (!noSeek || oldSegment != segmentId) {
var ms = getSegmentMs(segmentId);
seek(ms);
return true;
}
return false;
}
if (!moments) { var lastHash = '';
console.log('warning - no moments'); function playHash(hash) {
// console.log('playHash', lastHash, '->', hash);
if (hash == lastHash)
return; return;
} lastHash = hash;
if (hash) {
for (moment of moments) { hash = hash.slice(1);
if (moment.type != 'notification:playbackImpression') { if (hash[0] == 't')
continue; seek(Number(Math.round(hash.slice(1) * 1000.0)));
else {
let loc = hash.split('/');
let segmentId = loc[0];
if (loc.length > 1)
seek(momentsBySegment[segmentId][loc[1]].startMs);
else
seek(getSegmentMs(segmentId));
} }
applyImpression(moment);
} }
} }
function playSegment(segmentId) {
clearTimeout(timerId);
if (!segmentId || segmentId == "undefined")
segmentId = '1A';
console.log('playSegment', segmentId);
applyPlaybackImpression(segmentId);
location.hash = segmentId;
document.title = 'Bandersnatch - Chapter ' + segmentId;
var ms = getSegmentMs(segmentId);
seek(ms);
}

View file

@ -24,7 +24,7 @@
</head> </head>
<body onload=onload()> <body>
<div> <div>
<div id="c"> <div id="c">
<section role="banner"> <section role="banner">
@ -68,7 +68,7 @@
<li><kbd>F</kbd> - Toggle fullscreen</li> <li><kbd>F</kbd> - Toggle fullscreen</li>
<li><kbd>R</kbd> - Restart video</li> <li><kbd>R</kbd> - Restart video</li>
<li><kbd></kbd> - Jump to the next segment (or to the next interaction zone)</li> <li><kbd></kbd> - Jump to the next segment (or to the next interaction zone)</li>
<li><kbd></kbd> - Jump to the previous segment</li> <li><kbd></kbd> - Jump to the previous segment (or interaction zone)</li>
<li><kbd>Space</kbd> - Toggle play and pause</li> <li><kbd>Space</kbd> - Toggle play and pause</li>
</ul> </ul>
@ -77,7 +77,7 @@
</div> </div>
<div id="wrapper-video"> <div id="wrapper-video">
<video id="video" loop preload="metadata"> <video id="video" preload="metadata">
<source id="video-source" src=""> <source id="video-source" src="">
<track label="English" kind="subtitles" srclang="en" <track label="English" kind="subtitles" srclang="en"
src="subtitle/Black.Mirror.Bandersnatch.2018.WEBRip.x264-NoGRP.vtt" default> src="subtitle/Black.Mirror.Bandersnatch.2018.WEBRip.x264-NoGRP.vtt" default>