Skip to content

Instantly share code, notes, and snippets.

@CL-Jeremy
Last active September 9, 2020 21:42
Show Gist options
  • Save CL-Jeremy/835286bfd7bc04493e5bd2ef57750c4a to your computer and use it in GitHub Desktop.
Save CL-Jeremy/835286bfd7bc04493e5bd2ef57750c4a to your computer and use it in GitHub Desktop.
Single-file flowplayer-based danmaku player for use with WsgiDAV 3.0.x

Single file comment overlay ("danmaku") player

Could be used as a subtitle player, but is mainly used for danmaku display. It loads subtitle files starting with the same name as the video file and ending with _comments.ass.

The font choice tries to resemble that of the actual Niconico player on macOS and thus could not be distributed freely. It is however possible, at least for the ASS.js version, to source local fonts installed on the viewer's host OS.

The player loads the file index page(s) supplied by WsgiDAV. Source directories can be specifed easily by changing the line Promise.all(['./'].map(populate)), which only contains the current directory the players reside in.

Keyboard shortcuts from this distribution of Flowplayer is available. The skip interval for seeking with left/right arrow keys has been set to 5s. An extra pair of shortcuts have been added to control the visibility of subtitles: press Shift + Up/Down to increase/decrease the global comment alpha by 10%.

The wasm version must be hosted locally, cf.Dador/JavascriptSubtitlesOctopus. This is due to the same-domain restriction of service workers.

