26 Commits

Author SHA1 Message Date
tforgione 0201bcd4a9 First attempt with plyr 2021-05-29 15:38:59 +02:00
tforgione df821e07af Add support for dark mode 2021-04-03 23:33:01 +02:00
tforgione 28f6c05acb I don't like this way of doing record position 2021-03-24 08:18:46 +01:00
tforgione c28f3ef9ae Support for HLS 2021-03-24 08:17:36 +01:00
tforgione 360952706d Space is correctly allocated for images when unloaded 2021-03-13 11:31:31 +01:00
tforgione 53e96eaa48 Fix bug 2020-12-14 10:39:22 +01:00
tforgione a6d73f094d Update vd 2020-11-06 16:36:37 +01:00
tforgione 1bb1f8f5c4 Adds badge new 2020-11-06 12:19:42 +01:00
tforgione cace6f09f8 Clean 2020-11-06 11:43:28 +01:00
tforgione 5b902ec915 Sort playlists by last video date 2020-11-06 11:15:54 +01:00
tforgione 67e633d887 Fix bug 2020-11-05 17:34:03 +01:00
tforgione 1621e5ac58 Update vd 2020-11-05 17:32:58 +01:00
tforgione ff5db2df3d Adds favicon 2020-11-05 17:23:42 +01:00
tforgione a61d2fabcd Update video 2020-11-05 17:18:16 +01:00
tforgione 57bc1e0a9f Update vd.js 2020-11-05 16:17:05 +01:00
tforgione 2b79862e51 Remove useless comment 2020-10-28 17:03:11 +01:00
tforgione a6c651bdd2 Better timezone 2020-10-28 16:57:08 +01:00
tforgione e5c9bde291 Better padding for home button 2020-10-21 11:05:31 +02:00
tforgione 2775eed077 More robust indexify 2020-10-21 11:00:19 +02:00
tforgione 948df5da9a Change miniatures when hover 2020-10-16 12:03:07 +02:00
tforgione bf1d35c179 Fix timezone 2020-10-15 16:47:10 +02:00
tforgione 8c10e99cca Last miniature in playlist 2020-10-13 14:15:55 +02:00
tforgione 082817a521 Fix shortcuts 2020-10-08 17:25:17 +02:00
tforgione c8664f2375 Fix indexify 2020-10-08 16:33:30 +02:00
tforgione 1ba2bbd8ce Links instead of button 2020-10-08 16:18:22 +02:00
tforgione f22e1c576b Scroll up on page change 2020-10-06 18:32:10 +02:00
13 changed files with 1148 additions and 642 deletions
+1
View File
@@ -15,6 +15,7 @@
"elm/time": "1.0.0", "elm/time": "1.0.0",
"elm/url": "1.0.0", "elm/url": "1.0.0",
"jims/html-parser": "1.0.0", "jims/html-parser": "1.0.0",
"justinmimbs/timezone-data": "3.0.3",
"mdgriffith/elm-ui": "1.1.8", "mdgriffith/elm-ui": "1.1.8",
"rtfeldman/elm-iso8601-date-strings": "1.1.3" "rtfeldman/elm-iso8601-date-strings": "1.1.3"
}, },
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+112 -25
View File
@@ -4,46 +4,133 @@
<title>twitch.tforgione.fr</title> <title>twitch.tforgione.fr</title>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link href="css/video-js.css" rel="stylesheet"> <link rel="icon" type="image/ico" href="/favicon.ico"/>
<link rel="stylesheet" href="https://cdn.plyr.io/3.6.7/plyr.css" />
<link href="css/spinner.css" rel="stylesheet"> <link href="css/spinner.css" rel="stylesheet">
<style>
video, .plyr, .plyr__video-wrapper {
height: 100%;
max-height: 100%;
}
</style>
</head> </head>
<body> <body>
<div id="container"></div> <div id="container"></div>
<script src="js/vd.js"></script> <script src="https://cdn.plyr.io/3.6.7/plyr.polyfilled.js"></script>
<script src="https://cdn.rawgit.com/video-dev/hls.js/18bb552/dist/hls.min.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script> <script>
customElements.define('plyr-video',
class PlyrVideo extends HTMLElement {
constructor() {
super();
this.videoElement = document.createElement('video');
this.videoElement.setAttribute('controls', 'true');
}
static get observedAttributes() { return []; }
connectedCallback() {
this.appendChild(this.videoElement)
const hls = new Hls();
hls.loadSource(this.getAttribute('src'));
hls.on(Hls.Events.MANIFEST_PARSED, (event, data) => {
// Transform available levels into an array of integers (height values).
const availableQualities = hls.levels.map((l) => l.height);
availableQualities.unshift(0);
// Initialize here
new Plyr(this.videoElement, {
quality: {
default: 0,
options: availableQualities,
// this ensures Plyr to use Hls to update quality level
forced: true,
onChange: updateQuality,
debug: true,
},
fullscreen: {
enabled: true,
fallback: true,
},
});
});
hls.on(Hls.Events.LEVEL_SWITCHED, function (event, data) {
var span = document.querySelector(".plyr__menu__container [data-plyr='quality'][value='0'] span");
if (hls.autoLevelEnabled) {
span.innerHTML = "Auto (" + hls.levels[data.level].height + "p)";
} else {
span.innerHTML = "Auto";
}
var x = document.querySelectorAll(".plyr__menu__container [data-plyr='settings'] span")[3];
if (x.innerHTML.startsWith("Auto") || x.innerHTML === "0p") {
x.innerHTML = span.innerHTML;
}
})
hls.attachMedia(this.videoElement);
function updateQuality(newQuality) {
if (newQuality === 0) {
hls.currentLevel = -1;
} else {
hls.levels.forEach((level, levelIndex) => {
if (level.height === newQuality) {
hls.currentLevel = levelIndex;
}
});
}
}
}
}
);
function isDarkMode(e) {
var darkMode = JSON.parse(localStorage.getItem('darkMode'));
if (darkMode === null) {
if (e === undefined) {
e = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
}
darkMode = e.matches === true;
}
return darkMode;
}
var app = Elm.Main.init({ var app = Elm.Main.init({
node: document.getElementById('container'), node: document.getElementById('container'),
flags: { width: window.innerWidth, height: window.innerHeight } flags: {
width: window.innerWidth,
height: window.innerHeight,
darkMode: isDarkMode(),
darkSetting: JSON.parse(localStorage.getItem('darkMode'))
}
}); });
var lastId, player; var lastId;
if (app.ports !== undefined && app.ports.registerVideo !== undefined) { if (app.ports !== undefined) {
app.ports.registerVideo.subscribe(function(args) { if (app.ports.setDarkMode !== undefined) {
var time = parseInt(args[2], 10) || undefined; app.ports.setDarkMode.subscribe(function(arg) {
if (arg === null) {
requestAnimationFrame(function() { localStorage.removeItem('darkMode');
if (args[0] !== lastId) { } else {
lastId = args[0]; localStorage.setItem('darkMode', arg);
player = vd.setup(args[0], {
v: args[1] + "/manifest.mpd",
t: parseInt(args[2], 10) || 0,
focus: true
});
} else if (time !== undefined ){
player.currentTime(time);
} }
app.ports.darkMode.send(isDarkMode());
}); });
}); }
} }
if (app.ports !== undefined && app.ports.eraseVideo !== undefined) { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
app.ports.eraseVideo.subscribe(function() { app.ports.darkMode.send(isDarkMode(e));
lastId = undefined; });
});
}
</script> </script>
</body> </body>
</html> </html>
+11 -2
View File
@@ -2,16 +2,25 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const VIDEO_DIR = "videos"; const VIDEO_DIR = "videos";
const DESCRIPTION_FILE = "description.json"; const DESCRIPTION_FILE = "description.json";
const INDEX_FILE = "index.json";
function isDirectory(path) {
return fs.lstatSync(path).isDirectory();
}
let info = []; let info = [];
for (let dir of fs.readdirSync(VIDEO_DIR)) { for (let dir of fs.readdirSync(VIDEO_DIR)) {
if (!isDirectory(path.join(VIDEO_DIR, dir))) {
continue;
}
let description = JSON.parse(fs.readFileSync(path.join(VIDEO_DIR, dir, DESCRIPTION_FILE))); let description = JSON.parse(fs.readFileSync(path.join(VIDEO_DIR, dir, DESCRIPTION_FILE)));
description.url = dir + "/"; description.url = dir + "/";
description.videos = []; description.videos = [];
for (let subdir of fs.readdirSync(path.join(VIDEO_DIR, dir))) { for (let subdir of fs.readdirSync(path.join(VIDEO_DIR, dir))) {
if (subdir === DESCRIPTION_FILE) { if (!isDirectory(path.join(VIDEO_DIR, dir, subdir))) {
continue; continue;
} }
@@ -24,5 +33,5 @@ for (let dir of fs.readdirSync(VIDEO_DIR)) {
info.push(description); info.push(description);
} }
fs.writeFileSync(path.join(VIDEO_DIR, 'index.json'), JSON.stringify(info)); fs.writeFileSync(path.join(VIDEO_DIR, INDEX_FILE), JSON.stringify(info));
+493 -380
View File
@@ -3,447 +3,560 @@ const vd = (function() {
let vd = {}; let vd = {};
function createEl(tagName = 'div', properties = {}, attributes = {}, content) { function createEl(tagName = 'div', properties = {}, attributes = {}, content) {
const el = document.createElement(tagName); const el = document.createElement(tagName);
Object.getOwnPropertyNames(properties).forEach(function(propName) { Object.getOwnPropertyNames(properties).forEach(function(propName) {
const val = properties[propName]; const val = properties[propName];
// See #2176 // See #2176
// We originally were accepting both properties and attributes in the // We originally were accepting both properties and attributes in the
// same object, but that doesn't work so well. // same object, but that doesn't work so well.
if (propName.indexOf('aria-') !== -1 || propName === 'role' || propName === 'type') { if (propName.indexOf('aria-') !== -1 || propName === 'role' || propName === 'type') {
log.warn(tsml`Setting attributes in the second argument of createEl() log.warn(tsml`Setting attributes in the second argument of createEl()
has been deprecated. Use the third argument instead. has been deprecated. Use the third argument instead.
createEl(type, properties, attributes). Attempting to set ${propName} to ${val}.`); createEl(type, properties, attributes). Attempting to set ${propName} to ${val}.`);
el.setAttribute(propName, val); el.setAttribute(propName, val);
// Handle textContent since it's not supported everywhere and we have a // Handle textContent since it's not supported everywhere and we have a
// method for it. // method for it.
} else if (propName === 'textContent') { } else if (propName === 'textContent') {
textContent(el, val); textContent(el, val);
} else { } else {
el[propName] = val; el[propName] = val;
}
});
return el;
}
function findPosition(el) {
let box;
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = box.left + scrollLeft - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = box.top + scrollTop - clientTop;
// Android sometimes returns slightly off decimal values, so need to round
return {
left: Math.round(left),
top: Math.round(top)
};
}
function getBoundingClientRect(el) {
if (el && el.getBoundingClientRect && el.parentNode) {
const rect = el.getBoundingClientRect();
const result = {};
['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(k => {
if (rect[k] !== undefined) {
result[k] = rect[k];
} }
}); });
if (!result.height) {
result.height = parseFloat(computedStyle(el, 'height'));
}
if (!result.width) {
result.width = parseFloat(computedStyle(el, 'width'));
}
return result;
}
}
function getPointerPosition(el, event) {
const position = {};
const box = findPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
return position;
}
let vjs = {};
vjs.Component = videojs.getComponent('Component');
vjs.Menu = videojs.getComponent('Menu');
vjs.MenuButton = videojs.getComponent('MenuButton');
vjs.MenuItem = videojs.getComponent('MenuItem');
class MenuButton extends vjs.MenuButton {
constructor(player, options) {
super(player, options);
this.updateLabel(options === undefined ? "" : options.label || "");
}
createEl() {
const el = super.createEl();
this.labelEl_ = createEl('div', {
className: 'vjs-playback-rate-value',
innerHTML: 'auto'
});
el.appendChild(this.labelEl_);
return el; return el;
} }
function findPosition(el) { dispose() {
let box; this.labelEl_ = null;
super.dispose();
if (el.getBoundingClientRect && el.parentNode) {
box = el.getBoundingClientRect();
}
if (!box) {
return {
left: 0,
top: 0
};
}
const docEl = document.documentElement;
const body = document.body;
const clientLeft = docEl.clientLeft || body.clientLeft || 0;
const scrollLeft = window.pageXOffset || body.scrollLeft;
const left = box.left + scrollLeft - clientLeft;
const clientTop = docEl.clientTop || body.clientTop || 0;
const scrollTop = window.pageYOffset || body.scrollTop;
const top = box.top + scrollTop - clientTop;
// Android sometimes returns slightly off decimal values, so need to round
return {
left: Math.round(left),
top: Math.round(top)
};
} }
function getBoundingClientRect(el) { buildCSSClass() {
if (el && el.getBoundingClientRect && el.parentNode) { return `vjs-playback-rate ${super.buildCSSClass()}`;
const rect = el.getBoundingClientRect(); }
const result = {};
['bottom', 'height', 'left', 'right', 'top', 'width'].forEach(k => { buildWrapperCSSClass() {
if (rect[k] !== undefined) { return `vjs-playback-rate ${super.buildWrapperCSSClass()}`;
result[k] = rect[k]; }
}
createMenu() {
const menu = new vjs.Menu(this.player());
return menu;
}
updateARIAAttributes() {
this.el().setAttribute('aria-valuenow', this.labelEl_.innerHTML);
}
updateLabel(newLabel) {
this.labelEl_.innerHTML = newLabel;
}
}
class ResolutionItem extends vjs.MenuItem {
constructor() {
super(...arguments);
this.representation = arguments[1].representation;
this.menuButton = arguments[1].menuButton;
this.label = arguments[1].label;
}
handleClick() {
if (this.representation === undefined) {
// Clicked on the auto button
this.player().tech({IWillNotUseThisInPlugins: true}).hls.representations().forEach(function(rep) {
rep.enabled(true);
}); });
if (!result.height) { } else {
result.height = parseFloat(computedStyle(el, 'height')); // Clicked on another button
} let self = this;
this.player().tech({IWillNotUseThisInPlugins: true}).hls.representations().forEach(function(rep) {
if (!result.width) { rep.enabled(rep.height === self.options_.representation.height);
result.width = parseFloat(computedStyle(el, 'width'));
}
return result;
}
}
function getPointerPosition(el, event) {
const position = {};
const box = findPosition(el);
const boxW = el.offsetWidth;
const boxH = el.offsetHeight;
const boxY = box.top;
const boxX = box.left;
let pageY = event.pageY;
let pageX = event.pageX;
if (event.changedTouches) {
pageX = event.changedTouches[0].pageX;
pageY = event.changedTouches[0].pageY;
}
position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
return position;
}
function setupRepresentations(menuButton, player) {
let hls = player.tech({IWillNotUseThisInPlugins: true}).hls;
if (hls === undefined) {
setTimeout(() => setupRepresentations(menuButton, player), 500);
return;
}
let representations = hls.representations();
representations.sort((a, b) => b.height - a.height);
menuButton.menu.items = [];
menuButton.menu.addAndRecordItem = function(item) {
this.addItem(item);
this.items.push(item);
}
menuButton.menu.addAndRecordItem(new MenuItem(player, { label: "auto", menuButton }));
for (let representation of representations) {
menuButton.menu.addAndRecordItem(new MenuItem(player, {
label: representation.height + "p",
representation,
menuButton
}));
}
}
let vjs = {};
vjs.Component = videojs.getComponent('Component');
vjs.Menu = videojs.getComponent('Menu');
vjs.MenuButton = videojs.getComponent('MenuButton');
vjs.MenuItem = videojs.getComponent('MenuItem');
class MenuButton extends vjs.MenuButton {
constructor(player, options) {
super(player, options);
this.updateLabel('auto');
}
createEl() {
const el = super.createEl();
this.labelEl_ = createEl('div', {
className: 'vjs-playback-rate-value',
innerHTML: 'auto'
}); });
el.appendChild(this.labelEl_);
return el;
}
dispose() {
this.labelEl_ = null;
super.dispose();
}
buildCSSClass() {
return `vjs-playback-rate ${super.buildCSSClass()}`;
}
buildWrapperCSSClass() {
return `vjs-playback-rate ${super.buildWrapperCSSClass()}`;
}
createMenu() {
const menu = new vjs.Menu(this.player());
return menu;
}
updateARIAAttributes() {
this.el().setAttribute('aria-valuenow', this.labelEl_.innerHTML);
}
updateLabel(newLabel) {
this.labelEl_.innerHTML = newLabel;
} }
this.menuButton.updateLabel(this.label);
}
}
class SpeedItem extends vjs.MenuItem {
constructor() {
super(...arguments);
this.label = arguments[1].label;
this.speed = arguments[1].speed;
this.menuButton = arguments[1].menuButton;
} }
class MenuItem extends vjs.MenuItem { handleClick() {
constructor() { this.player().playbackRate(this.speed);
super(...arguments); this.menuButton.updateLabel(this.label);
this.representation = arguments[1].representation; }
this.menuButton = arguments[1].menuButton; }
this.label = arguments[1].label;
}
handleClick() { class Thumbnail extends vjs.Component {
if (this.representation === undefined) { constructor() {
// Clicked on the auto button super(...arguments);
this.player().tech({IWillNotUseThisInPlugins: true}).hls.representations().forEach(function(rep) { this.thumbnails = arguments[1].thumbnails;
rep.enabled(true); this.width = arguments[1].width;
}); this.height = arguments[1].height;
}
} else { createEl() {
// Clicked on another button let el = super.createEl('img', {
let self = this; src: this.options_.thumbnails[0],
this.player().tech({IWillNotUseThisInPlugins: true}).hls.representations().forEach(function(rep) { width: '0px',
rep.enabled(rep.height === self.options_.representation.height); });
});
el.style.position = 'absolute';
el.style.left = '0px';
el.style.top = -this.options_.height + "px";
el.style.border = "solid";
el.style.borderColor = "black";
el.style.borderWidth = "1px";
return el;
}
update(ratio) {
this.el().src = this.options_.thumbnails[Math.round(100 * ratio)];
}
}
function createRepresentationButtons(player, menuButton = undefined) {
if (menuButton === undefined) {
menuButton = new MenuButton(player);
menuButton.updateLabel("auto");
}
let hls = player.tech({IWillNotUseThisInPlugins: true}).hls;
if (hls === undefined || hls.representations === undefined) {
setTimeout(() => createRepresentationButtons(player, menuButton), 500);
return menuButton;
}
let representations = hls.representations();
representations.sort((a, b) => b.height - a.height);
menuButton.menu.items = [];
menuButton.menu.addAndRecordItem = function(item) {
this.addItem(item);
this.items.push(item);
}
menuButton.menu.addAndRecordItem(new ResolutionItem(player, { label: "auto", menuButton }));
for (let representation of representations) {
menuButton.menu.addAndRecordItem(new ResolutionItem(player, {
label: representation.height + "p",
representation,
menuButton
}));
}
return menuButton;
}
function createSpeedButtons(player) {
let menuButton = new MenuButton(player);
let speeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
menuButton.updateLabel('x1');
menuButton.menu.items = [];
menuButton.menu.addAndRecordItem = function(item) {
this.addItem(item);
this.items.push(item);
}
for (let speed of speeds) {
menuButton.menu.addAndRecordItem(new SpeedItem(player, {
label: "x" + speed,
menuButton,
speed,
}));
}
return menuButton;
}
vd.parseTime = function(t) {
let parsed = 1 * t;
if (!isNaN(parsed)) {
return parsed;
}
// Compute the split
let split = t.split("h");
let hours;
let minutes;
let seconds;
switch (split.length) {
case 1:
hours = 0;
split = split[0].split("m");
break;
case 2:
hours = 1 * split[0];
if (isNaN(hours)) {
return NaN;
} }
this.menuButton.updateLabel(this.label); split = split[1].split("m");
} break;
default:
return NaN;
} }
class Thumbnail extends vjs.Component { switch (split.length) {
constructor() { case 1:
super(...arguments); minutes = 0;
this.thumbnails = arguments[1].thumbnails; split = split[0].split("s");
this.width = arguments[1].width; break;
this.height = arguments[1].height; case 2:
} minutes = 1 * split[0];
if (isNaN(minutes)) {
createEl() { return NaN;
let el = super.createEl('img', { }
src: this.options_.thumbnails[0], split = split[1].split("s");
width: '0px', break;
}); default:
return NaN;
el.style.position = 'absolute';
el.style.left = '0px';
el.style.top = -this.options_.height + "px";
el.style.border = "solid";
el.style.borderColor = "black";
el.style.borderWidth = "1px";
return el;
}
update(ratio) {
this.el().src = this.options_.thumbnails[Math.round(100 * ratio)];
}
} }
seconds = 1 * split[0];
if ((split.length !== 1 && (! (split.length == 2 && split[1] === ""))) || isNaN(seconds)) {
return NaN;
}
vd.setup = function(video, args) { return 3600 * hours + 60 * minutes + seconds;
}
let src; vd.setup = function(video, args) {
if (typeof video === 'string' || video instanceof String) { let src;
video = document.getElementById(video);
}
if (video === undefined) { if (typeof video === 'string' || video instanceof String) {
throw new Error("video element or ID invalid"); video = document.getElementById(video);
} }
if (typeof args === "string" || args instanceof String) { if (video === undefined) {
src = args; throw new Error("video element or ID invalid");
} }
if (typeof args === "object") { if (typeof args === "string" || args instanceof String) {
src = args.v; src = args;
} }
if (src === undefined) { if (typeof args === "object") {
throw new Error("video src is undefined"); src = args.v;
} }
let player = videojs(video); if (src === undefined) {
throw new Error("video src is undefined");
}
let player = videojs(video);
if (src.endsWith('.mpd')) {
player.src({ player.src({
src, src,
type: 'application/dash+xml' type: 'application/dash+xml'
}); });
} else {
player.src({
src,
});
}
if (args.focus === true) { if (args.focus === true) {
player.focus(); player.focus();
}
player._oldRequestFullscreen = player.requestFullscreen;
player.requestFullscreen = function() {
var player = document.getElementById(this.id());
if (player === null) {
return;
} }
player = typeof player.player === "function" ? player.player() : player.player;
player._oldRequestFullscreen(...arguments);
setTimeout(() => {
if (screen.orientation) {
screen.orientation.lock("landscape")
}
}, 1000);
};
player._oldRequestFullscreen = player.requestFullscreen; player._oldExitFullscreen = player.exitFullscreen;
player.requestFullscreen = function() { player.exitFullscreen = function() {
var player = document.getElementById(this.id()); var player = document.getElementById(this.id());
if (player === null) { if (player === null) {
return;
}
player = typeof player.player === "function" ? player.player() : player.player;
player._oldExitFullscreen(...arguments);
setTimeout(() => {
if (screen.orientation) {
screen.orientation.unlock();
}
}, 1000);
};
if (args.t !== undefined) {
let time = vd.parseTime(args.t);
if (!isNaN(time)) {
player.currentTime(time);
}
}
if (player.getAttribute('disable-shortcuts') == undefined) {
player.el().addEventListener('keydown', (e) => {
if (e.ctrlKey || e.shiftKey || e.altKey) {
return; return;
} }
player = typeof player.player === "function" ? player.player() : player.player;
player._oldRequestFullscreen(...arguments);
setTimeout(() => {
if (screen.orientation) {
screen.orientation.lock("landscape")
}
}, 1000);
};
player._oldExitFullscreen = player.exitFullscreen; let p = document.getElementById(player.id());
player.exitFullscreen = function() { if (p === null) {
var player = document.getElementById(this.id());
if (player === null) {
return; return;
} }
player = typeof player.player === "function" ? player.player() : player.player; p = typeof p.player === "function" ? p.player() : p.player;
player._oldExitFullscreen(...arguments);
setTimeout(() => {
if (screen.orientation) {
screen.orientation.unlock();
}
}, 1000);
};
if (args.t !== undefined) { switch (e.keyCode) {
let time = parseFloat(args.t); case 37: e.preventDefault(); p.currentTime(p.currentTime() - 5); break;
if (!isNaN(time)) { case 39: e.preventDefault(); p.currentTime(p.currentTime() + 5); break;
player.currentTime(time); case 32: e.preventDefault(); if (p.paused()) p.play(); else p.pause(); break;
case 40: e.preventDefault(); p.volume(p.volume() - 0.1); break;
case 38: e.preventDefault(); p.volume(p.volume() + 0.1); break;
} }
} }, true);
document.body.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
if (player.getAttribute('disable-shortcuts') == undefined) { let p = document.getElementById(player.id());
player.el().addEventListener('keydown', (e) => { if (p === null) {
if (e.ctrlKey || e.shiftKey || e.altKey) { return;
return; }
} p = typeof p.player === "function" ? p.player() : p.player;
switch (e.keyCode) { switch (e.keyCode) {
case 37: e.preventDefault(); player.currentTime(player.currentTime() - 5); break; case 74: e.preventDefault(); p.currentTime(p.currentTime() - 10); break; // J -> -10s
case 39: e.preventDefault(); player.currentTime(player.currentTime() + 5); break; case 76: e.preventDefault(); p.currentTime(p.currentTime() + 10); break; // L -> +10s
case 32: e.preventDefault(); if (player.paused()) player.play(); else player.pause(); break; case 75: e.preventDefault(); if (p.paused()) p.play(); else p.pause(); break; // K -> play/pause
case 40: e.preventDefault(); player.volume(player.volume() - 0.1); break; case 77: e.preventDefault(); p.muted(!p.muted()); break; // M -> mute
case 38: e.preventDefault(); player.volume(player.volume() + 0.1); break;
}
}, true);
document.body.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
switch (e.keyCode) {
case 74: e.preventDefault(); player.currentTime(player.currentTime() - 10); break; // J -> -10s
case 76: e.preventDefault(); player.currentTime(player.currentTime() + 10); break; // L -> +10s
case 75: e.preventDefault(); if (player.paused()) player.play(); else player.pause(); break; // K -> play/pause
case 77: e.preventDefault(); player.muted(!player.muted()); break; // M -> mute
// F -> toggle fullscreen // F -> toggle fullscreen
case 70: case 70:
e.preventDefault(); e.preventDefault();
let p = document.getElementById(player.id());
if (p === null) { if (p.isFullscreen()) {
break; p.exitFullscreen();
} } else {
p = typeof p.player === "function" ? p.player() : p.player; p.requestFullscreen();
if (p.isFullscreen()) { }
p.exitFullscreen(); break;
} else {
p.requestFullscreen();
}
break;
// Seek shortcuts // Seek shortcuts
case 48: case 96: e.preventDefault(); player.currentTime(0); break; case 48: case 96: e.preventDefault(); p.currentTime(0); break;
case 49: case 97: e.preventDefault(); player.currentTime( player.duration() / 10); break; case 49: case 97: e.preventDefault(); p.currentTime( p.duration() / 10); break;
case 50: case 98: e.preventDefault(); player.currentTime(2 * player.duration() / 10); break; case 50: case 98: e.preventDefault(); p.currentTime(2 * p.duration() / 10); break;
case 51: case 99: e.preventDefault(); player.currentTime(3 * player.duration() / 10); break; case 51: case 99: e.preventDefault(); p.currentTime(3 * p.duration() / 10); break;
case 52: case 100: e.preventDefault(); player.currentTime(4 * player.duration() / 10); break; case 52: case 100: e.preventDefault(); p.currentTime(4 * p.duration() / 10); break;
case 53: case 101: e.preventDefault(); player.currentTime(5 * player.duration() / 10); break; case 53: case 101: e.preventDefault(); p.currentTime(5 * p.duration() / 10); break;
case 54: case 102: e.preventDefault(); player.currentTime(6 * player.duration() / 10); break; case 54: case 102: e.preventDefault(); p.currentTime(6 * p.duration() / 10); break;
case 55: case 103: e.preventDefault(); player.currentTime(7 * player.duration() / 10); break; case 55: case 103: e.preventDefault(); p.currentTime(7 * p.duration() / 10); break;
case 56: case 104: e.preventDefault(); player.currentTime(8 * player.duration() / 10); break; case 56: case 104: e.preventDefault(); p.currentTime(8 * p.duration() / 10); break;
case 57: case 105: e.preventDefault(); player.currentTime(9 * player.duration() / 10); break; case 57: case 105: e.preventDefault(); p.currentTime(9 * p.duration() / 10); break;
}
}, true);
}
let root = src.split('/').slice(0, -1).join('/');
if (root !== "") {
root += "/";
}
if (player.getAttribute('disable-thumbnails') == undefined) {
let thumbnails = [];
const MAX_INDEX = 100;
for (let i = 0; i <= MAX_INDEX; i++) {
thumbnails.push(root + "miniature-" + ("" + i).padStart(3, "0") + ".png");
} }
}, true);
let thumbnail = new Thumbnail(player, {
thumbnails,
width: 192 * 0.75,
height: 108 * 0.75,
});
player.controlBar.progressControl.addChild(thumbnail);
player.controlBar.progressControl.el().addEventListener('mouseenter', e => {
thumbnail.el().width = thumbnail.width;
thumbnail.update(e.offsetX / e.target.offsetWidth);
});
player.controlBar.progressControl.el().addEventListener('mouseleave', e => {
thumbnail.el().width = 0;
});
player.controlBar.progressControl.on('mousemove', (event) => {
const seekBar = player.controlBar.progressControl.seekBar;
const seekBarEl = seekBar.el();
const seekBarRect = getBoundingClientRect(seekBarEl);
let seekBarPoint = getPointerPosition(seekBarEl, event).x;
seekBarPoint = Math.max(0, Math.min(1, seekBarPoint));
thumbnail.update(seekBarPoint);
thumbnail.el().style.left = (seekBarPoint * seekBarRect.width - thumbnail.width / 2) + "px";
});
}
let controlBar = player.getChild('controlBar');
let fullscreenButton = controlBar.children()[controlBar.children().length - 1];
controlBar.removeChild(fullscreenButton);
let menuButton = new MenuButton(player);
controlBar.addChild(menuButton, {});
controlBar.addChild(fullscreenButton, {});
setupRepresentations(menuButton, player);
// videojs.Html5DashJS.hook('beforeinitialize', (p, mp) => setupRepresentations(menuButton, p, mp));
window.player = player;
if (video.getAttribute('autoplay') != undefined) {
player.play();
}
return player;
} }
for (let element of document.getElementsByTagName('video')) { let root = src.split('/').slice(0, -1).join('/');
let src = element.getAttribute('data-dash-src'); if (root !== "") {
if (src != undefined) { root += "/";
vd.setup(element, src);
}
} }
if (player.getAttribute('disable-thumbnails') == undefined) {
player.poster(root + "poster.png");
let thumbnails = [];
const MAX_INDEX = 100;
for (let i = 0; i <= MAX_INDEX; i++) {
thumbnails.push(root + "miniature-" + ("" + i).padStart(3, "0") + ".png");
}
let thumbnail = new Thumbnail(player, {
thumbnails,
width: 192 * 0.75,
height: 108 * 0.75,
});
player.controlBar.progressControl.addChild(thumbnail);
player.controlBar.progressControl.el().addEventListener('mouseenter', e => {
thumbnail.el().width = thumbnail.width;
thumbnail.update(e.offsetX / e.target.offsetWidth);
});
player.controlBar.progressControl.el().addEventListener('mouseleave', e => {
thumbnail.el().width = 0;
});
player.controlBar.progressControl.on('mousemove', (event) => {
const seekBar = player.controlBar.progressControl.seekBar;
const seekBarEl = seekBar.el();
const seekBarRect = getBoundingClientRect(seekBarEl);
let seekBarPoint = getPointerPosition(seekBarEl, event).x;
seekBarPoint = Math.max(0, Math.min(1, seekBarPoint));
thumbnail.update(seekBarPoint);
thumbnail.el().style.left = (seekBarPoint * seekBarRect.width - thumbnail.width / 2) + "px";
});
}
let controlBar = player.getChild('controlBar');
let fullscreenButton = controlBar.children()[controlBar.children().length - 1];
controlBar.removeChild(fullscreenButton);
let menuButton = createRepresentationButtons(player);
if (player.getAttribute('disable-speed') == undefined) {
let speedButton = createSpeedButtons(player);
controlBar.addChild(speedButton, {});
}
controlBar.addChild(menuButton, {});
controlBar.addChild(fullscreenButton, {});
window.player = player;
if (video.getAttribute('autoplay') != undefined) {
player.play();
}
return player;
}
for (let element of document.getElementsByTagName('video')) {
let src = element.getAttribute('data-dash-src');
if (src != undefined) {
vd.setup(element, src);
}
}
return vd; return vd;
})(); })();
+36 -10
View File
@@ -1,4 +1,4 @@
module Colors exposing (blackFont, greyBackground, greyFont, primary, primaryOver, selected, white) module Colors exposing (..)
import Element import Element
@@ -18,21 +18,47 @@ white =
Element.rgb255 255 255 255 Element.rgb255 255 255 255
red : Element.Color
red =
Element.rgb255 255 0 0
greyBackground : Element.Color greyBackground : Element.Color
greyBackground = greyBackground =
Element.rgba255 0 0 0 0.7 Element.rgba255 0 0 0 0.7
blackFont : Element.Color background : Bool -> Element.Color
blackFont = background darkMode =
Element.rgb255 54 54 54 if darkMode then
Element.rgb255 47 49 54
else
Element.rgb255 245 245 245
greyFont : Element.Color font : Bool -> Element.Color
greyFont = font darkMode =
Element.rgb255 128 128 128 if darkMode then
Element.rgb255 245 245 245
else
Element.rgb255 54 57 63
selected : Element.Color detailFont : Bool -> Element.Color
selected = detailFont darkMode =
Element.rgb255 223 233 250 if darkMode then
Element.rgb255 160 160 160
else
Element.rgb255 128 128 128
selected : Bool -> Element.Color
selected darkMode =
if darkMode then
Element.rgb255 64 68 75
else
Element.rgb255 223 233 250
+207 -108
View File
@@ -1,50 +1,78 @@
module Core exposing (FullModel(..), Model, Msg(..), Page(..), init, subscriptions, update) module Core exposing (Model, Msg(..), Page(..), init, subscriptions, update)
import Browser
import Browser.Events as Events import Browser.Events as Events
import Browser.Navigation as Nav import Browser.Navigation as Nav
import Dict exposing (Dict) import Dict exposing (Dict)
import Element import Element
import Hover exposing (Hover)
import Http import Http
import Ports import Ports
import Task
import Time import Time
import TimeZone
import Twitch import Twitch
import Url import Url
type FullModel
= Unloaded Element.Device Url.Url Nav.Key
| Loaded Model
type alias Model = type alias Model =
{ playlists : List Twitch.Playlist { playlists : List Twitch.Playlist
, zone : Time.Zone , zone : Time.Zone
, page : Page , page : Page
, key : Nav.Key , key : Nav.Key
, device : Element.Device , device : Element.Device
, time : Time.Posix
, currentDate : Time.Posix
, url : Url.Url
, darkMode : Bool
, darkSetting : Maybe Bool
} }
type Page type Page
= Home = Home (Maybe (Hover Twitch.Playlist))
| Playlist Twitch.Playlist | Playlist Twitch.Playlist (Maybe (Hover Twitch.Video))
| Video Twitch.Playlist Twitch.Video | Video Twitch.Playlist Twitch.Video (Maybe (Hover Twitch.Video))
type Msg type Msg
= Noop = Noop
| PlaylistsReceived ( List Twitch.Playlist, Time.Zone ) | PlaylistsReceived (List Twitch.Playlist)
| HomeClicked | HomeClicked
| PlaylistClicked Twitch.Playlist | PlaylistClicked Twitch.Playlist
| VideoClicked Twitch.Playlist Twitch.Video | VideoClicked Twitch.Playlist Twitch.Video
| UrlReceived Url.Url | UrlReceived Url.Url
| UrlRequested Browser.UrlRequest
| SizeReceived Int Int | SizeReceived Int Int
| TimeZoneReceivedResult (Result TimeZone.Error ( String, Time.Zone ))
| TimeZoneReceived Time.Zone
| HoverPlaylist Twitch.Playlist
| HoverVideo Twitch.Video
| Unhover
| TimeReceived Time.Posix
| CurrentDateReceived Time.Posix
| DarkMode Bool
| DarkModeClicked
init : { width : Int, height : Int } -> Url.Url -> Nav.Key -> ( FullModel, Cmd Msg ) init : { width : Int, height : Int, darkMode : Bool, darkSetting : Maybe Bool } -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init { width, height } url key = init { width, height, darkMode, darkSetting } url key =
( Unloaded (Element.classifyDevice { width = width, height = height }) url key ( Model
, Twitch.fetchPlaylists resultToMsg []
Time.utc
(Home Nothing)
key
(Element.classifyDevice { width = width, height = height })
(Time.millisToPosix 0)
(Time.millisToPosix 0)
url
darkMode
darkSetting
, Cmd.batch
[ Task.attempt TimeZoneReceivedResult TimeZone.getZone
, Task.perform CurrentDateReceived Time.now
, Twitch.fetchPlaylists resultToMsg
]
) )
@@ -52,122 +80,180 @@ resultToMsg : Result Http.Error (List Twitch.Playlist) -> Msg
resultToMsg result = resultToMsg result =
case result of case result of
Ok o -> Ok o ->
PlaylistsReceived ( o, Time.utc ) PlaylistsReceived o
Err _ -> Err _ ->
Noop Noop
subscriptions : FullModel -> Sub Msg subscriptions : Model -> Sub Msg
subscriptions _ = subscriptions _ =
Events.onResize (\w h -> SizeReceived w h) Sub.batch
[ Events.onResize (\w h -> SizeReceived w h)
, Time.every 200 TimeReceived
, Ports.darkMode DarkMode
]
update : Msg -> FullModel -> ( FullModel, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = update msg model =
case ( msg, model ) of case msg of
( Noop, _ ) -> Noop ->
( model, Cmd.none ) ( model, Cmd.none )
( SizeReceived w h, Loaded m ) -> TimeZoneReceived z ->
( Loaded { m | device = Element.classifyDevice { width = w, height = h } } ( { model | zone = z }, Cmd.none )
TimeZoneReceivedResult (Ok ( _, zone )) ->
( { model | zone = zone }, Cmd.none )
TimeZoneReceivedResult (Err _) ->
( model, Task.perform TimeZoneReceived Time.here )
TimeReceived p ->
( { model | time = p }, Cmd.none )
CurrentDateReceived d ->
( { model | currentDate = d }, Cmd.none )
HoverPlaylist hover ->
case model.page of
Home Nothing ->
( { model | page = Home (Just (Hover.hover hover model.time)) }, Cmd.none )
_ ->
( model, Cmd.none )
HoverVideo hover ->
case model.page of
Playlist p Nothing ->
( { model | page = Playlist p (Just (Hover.hover hover model.time)) }, Cmd.none )
Video p v Nothing ->
( { model | page = Video p v (Just (Hover.hover v model.time)) }, Cmd.none )
_ ->
( model, Cmd.none )
Unhover ->
case model.page of
Home _ ->
( { model | page = Home Nothing }, Cmd.none )
Playlist p _ ->
( { model | page = Playlist p Nothing }, Cmd.none )
Video p v _ ->
( { model | page = Video p v Nothing }, Cmd.none )
SizeReceived w h ->
( { model | device = Element.classifyDevice { width = w, height = h } }
, Cmd.none , Cmd.none
) )
( PlaylistsReceived ( playlists, zone ), Unloaded device url key ) -> PlaylistsReceived playlists ->
update update
(UrlReceived url) (UrlReceived model.url)
(Loaded { key = key, playlists = playlists, zone = zone, page = Home, device = device }) { model | playlists = playlists, page = Home Nothing }
( HomeClicked, Loaded m ) -> HomeClicked ->
( model ( model
, Nav.pushUrl m.key "#" , Nav.pushUrl model.key "#"
) )
( PlaylistClicked playlist, Loaded m ) -> DarkModeClicked ->
( model
, Nav.pushUrl m.key ("#" ++ playlist.url)
)
( VideoClicked playlist video, Loaded m ) ->
( model
, Nav.pushUrl m.key ("#" ++ playlist.url ++ video.url)
)
( UrlReceived url, Loaded m ) ->
let let
splits = next =
String.split "?" (Maybe.withDefault "" url.fragment) nextDarkSetting model.darkSetting
( split, args ) =
case splits of
h1 :: h2 :: _ ->
( String.split "/" h1, parseQueryString h2 )
h1 :: _ ->
( String.split "/" h1, Dict.empty )
_ ->
( [], Dict.empty )
time =
case Maybe.map String.toInt (Dict.get "t" args) of
Just (Just 0) ->
Nothing
Just (Just t) ->
Just t
_ ->
Nothing
( playlistName, videoName ) =
case split of
p :: v :: _ ->
( Just (p ++ "/"), Just (v ++ "/") )
p :: _ ->
( Just (p ++ "/"), Nothing )
_ ->
( Nothing, Nothing )
playlist =
List.head (List.filter (\x -> Just x.url == playlistName) m.playlists)
video =
case playlist of
Just p ->
List.head (List.filter (\x -> Just x.url == videoName) p.videos)
_ ->
Nothing
( page, cmd ) =
case ( playlist, video ) of
( Just p, Just v ) ->
( Video p v
, Ports.registerVideo ( Twitch.videoId v, "videos/" ++ p.url ++ v.url, time )
)
( Just p, Nothing ) ->
( Playlist p, Cmd.none )
_ ->
( Home, Cmd.none )
extraCmd =
case page of
Video _ _ ->
Cmd.none
_ ->
Ports.eraseVideo ()
in in
( Loaded { m | page = page }, Cmd.batch [ cmd, extraCmd ] ) ( { model | darkSetting = next }, Ports.setDarkMode next )
_ -> PlaylistClicked playlist ->
( model, Cmd.none ) ( model
, Nav.pushUrl model.key ("#" ++ playlist.url)
)
VideoClicked playlist video ->
( model
, Nav.pushUrl model.key ("#" ++ playlist.url ++ video.url)
)
UrlReceived url ->
if List.isEmpty model.playlists then
( { model | url = url }, Cmd.none )
else
let
splits =
String.split "?" (Maybe.withDefault "" url.fragment)
( split, args ) =
case splits of
h1 :: h2 :: _ ->
( String.split "/" h1, parseQueryString h2 )
h1 :: _ ->
( String.split "/" h1, Dict.empty )
_ ->
( [], Dict.empty )
time =
case Dict.get "t" args of
Just "0" ->
Nothing
Just t ->
Just t
_ ->
Nothing
( playlistName, videoName ) =
case split of
p :: v :: _ ->
( Just (p ++ "/"), Just (v ++ "/") )
p :: _ ->
( Just (p ++ "/"), Nothing )
_ ->
( Nothing, Nothing )
playlist =
List.head (List.filter (\x -> Just x.url == playlistName) model.playlists)
video =
case playlist of
Just p ->
List.head (List.filter (\x -> Just x.url == videoName) p.videos)
_ ->
Nothing
page =
case ( playlist, video ) of
( Just p, Just v ) ->
Video p v Nothing
( Just p, Nothing ) ->
Playlist p Nothing
_ ->
Home Nothing
in
( { model | page = page }, Cmd.none )
UrlRequested u ->
case u of
Browser.Internal url ->
( model, Nav.pushUrl model.key (Url.toString url) )
Browser.External s ->
( model, Nav.load s )
DarkMode dark ->
( { model | darkMode = dark }, Cmd.none )
splitter : String -> Maybe ( String, String ) splitter : String -> Maybe ( String, String )
@@ -183,3 +269,16 @@ splitter input =
parseQueryString : String -> Dict String String parseQueryString : String -> Dict String String
parseQueryString input = parseQueryString input =
Dict.fromList (List.filterMap splitter (String.split "&" input)) Dict.fromList (List.filterMap splitter (String.split "&" input))
nextDarkSetting : Maybe Bool -> Maybe Bool
nextDarkSetting current =
case current of
Nothing ->
Just True
Just True ->
Just False
Just False ->
Nothing
+14
View File
@@ -0,0 +1,14 @@
module Hover exposing (..)
import Time
type alias Hover a =
{ element : a
, time : Time.Posix
}
hover : a -> Time.Posix -> Hover a
hover element time =
{ element = element, time = time }
+2 -2
View File
@@ -5,7 +5,7 @@ import Core
import Views import Views
main : Program { width : Int, height : Int } Core.FullModel Core.Msg main : Program { width : Int, height : Int, darkMode : Bool, darkSetting : Maybe Bool } Core.Model Core.Msg
main = main =
Browser.application Browser.application
{ init = Core.init { init = Core.init
@@ -13,5 +13,5 @@ main =
, view = Views.view , view = Views.view
, subscriptions = Core.subscriptions , subscriptions = Core.subscriptions
, onUrlChange = Core.UrlReceived , onUrlChange = Core.UrlReceived
, onUrlRequest = \_ -> Core.Noop , onUrlRequest = Core.UrlRequested
} }
+8 -2
View File
@@ -1,7 +1,13 @@
port module Ports exposing (eraseVideo, registerVideo) port module Ports exposing (darkMode, eraseVideo, registerVideo, setDarkMode)
port registerVideo : ( String, String, Maybe Int ) -> Cmd msg port registerVideo : ( String, String, Maybe String ) -> Cmd msg
port eraseVideo : () -> Cmd msg port eraseVideo : () -> Cmd msg
port setDarkMode : Maybe Bool -> Cmd msg
port darkMode : (Bool -> msg) -> Sub msg
+71 -7
View File
@@ -3,12 +3,14 @@ module Twitch exposing
, Video , Video
, decodePlaylists , decodePlaylists
, fetchPlaylists , fetchPlaylists
, playlistDate
, playlistMiniatureUrl , playlistMiniatureUrl
, videoId , videoId
, videoMiniatureUrl , videoMiniatureUrl
, videoName , videoName
) )
import Hover exposing (Hover)
import Http import Http
import Iso8601 import Iso8601
import Json.Decode as Decode import Json.Decode as Decode
@@ -49,7 +51,32 @@ decodePlaylist =
decodePlaylists : Decode.Decoder (List Playlist) decodePlaylists : Decode.Decoder (List Playlist)
decodePlaylists = decodePlaylists =
Decode.map (List.sortBy .url >> List.reverse) (Decode.list decodePlaylist) Decode.map (sortPlaylists >> List.reverse) (Decode.list decodePlaylist)
mostRecentVideo : List Video -> Maybe Time.Posix
mostRecentVideo videos =
case ( videos, List.map .date videos ) of
( _, (Just t) :: _ ) ->
Just t
( _ :: t, Nothing :: _ ) ->
mostRecentVideo t
_ ->
Nothing
playlistDate : Playlist -> Int
playlistDate playlist =
mostRecentVideo playlist.videos
|> Maybe.map Time.posixToMillis
|> Maybe.withDefault 0
sortPlaylists : List Playlist -> List Playlist
sortPlaylists list =
List.sortBy playlistDate list
fetchPlaylists : (Result Http.Error (List Playlist) -> msg) -> Cmd msg fetchPlaylists : (Result Http.Error (List Playlist) -> msg) -> Cmd msg
@@ -70,9 +97,22 @@ videoId video =
String.dropLeft 1 video.url |> String.replace "/" "-" String.dropLeft 1 video.url |> String.replace "/" "-"
playlistMiniatureUrl : Playlist -> String playlistMiniatureUrl : Time.Posix -> Maybe (Hover Playlist) -> Playlist -> String
playlistMiniatureUrl playlist = playlistMiniatureUrl currentTime hover playlist =
case List.head (List.reverse playlist.videos) of let
skip =
case hover of
Nothing ->
0
Just { element, time } ->
if element == playlist then
modBy (List.length playlist.videos) ((Time.posixToMillis currentTime - Time.posixToMillis time) // 1000)
else
0
in
case List.head (List.drop skip playlist.videos) of
Just v -> Just v ->
"videos/" ++ playlist.url ++ v.url ++ "miniature-050.png" "videos/" ++ playlist.url ++ v.url ++ "miniature-050.png"
@@ -80,6 +120,30 @@ playlistMiniatureUrl playlist =
"" ""
videoMiniatureUrl : Playlist -> Video -> String videoMiniatureUrl : Time.Posix -> Maybe (Hover Video) -> Playlist -> Video -> String
videoMiniatureUrl playlist video = videoMiniatureUrl currentTime hover playlist video =
"videos/" ++ playlist.url ++ video.url ++ "miniature-050.png" let
index =
case hover of
Nothing ->
5
Just { element, time } ->
if element == video then
modBy 11 ((Time.posixToMillis currentTime - Time.posixToMillis time) // 1000)
else
5
indexString =
case index of
10 ->
"100"
0 ->
"000"
n ->
"0" ++ String.fromInt n ++ "0"
in
"videos/" ++ playlist.url ++ video.url ++ "miniature-" ++ indexString ++ ".png"
+12
View File
@@ -0,0 +1,12 @@
module Ui exposing (link)
import Element exposing (Element)
import Element.Input as Input
link : List (Element.Attribute msg) -> { label : Element msg, url : String } -> Element msg
link attr data =
Input.button attr
{ label = Element.link [ Element.width Element.fill, Element.height Element.fill ] data
, onPress = Nothing
}
+181 -106
View File
@@ -7,106 +7,128 @@ import Core
import Element exposing (Element) import Element exposing (Element)
import Element.Background as Background import Element.Background as Background
import Element.Border as Border import Element.Border as Border
import Element.Events as Events
import Element.Font as Font import Element.Font as Font
import Element.Input as Input import Element.Input as Input
import Element.Keyed as Keyed import Element.Keyed as Keyed
import Hover exposing (Hover)
import Html import Html
import Html.Attributes import Html.Attributes
import Json.Encode as Encode import Json.Encode as Encode
import Time import Time
import TimeUtils import TimeUtils
import Twitch import Twitch
import Ui
view : Core.FullModel -> Browser.Document Core.Msg view : Core.Model -> Browser.Document Core.Msg
view model = view model =
let let
element = element =
case model of case model.playlists of
Core.Unloaded _ _ _ -> [] ->
Element.el [ Element.padding 10, Element.centerX ] spinner Element.el [ Element.padding 10, Element.centerX ] spinner
Core.Loaded m -> _ ->
viewContent m viewContent model
in in
{ title = title model { title = title model
, body = , body =
[ Element.layout [ Element.layout
[ Font.color Colors.blackFont [ Font.color (Colors.font model.darkMode)
, Background.color (Colors.background model.darkMode)
, Font.size Consts.normalFontSize , Font.size Consts.normalFontSize
, Font.family [ Font.typeface "Cantarell" ] , Font.family [ Font.typeface "Cantarell" ]
] ]
(Element.column (Element.column
[ Element.width Element.fill, Element.height Element.fill ] [ Element.width Element.fill, Element.height Element.fill ]
[ topBar, element ] [ topBar model.darkSetting, element ]
) )
] ]
} }
title : Core.FullModel -> String title : Core.Model -> String
title model = title model =
case model of case model.page of
Core.Unloaded _ _ _ -> Core.Home _ ->
Consts.url Consts.url
Core.Loaded m -> Core.Playlist p _ ->
case m.page of Consts.url ++ " - " ++ p.name
Core.Home ->
Consts.url
Core.Playlist p -> Core.Video p v _ ->
Consts.url ++ " - " ++ p.name Consts.url ++ " - " ++ p.name ++ " - " ++ v.name
Core.Video p v ->
Consts.url ++ " - " ++ p.name ++ " - " ++ v.name
viewContent : Core.Model -> Element Core.Msg viewContent : Core.Model -> Element Core.Msg
viewContent model = viewContent model =
case model.page of case model.page of
Core.Home -> Core.Home hover ->
playlistsView model.device model.playlists playlistsView model.device model.playlists model.currentDate model.time hover
Core.Playlist playlist -> Core.Playlist playlist hover ->
videoMiniaturesView model.device model.zone playlist videoMiniaturesView model.darkMode model.device model.zone model.currentDate model.time hover playlist
Core.Video playlist video -> Core.Video playlist video hover ->
videoView model.device model.zone playlist video videoView model.darkMode model.device model.zone model.currentDate model.time hover playlist video
topBar : Element Core.Msg topBar : Maybe Bool -> Element Core.Msg
topBar = topBar darkSetting =
Element.row Element.row
[ Element.width Element.fill [ Element.width Element.fill
, Background.color Colors.primary , Background.color Colors.primary
, Font.color Colors.white , Font.color Colors.white
, Font.size Consts.homeFontSize , Font.size Consts.homeFontSize
] ]
[ homeButton ] [ homeButton, mode darkSetting ]
mode : Maybe Bool -> Element Core.Msg
mode current =
let
( label, icon ) =
case current of
Nothing ->
( "Par défaut", "🌓" )
Just True ->
( "Mode nuit", "🌑" )
Just False ->
( "Mode jour", "🌕" )
in
Input.button
[ Element.height Element.fill, Element.alignRight, Element.padding 10 ]
{ label =
Element.row [ Element.spacing 5 ]
[ Element.el [ Font.size Consts.titleFontSize ] (Element.text label)
, Element.el [ Font.size Consts.homeFontSize ] (Element.text icon)
]
, onPress = Just Core.DarkModeClicked
}
homeButton : Element Core.Msg homeButton : Element Core.Msg
homeButton = homeButton =
Input.button Ui.link
[ Element.padding Consts.homePadding [ Element.height Element.fill
, Element.height Element.fill
, Element.mouseOver [ Background.color Colors.primaryOver ]
, Font.bold , Font.bold
] ]
{ label = Element.text Consts.name { label = Element.el [ Element.padding 10 ] (Element.text Consts.name)
, onPress = Just Core.HomeClicked , url = "/"
} }
playlistsView : Element.Device -> List Twitch.Playlist -> Element Core.Msg playlistsView : Element.Device -> List Twitch.Playlist -> Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Playlist) -> Element Core.Msg
playlistsView device playlists = playlistsView device playlists currentDate time hover =
let let
empty = empty =
Element.el [ Element.width Element.fill ] Element.none Element.el [ Element.width Element.fill ] Element.none
views = views =
List.map playlistView playlists List.map (playlistView currentDate time hover) playlists
grouped = grouped =
group (numberOfVideosPerRow device) views group (numberOfVideosPerRow device) views
@@ -122,19 +144,32 @@ playlistsView device playlists =
final final
playlistView : Twitch.Playlist -> Element Core.Msg playlistView : Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Playlist) -> Twitch.Playlist -> Element Core.Msg
playlistView playlist = playlistView currentDate time hover playlist =
let let
key =
Twitch.playlistMiniatureUrl time Nothing playlist
src = src =
Twitch.playlistMiniatureUrl playlist Twitch.playlistMiniatureUrl time hover playlist
image = image =
Keyed.el [ Element.width Element.fill, Element.height Element.fill ] Keyed.el
( src [ Element.width Element.fill
, Element.height (Element.px 0)
, Element.htmlAttribute (Html.Attributes.style "padding-top" "56.25%")
, Element.htmlAttribute (Html.Attributes.style "position" "relative")
, Events.onMouseEnter (Core.HoverPlaylist playlist)
, Events.onMouseLeave Core.Unhover
]
( key
, Element.image , Element.image
[ Element.width Element.fill [ Element.width Element.fill
, Element.height Element.fill , Element.htmlAttribute (Html.Attributes.style "position" "absolute")
, Element.htmlAttribute (Html.Attributes.style "top" "0")
, Element.htmlAttribute (Html.Attributes.style "height" "100%")
, Element.inFront inFront , Element.inFront inFront
, Element.inFront new
] ]
{ description = "", src = src } { description = "", src = src }
) )
@@ -166,6 +201,13 @@ playlistView playlist =
, Element.padding 5 , Element.padding 5
] ]
new =
if Time.posixToMillis currentDate - Twitch.playlistDate playlist > week then
Element.none
else
newBadge
display = display =
Element.column [ Element.width Element.fill, Element.spacing 10 ] Element.column [ Element.width Element.fill, Element.spacing 10 ]
[ image [ image
@@ -176,21 +218,25 @@ playlistView playlist =
button = button =
Input.button [ Element.width Element.fill, Element.alignTop ] Input.button [ Element.width Element.fill, Element.alignTop ]
{ label = display { label =
, onPress = Just (Core.PlaylistClicked playlist) Ui.link [ Element.width Element.fill, Element.alignTop ]
{ label = display
, url = "/#" ++ playlist.url
}
, onPress = Nothing
} }
in in
button button
videoMiniaturesView : Element.Device -> Time.Zone -> Twitch.Playlist -> Element Core.Msg videoMiniaturesView : Bool -> Element.Device -> Time.Zone -> Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Video) -> Twitch.Playlist -> Element Core.Msg
videoMiniaturesView device zone playlist = videoMiniaturesView darkMode device zone currentDate time hover playlist =
let let
empty = empty =
Element.el [ Element.width Element.fill ] Element.none Element.el [ Element.width Element.fill ] Element.none
views = views =
List.map (videoMiniatureView zone playlist) playlist.videos List.map (videoMiniatureView darkMode zone currentDate time hover playlist) playlist.videos
grouped = grouped =
group (numberOfVideosPerRow device) views group (numberOfVideosPerRow device) views
@@ -206,8 +252,8 @@ videoMiniaturesView device zone playlist =
final final
videoMiniature : Twitch.Playlist -> Twitch.Video -> Element Core.Msg videoMiniature : Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Video) -> Twitch.Playlist -> Twitch.Video -> Element Core.Msg
videoMiniature playlist video = videoMiniature currentDate time hover playlist video =
let let
inFront = inFront =
Element.text label Element.text label
@@ -223,16 +269,41 @@ videoMiniature playlist video =
, Element.padding 5 , Element.padding 5
] ]
date =
video.date
|> Maybe.map Time.posixToMillis
|> Maybe.withDefault 0
new =
if Time.posixToMillis currentDate - date > week then
Element.none
else
newBadge
key =
Twitch.videoMiniatureUrl time Nothing playlist video
src = src =
Twitch.videoMiniatureUrl playlist video Twitch.videoMiniatureUrl time hover playlist video
image = image =
Keyed.el [ Element.width Element.fill, Element.height Element.fill ] Keyed.el
( src [ Element.width Element.fill
, Element.height (Element.px 0)
, Element.htmlAttribute (Html.Attributes.style "padding-top" "56.25%")
, Element.htmlAttribute (Html.Attributes.style "position" "relative")
, Events.onMouseEnter (Core.HoverVideo video)
, Events.onMouseLeave Core.Unhover
]
( key
, Element.image , Element.image
[ Element.width Element.fill [ Element.width Element.fill
, Element.height Element.fill , Element.htmlAttribute (Html.Attributes.style "position" "absolute")
, Element.htmlAttribute (Html.Attributes.style "top" "0")
, Element.htmlAttribute (Html.Attributes.style "height" "100%")
, Element.inFront inFront , Element.inFront inFront
, Element.inFront new
] ]
{ description = "", src = src } { description = "", src = src }
) )
@@ -243,53 +314,53 @@ videoMiniature playlist video =
image image
videoMiniatureView : Time.Zone -> Twitch.Playlist -> Twitch.Video -> Element Core.Msg videoMiniatureView : Bool -> Time.Zone -> Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Video) -> Twitch.Playlist -> Twitch.Video -> Element Core.Msg
videoMiniatureView zone playlist video = videoMiniatureView darkMode zone currentDate time hover playlist video =
let let
display = display =
Element.column [ Element.width Element.fill, Element.spacing 10 ] Element.column [ Element.width Element.fill, Element.spacing 10 ]
[ videoMiniature playlist video [ videoMiniature currentDate time hover playlist video
, videoDescription zone video , videoDescription darkMode zone video
] ]
button = button =
Input.button [ Element.width Element.fill, Element.alignTop ] Ui.link [ Element.width Element.fill, Element.alignTop ]
{ label = display { label = display
, onPress = Just (Core.VideoClicked playlist video) , url = "/#" ++ playlist.url ++ video.url
} }
in in
button button
videoInList : Time.Zone -> Twitch.Playlist -> Twitch.Video -> Twitch.Video -> Element Core.Msg videoInList : Bool -> Time.Zone -> Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Video) -> Twitch.Playlist -> Twitch.Video -> Twitch.Video -> Element Core.Msg
videoInList zone playlist activeVideo video = videoInList darkMode zone currentDate time hover playlist activeVideo video =
let let
( msg, attr ) =
if video == activeVideo then
( Nothing
, [ Element.width Element.fill
, Background.color Colors.selected
, Border.color Colors.primary
, Border.width 2
]
)
else
( Just (Core.VideoClicked playlist video), [ Element.width Element.fill ] )
label = label =
Element.row [ Element.width Element.fill, Element.spacing 10 ] Element.row [ Element.width Element.fill, Element.spacing 10 ]
[ Element.el [ Element.width (Element.fillPortion 2) ] [ Element.el [ Element.width (Element.fillPortion 2) ]
(videoMiniature playlist video) (videoMiniature currentDate time hover playlist video)
, Element.el [ Element.width (Element.fillPortion 3), Element.paddingXY 0 10, Element.alignTop ] , Element.el [ Element.width (Element.fillPortion 3), Element.paddingXY 0 10, Element.alignTop ]
(videoDescription zone video) (videoDescription darkMode zone video)
] ]
in in
Input.button attr { label = label, onPress = msg } if video == activeVideo then
Element.el
[ Element.width Element.fill
, Background.color (Colors.selected darkMode)
, Border.color Colors.primary
, Border.width 2
]
label
else
Ui.link [ Element.width Element.fill ]
{ label = label
, url = "/#" ++ playlist.url ++ video.url
}
videoView : Element.Device -> Time.Zone -> Twitch.Playlist -> Twitch.Video -> Element Core.Msg videoView : Bool -> Element.Device -> Time.Zone -> Time.Posix -> Time.Posix -> Maybe (Hover Twitch.Video) -> Twitch.Playlist -> Twitch.Video -> Element Core.Msg
videoView device zone playlist video = videoView darkMode device zone currentDate time hover playlist video =
let let
( builder, contentPadding ) = ( builder, contentPadding ) =
case device.class of case device.class of
@@ -310,28 +381,10 @@ videoView device zone playlist video =
, Element.alignTop , Element.alignTop
, Element.height Element.fill , Element.height Element.fill
] ]
[ Keyed.el [ Element.html
[ Element.width Element.fill (Html.node "plyr-video"
, Element.height (Element.px 0) [ Html.Attributes.attribute "src" ("videos/" ++ playlist.url ++ video.url ++ "manifest.m3u8") ]
, Element.htmlAttribute (Html.Attributes.style "padding-top" "56.25%") []
, Element.htmlAttribute (Html.Attributes.style "position" "relative")
]
( video.url
, Element.html
(Html.video
[ Html.Attributes.id (Twitch.videoId video)
, Html.Attributes.class "video-js"
, Html.Attributes.class "vjs-default-skin"
, Html.Attributes.class "wf"
, Html.Attributes.property "data-setup" (Encode.string "{\"fluid\": true}")
, Html.Attributes.style "position" "absolute"
, Html.Attributes.style "top" "0"
, Html.Attributes.style "height" "100%"
, Html.Attributes.controls True
, Html.Attributes.autoplay True
]
[]
)
) )
, Element.paragraph , Element.paragraph
[ Font.size Consts.homeFontSize [ Font.size Consts.homeFontSize
@@ -356,12 +409,12 @@ videoView device zone playlist video =
, Element.height Element.fill , Element.height Element.fill
, Element.scrollbarY , Element.scrollbarY
] ]
(List.map (videoInList zone playlist video) playlist.videos) (List.map (videoInList darkMode zone currentDate time hover playlist video) playlist.videos)
] ]
videoDescription : Time.Zone -> Twitch.Video -> Element Core.Msg videoDescription : Bool -> Time.Zone -> Twitch.Video -> Element Core.Msg
videoDescription zone video = videoDescription darkMode zone video =
Element.column [ Element.spacing 10 ] Element.column [ Element.spacing 10 ]
[ Element.paragraph [ Element.paragraph
[ Font.bold [ Font.bold
@@ -371,7 +424,7 @@ videoDescription zone video =
, case video.date of , case video.date of
Just date -> Just date ->
Element.paragraph Element.paragraph
[ Font.color Colors.greyFont [ Font.color (Colors.detailFont darkMode)
] ]
[ Element.text ("Diffusé le " ++ formatDate zone date) ] [ Element.text ("Diffusé le " ++ formatDate zone date) ]
@@ -522,3 +575,25 @@ spinner =
, Html.div [] [] , Html.div [] []
] ]
) )
newBadge : Element Core.Msg
newBadge =
Element.text "NOUV."
|> Element.el
[ Background.color Colors.red
, Border.rounded 5
, Element.padding 5
, Font.color Colors.white
, Font.bold
]
|> Element.el
[ Element.alignBottom
, Element.alignLeft
, Element.padding 5
]
week : Int
week =
1000 * 60 * 60 * 24 * 7