This player tries its best to be compatible with older browsers. However, some browsers have serious quirks in behavior: for example, Safari < 11 doesn't send cached HTTP basic auth credentials while executing fetch, resulting in repeated password dialogs all the time. This is not a problem of the player.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>if (document.URL !== location.href) location.href = location.href</script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/flowplayer/7.2.6/skin/skin.css" rel="stylesheet">
<style type="text/css">
body {
font-family: sans-serif;
}
</style>
</head>
<body>
<p><select id="episodes"></select></p>
<p><a href=".">Index</a>&nbsp;<a id="switch" href="player.html">Switch to HTML5 renderer (ASS.js)</a></p>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.10.4/polyfill.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowplayer/7.2.6/flowplayer.min.js"></script>
<script src="./dist/js/subtitles-octopus.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fetch-polyfill2/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/mhertsch/gruft/src/gruft-common.js"></script>
<script src="https://cdn.jsdelivr.net/gh/mhertsch/gruft/src/gruft-tiger192.js"></script>
<script type="text/babel">
$(() => {
var newPlayer = () => $('<div id="player" class="fp-slim"></div>').appendTo('body');
var params, api, player;
var getQueryStringParams = query => query
? (/^[?#]/.test(query) ? query.slice(1) : query)
.split('&')
.reduce((params, param) => {
let [key, value] = param.split('=');
params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : '';
return params;
}, {})
: {};
var setQueryString = (href, newQueryString) => href.replace(/([^?#]*)(?:\?[^?#]*)?(#[^?#]*)?/, `$1\?${newQueryString}$2`);
var getQueryString = href => href.replace(/[^?#]*(?:\?([^?#]*))?(?:#[^?#]*)?/, '$1');
var editQueryString = (href, key, value) => {
params = getQueryStringParams(getQueryString(href));
return key in params
? href.replace(`${key}=${encodeURIComponent(params[key])}`, value === null
? ''
: `${key}=${value}`).replace(/[?&]$/, '')
: value === null
? href
: href.replace(/([^?#]*)(\?[^?#]*)?(#[^?#]*)?/, `$1${getQueryString(href) ? '$2&' : '?'}${key}=${value}$3`);
};
var getParams = () => getQueryStringParams(getQueryString(window.location.href));
var setParam = (key, value) => {
if (encodeURIComponent(getParams()[key]) != value)
window.history.pushState({}, null, editQueryString(window.location.href, key, value));
};
const selector = $('#episodes');
selector.on('input', () => {
setParam('s', null);
$('p:has(a)').html((index, html) => html.replace(/&nbsp;<a id="bookmark".*\/a>/, ''));
api.shutdown();
$('#player').remove();
playCurrentEpisode();
});
const tiger = new gruft.Tiger192();
var getVid = text => tiger.digest(text, { format: 'base64_safe' }).slice(0, 8);
var findEpisodeByVid = vid => $('#episodes option').filter((index, node) => getVid($(node).text()) === vid);
var findEpisode = name => $('#episodes option').filter((index, node) => $(node).text() === name);
var countEpisodes = () => $('#episodes option').length;
var hasEpisode = name => findEpisode(name).length ? true : false;
var getEpisode = name => findEpisode(name).prop('value');
var getEpisodeAtIndex = index => $(`#episodes :nth-child(${index})`).text();
var onReadyHandler = () => {
if ('s' in getParams())
api.seek(getParams()['s']);
var video = $('.fp-engine');
video.on('timeupdate', () => {
$('#switch, #bookmark').prop('href', (index, href) =>
editQueryString(href, 's', api.video.time == 0 ? null : ~~api.video.time));
});
window.SubtitlesOctopusOnLoad = () => {
var options = {
video: video[0],
subUrl: video.attr('src').replace(/\.[^.]*$/i, '_comments.ass'),
//fonts: ['/pubshare/HiraginoSansEmOneBW-W6.otf', '/pubshare/HiraginoSans-W3.otf', '/pubshare/MS-PGothic-AA.ttf'],
onReady: () => {
$('.fp-player').attr('tabIndex', -1);
$('.fp-player').on('keydown', e => {
if (e.shiftKey) {
if (e.keyCode === 38) $('.libassjs-canvas-parent').css('opacity', '+=0.1');
if (e.keyCode === 40) $('.libassjs-canvas-parent').css('opacity', '-=0.1');
}
});
},
//debug: true,
workerUrl: './dist/js/subtitles-octopus-worker.js'
};
window.octopusInstance = new SubtitlesOctopus(options); // You can experiment in console
};
if (SubtitlesOctopus) {
SubtitlesOctopusOnLoad();
}
};
var playCurrentEpisode = () => {
var file = selector.val();
setParam('id', null);
setParam('v', null);
setParam('vid', getVid(getEpisodeAtIndex(selector.prop('selectedIndex') + 1)));
player = newPlayer();
api = flowplayer('#player', {
seekStep: 5,
clip: {
sources: [
{ type: "video/" + file.match(/\.[^.]+$/)[0].slice(1), src: file.replace(/([,?:@&=+$#])/, encodeURIComponent) }
]
}
}).on('ready', onReadyHandler);
if ('s' in getParams())
api.video.time = getParams()['s'];
$('p:has(a)').append(`&nbsp;<a id="bookmark" href=${location.pathname.split("/").slice(-1)}>Bookmark current position</a>`);
$('#switch, #bookmark').prop('href', (index, href) => setQueryString(href, getQueryString(window.location.href)));
};
var populate = dir => fetch(dir, { credentials: 'include' })
.then(res => res.text())
.then((text) => {
$($.parseHTML(text))
.filter('table')
.find('a')
.each((i, el) => {
var file = $.trim(el.innerText);
if (file.match(/\.(mp4|mkv|avi|webm)+$/)) {
$('#episodes').append($('<option>', {
value: dir + file,
text: file.replace(/\.[^.]*$/i, '')
}));
}
});
});
Promise.all(['./'].map(populate))
.then(() => selector.html(selector.find('option').sort((a,b) => {
return (a.innerHTML > b.innerHTML) ? 1 : -1
})))
.then(() => {
if ('vid' in getParams()) {
const v = findEpisodeByVid(getParams()['vid']);
if (v.length) {
selector.val(v.prop('value'));
return;
}
}
if ('v' in getParams() && hasEpisode(decodeURIComponent(getParams()['v'])))
selector.val(getEpisode(decodeURIComponent(getParams()['v'])));
else if ('id' in getParams())
selector.prop('selectedIndex', getParams()['id'] == 0
? 0
: +getParams()['id'] + ~~(1 - (getParams()['id']) / countEpisodes()) * countEpisodes() - 1);
})
.then(playCurrentEpisode);
});
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>if (document.URL !== location.href) location.href = location.href</script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/flowplayer/7.2.6/skin/skin.css" rel="stylesheet">
<style type="text/css">
body {
font-family: sans-serif;
}
/* @font-face {
font-family: 'Hiragino Sans W6';
font-weight: 600;
src: local('Hiragino Sans W6'), local('Hiragino Kaku Gothic ProN W6');
}
@font-face {
font-family: 'Hiragino Sans W3';
font-weight: 300;
src: local('Hiragino Sans W3'), local('Hiragino Kaku Gothic ProN W3');
}
@font-face {
font-family: 'MS PGothic AA';
font-weight: 600;
src: url('/pubshare/MS-PGothic-AA.ttf'); */
}
.ASS-dialogue > span {
font-size-adjust: 0.48;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<p><select id="episodes"></select></p>
<p><a href=".">Index</a>&nbsp;<a id="switch" href="player-wasm.html">Switch to native renderer (WebAssembly)</a></p>
<script src="https://code.jquery.com/jquery-1.12.4.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.10.4/polyfill.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flowplayer/7.2.6/flowplayer.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/CL-Jeremy/ASS@next/dist/ass.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/fetch-polyfill2/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/mhertsch/gruft/src/gruft-common.js"></script>
<script src="https://cdn.jsdelivr.net/gh/mhertsch/gruft/src/gruft-tiger192.js"></script>
<script src="https://cdn.jsdelivr.net/npm/css-element-queries/src/ResizeSensor.js"></script>
<script type="text/babel">
$(() => {
var newPlayer = () => $('<div id="player" class="fp-slim"></div>').appendTo('body');
var params, api, player;
var getQueryStringParams = query => query
? (/^[?#]/.test(query) ? query.slice(1) : query)
.split('&')
.reduce((params, param) => {
let [key, value] = param.split('=');
params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : '';
return params;
}, {})
: {};
var setQueryString = (href, newQueryString) => href.replace(/([^?#]*)(?:\?[^?#]*)?(#[^?#]*)?/, `$1\?${newQueryString}$2`);
var getQueryString = href => href.replace(/[^?#]*(?:\?([^?#]*))?(?:#[^?#]*)?/, '$1');
var editQueryString = (href, key, value) => {
params = getQueryStringParams(getQueryString(href));
return key in params
? href.replace(`${key}=${encodeURIComponent(params[key])}`, value === null
? ''
: `${key}=${value}`).replace(/[?&]$/, '')
: value === null
? href
: href.replace(/([^?#]*)(\?[^?#]*)?(#[^?#]*)?/, `$1${getQueryString(href) ? '$2&' : '?'}${key}=${value}$3`);
};
var getParams = () => getQueryStringParams(getQueryString(window.location.href));
var setParam = (key, value) => {
if (encodeURIComponent(getParams()[key]) != value)
window.history.pushState({}, null, editQueryString(window.location.href, key, value));
};
const selector = $('#episodes');
selector.on('input', () => {
$('p:has(a)').html((index, html) => html.replace(/&nbsp;<a id="bookmark".*\/a>/, ''));
setParam('s', null);
api.shutdown();
$('#player').remove();
playCurrentEpisode();
});
const tiger = new gruft.Tiger192();
var getVid = text => tiger.digest(text, { format: 'base64_safe' }).slice(0, 8);
var findEpisodeByVid = vid => $('#episodes option').filter((index, node) => getVid($(node).text()) === vid);
var findEpisode = name => $('#episodes option').filter((index, node) => $(node).text() === name);
var countEpisodes = () => $('#episodes option').length;
var hasEpisode = name => findEpisode(name).length ? true : false;
var getEpisode = name => findEpisode(name).prop('value');
var getEpisodeAtIndex = index => $(`#episodes :nth-child(${index})`).text();
var onReadyHandler = () => {
if ('s' in getParams())
api.seek(getParams()['s']);
var video = $('.fp-engine');
video.on('timeupdate', () => {
$('#switch, #bookmark').prop('href', (index, href) =>
editQueryString(href, 's', api.video.time == 0 ? null : ~~api.video.time));
});
fetch(video.attr('src').replace(/\.[^.]*$/i, '_comments.ass'), { credentials: 'include' })
.then(res => res.text())
.then((text) => {
const ass = new ASS(text, video[0], {
container: $('.fp-player')[0],
resampling: 'video-width'
});
new ResizeSensor(player, () => {
$('.fp-player').attr('style', `width: ${player.css('width')}; height: ${player.css('height')};`);
ass.resize();
});
ass.show();
$('.fp-player').attr('tabIndex', -1);
$('.fp-player').on('keydown', e => {
if (e.shiftKey) {
if (e.keyCode === 38) $('.ASS-stage').css('opacity', '+=0.1');
if (e.keyCode === 40) $('.ASS-stage').css('opacity', '-=0.1');
}
});
});
};
var playCurrentEpisode = () => {
var file = selector.val();
setParam('id', null);
setParam('v', null);
setParam('vid', getVid(getEpisodeAtIndex(selector.prop('selectedIndex') + 1)));
player = newPlayer();
api = flowplayer('#player', {
seekStep: 5,
clip: {
sources: [
{ type: "video/" + file.match(/\.[^.]+$/)[0].slice(1), src: file.replace(/([,?:@&=+$#])/, encodeURIComponent) }
]
}
}).on('ready', onReadyHandler);
if ('s' in getParams())
api.video.time = getParams()['s'];
$('p:has(a)').append(`&nbsp;<a id="bookmark" href=${location.pathname.split("/").slice(-1)}>Bookmark current position</a>`);
$('#switch, #bookmark').prop('href', (index, href) => setQueryString(href, getQueryString(window.location.href)));
};
var populate = dir => fetch(dir, { credentials: 'include' })
.then(res => res.text())
.then((text) => {
$($.parseHTML(text))
.filter('table')
.find('a')
.each((i, el) => {
var file = $.trim(el.innerText);
if (file.match(/\.(mp4|mkv|avi|webm)+$/)) {
$('#episodes').append($('<option>', {
value: dir + file,
text: file.replace(/\.[^.]*$/i, '')
}));
}
});
});
Promise.all(['./', 'enjou/'].map(populate))
.then(() => selector.html(selector.find('option').sort((a,b) => {
return (a.innerHTML > b.innerHTML) ? 1 : -1
})))
.then(() => {
if ('vid' in getParams()) {
const v = findEpisodeByVid(getParams()['vid']);
if (v.length) {
selector.val(v.prop('value'));
return;
}
}
if ('v' in getParams() && hasEpisode(decodeURIComponent(getParams()['v'])))
selector.val(getEpisode(decodeURIComponent(getParams()['v'])));
else if ('id' in getParams())
selector.prop('selectedIndex', getParams()['id'] == 0
? 0
: +getParams()['id'] + ~~(1 - (getParams()['id']) / countEpisodes()) * countEpisodes() - 1);
})
.then(playCurrentEpisode);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment