Introduce custom video player

This commit is contained in:
Simon Repp
2024-04-22 11:25:42 +02:00
parent c1619be463
commit 2657510fd2
8 changed files with 777 additions and 268 deletions
+175
View File
@@ -27,3 +27,178 @@ if (copyLink) {
copyLink.addEventListener('click', event => copyToClipboard(event.target));
}
const video = document.querySelector('video');
// TODO: Potentially move in separate script file that only gets loaded on video pages
if (video) {
let playbackUpdateInterval;
function formatTime(time) {
const timeFloored = Math.floor(time);
if (timeFloored >= 3600) {
const hours = Math.floor(timeFloored / 3600).toString();
const minutes = Math.floor((timeFloored % 3600) / 60).toString().padStart(2, '0');
const seconds = (timeFloored % 60).toString().padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
} else if (timeFloored >= 60) {
const minutes = Math.floor(timeFloored / 60).toString().padStart(2, '0');
const seconds = (timeFloored % 60).toString().padStart(2, '0');
return `${minutes}:${seconds}`;
} else {
const seconds = (timeFloored % 60).toString().padStart(2, '0');
return `00:${seconds}`;
}
}
const defaultPlayButton = document.querySelector('#default_play');
const intro = document.querySelector('.player_intro');
const playerControls = document.querySelector('.player_controls');
const playerWrapper = document.querySelector('.player_wrapper');
const timelineInput = document.querySelector('.timeline input');
const selectResolutionButton = document.querySelector('#select_resolution');
const toggleFullscreenButton = document.querySelector('#toggle_fullscreen');
const togglePlaybackButton = document.querySelector('#toggle_playback');
const toggleSpeedButton = document.querySelector('#toggle_speed');
const toggleVolumeButton = document.querySelector('#toggle_volume');
const persistedVolume = localStorage.getItem('hyper8Volume');
if (persistedVolume !== null) {
video.volume = parseFloat(persistedVolume);
if (video.volume > 0.666) {
toggleVolumeButton.replaceChildren(document.querySelector('#volume_loud_icon').content.cloneNode(true));
} else if (video.volume < 0.333) {
toggleVolumeButton.replaceChildren(document.querySelector('#volume_muted_icon').content.cloneNode(true));
} else {
toggleVolumeButton.replaceChildren(document.querySelector('#volume_medium_icon').content.cloneNode(true));
}
}
function playbackUpdate() {
const time = video.currentTime;
const timeFormatted = formatTime(time);
timelineInput.value = time;
timelineInput.setAttribute('aria-valuenow', time);
timelineInput.setAttribute('aria-valuetext', timeFormatted);
document.querySelector('.bar.progress').style.width = `${(time / video.duration) * 100}%`;
document.querySelector('#time_current').innerHTML = timeFormatted;
if (playerControls.dataset.hideBy && (new Date()).getTime() > playerControls.dataset.hideBy) {
playerControls.style.display = 'none';
}
}
function revealControls() {
playerControls.dataset.hideBy = (new Date()).getTime() + 3000;
playerControls.style.display = null;
}
function selectResolution(event) {
intro.style.display = 'none';
if (video.getAttribute('src') !== event.target.dataset.src) {
video.src = event.target.dataset.src;
selectResolutionButton.innerHTML = event.target.dataset.label;
}
video.play();
togglePlaybackButton.replaceChildren(document.querySelector('#pause_icon').content.cloneNode(true));
playbackUpdateInterval = setInterval(playbackUpdate, 80);
}
function togglePlayback() {
if (video.paused) {
intro.style.display = 'none';
video.play();
togglePlaybackButton.replaceChildren(document.querySelector('#pause_icon').content.cloneNode(true));
playbackUpdateInterval = setInterval(playbackUpdate, 80);
} else {
video.pause();
clearInterval(playbackUpdateInterval);
togglePlaybackButton.replaceChildren(document.querySelector('#play_icon').content.cloneNode(true));
}
}
for (const path of document.querySelectorAll('.versions_ring path[data-src]')) {
path.addEventListener('click', selectResolution);
}
defaultPlayButton.addEventListener('click', togglePlayback);
window.addEventListener('keydown', revealControls);
playerWrapper.addEventListener('mousemove', revealControls);
selectResolutionButton.addEventListener('click', () => {
intro.style.display = null;
if (!video.paused) {
video.pause();
clearInterval(playbackUpdateInterval);
togglePlaybackButton.replaceChildren(document.querySelector('#play_icon').content.cloneNode(true));
}
});
toggleFullscreenButton.addEventListener('click', () => {
if (document.fullscreenElement) {
document.exitFullscreen();
toggleFullscreenButton.replaceChildren(document.querySelector('#maximize_icon').content.cloneNode(true));
} else {
document.querySelector('.player_wrapper').requestFullscreen();
toggleFullscreenButton.replaceChildren(document.querySelector('#minimize_icon').content.cloneNode(true));
}
});
toggleSpeedButton.addEventListener('click', () => {
if (toggleSpeedButton.innerHTML === '1×') {
video.playbackRate = 1.25;
toggleSpeedButton.innerHTML = '1.25×'
} else if (toggleSpeedButton.innerHTML === '1.25×') {
video.playbackRate = 1.5;
toggleSpeedButton.innerHTML = '1.5×'
} else if (toggleSpeedButton.innerHTML === '1.5×') {
video.playbackRate = 1.75;
toggleSpeedButton.innerHTML = '1.75×'
} else if (toggleSpeedButton.innerHTML === '1.75×') {
video.playbackRate = 2;
toggleSpeedButton.innerHTML = '2×'
} else if (toggleSpeedButton.innerHTML === '2×') {
video.playbackRate = 0.5;
toggleSpeedButton.innerHTML = '0.5×'
} else if (toggleSpeedButton.innerHTML === '0.5×') {
video.playbackRate = 0.75;
toggleSpeedButton.innerHTML = '0.75×'
} else {
video.playbackRate = 1;
toggleSpeedButton.innerHTML = '1×'
}
});
toggleVolumeButton.addEventListener('click', () => {
if (video.volume > 0.666) {
video.volume = 0;
toggleVolumeButton.replaceChildren(document.querySelector('#volume_muted_icon').content.cloneNode(true));
localStorage.setItem('hyper8Volume', '0');
} else if (video.volume < 0.333) {
video.volume = 0.5;
toggleVolumeButton.replaceChildren(document.querySelector('#volume_medium_icon').content.cloneNode(true));
localStorage.setItem('hyper8Volume', '0.5');
} else {
video.volume = 1;
toggleVolumeButton.replaceChildren(document.querySelector('#volume_loud_icon').content.cloneNode(true));
localStorage.setItem('hyper8Volume', '1');
}
});
video.addEventListener('click', togglePlayback);
togglePlaybackButton.addEventListener('click', togglePlayback);
timelineInput.addEventListener('input', _ => {
intro.style.display = 'none';
video.currentTime = timelineInput.value;
playbackUpdate();
});
}
+285 -180
View File
@@ -1,300 +1,405 @@
:root {
--green: #5bd217;
--letterbox: #000;
--green: #5bd217;
--letterbox: #000;
}
a {
color: var(--fg-1);
text-decoration: none;
color: var(--fg-1);
text-decoration: none;
}
body {
background-color: var(--bg-1);
color: var(--fg-2);
font-family: sans-serif;
margin: 0;
background-color: var(--bg-1);
color: var(--fg-2);
font-family: sans-serif;
margin: 0;
}
button {
color: var(--fg-2);
font-size: 1rem;
color: var(--fg-2);
font-size: 1rem;
}
h1 { font-size: 1.5rem; }
h2 { font-size: 1.17rem; }
video {
border-radius: .4rem;
width: 100%;
border-radius: .4rem;
display: block;
width: 100%;
}
.active { color: red; }
.center {
margin: 0 auto;
max-width: 33rem;
position: relative;
margin: 0 auto;
max-width: 33rem;
position: relative;
}
.chart {
align-items: center;
cursor: pointer;
display: flex;
column-gap: 1px;
margin-bottom: .5rem;
position: relative;
align-items: center;
cursor: pointer;
display: flex;
column-gap: 1px;
margin-bottom: .5rem;
position: relative;
}
.bar {
height: 1.17rem;
height: 1.17rem;
}
.chart .emissions {
border: 1px solid var(--bg-mg);
border-right: none;
padding: 1px 0 1px 1px;
border: 1px solid var(--bg-mg);
border-right: none;
padding: 1px 0 1px 1px;
}
.chart .savings {
border: 1px solid var(--green);
border-left: none;
padding: 1px 1px 1px 0;
border: 1px solid var(--green);
border-left: none;
padding: 1px 1px 1px 0;
}
.chart .label {
color: #fff;
position: absolute;
left: 50%;
transform: translateX(-50%);
color: var(--fg-1);
position: absolute;
left: 50%;
transform: translateX(-50%);
}
#copy-link {
align-items: center;
background: var(--bg-2);
border: none;
border-radius: .2rem;
color: var(--fg-1);
column-gap: .3rem;
cursor: pointer;
display: flex;
padding: .2rem .5rem .2rem .4rem;
align-items: center;
background: var(--bg-2);
border: none;
border-radius: .2rem;
color: var(--fg-1);
column-gap: .3rem;
cursor: pointer;
display: flex;
padding: .2rem .5rem .2rem .4rem;
}
#copy-link::after {
color: var(--fg-2);
padding-left: .5rem;
color: var(--fg-2);
padding-left: .5rem;
}
#copy-link.confirmed::after { content: var(--t-copied); }
#copy-link.failed::after { content: var(--t-failed); }
#copy-link:focus-visible,
#copy-link:hover {
background-color: var(--fg-1);
color: var(--bg-1);
background-color: var(--fg-1);
color: var(--bg-1);
}
.current { background-color: var(--bg-3); }
.emissions .bar { background-color: var(--bg-mg); }
footer {
align-items: center;
display: flex;
justify-content: space-between;
margin: 3rem auto 0 auto;
padding: 1rem 0;
width: min(33rem, calc(100vw - 2rem));
align-items: center;
display: flex;
justify-content: space-between;
margin: 3rem auto 0 auto;
padding: 1rem 0;
width: min(33rem, calc(100vw - 2rem));
}
header {
align-items: center;
column-gap: .7rem;
display: flex;
height: 2rem;
padding: .5rem 0;
align-items: center;
column-gap: .7rem;
display: flex;
height: 2rem;
padding: .5rem 0;
}
header a {
border-bottom: .2rem solid transparent;
color: var(--fg-1);
outline: none;
padding: .3rem 0;
border-bottom: .2rem solid transparent;
color: var(--fg-1);
outline: none;
padding: .3rem 0;
}
header a:focus-visible,
header a:hover {
border-bottom-color: var(--fg-1);
border-bottom-color: var(--fg-1);
}
img { max-width: 100%; }
.layout {
display: flex;
flex-direction: column;
height: 100dvh;
display: flex;
flex-direction: column;
height: 100dvh;
}
.layout .growing { flex-grow: 1; }
.logo {
background-color: var(--fg-2);
color: var(--bg-1);
font-weight: 500;
padding: .5rem;
background-color: var(--fg-2);
color: var(--bg-1);
font-weight: 500;
padding: .5rem;
}
.player_controls {
bottom: 0;
display: flex;
flex-direction: column;
position: absolute;
width: 100%;
}
.player_controls button {
background: none;
border: none;
padding: .6rem;
}
.player_controls button svg {
height: 1.4rem;
width: 1.4rem;
}
.player_controls .control_pane {
background-color: var(--bg-1-overlay);
display: flex;
font-size: .85rem;
justify-content: space-between;
}
.player_controls .control_pane button { font-size: .85rem; }
.player_controls .control_pane div {
align-items: center;
display: flex;
}
.player_controls .control_pane input {
opacity: 0;
position: absolute;
width: 100%;
}
.player_controls .timeline {
height: .8rem;
position: relative;
}
.player_controls .timeline * { cursor: pointer; }
.player_controls .timeline .bar {
bottom: 0;
height: .3rem;
position: absolute;
}
.player_controls .timeline:hover .bar { height: .35rem; }
.player_controls .timeline .bar.backdrop {
background-color: var(--bg-mg);
width: 100%;
z-index: 1;
}
.player_controls .timeline .bar.progress {
background-color: var(--fg-1);
width: 0;
z-index: 2;
}
.player_controls .timeline input {
appearance: none;
bottom: 0;
left: 0;
opacity: 0;
position: absolute;
right: 0;
top: 0;
width: 100%;
z-index: 3;
}
.player_intro {
align-items: center;
background-color: var(--bg-1-overlay);
bottom: 0;
display: flex;
justify-content: center;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.player_intro #default_play {
background: none;
border: none;
z-index: 1;
}
.player_intro #default_play svg {
height: 3rem;
width: 3rem;
}
.player_intro .versions_ring {
position: absolute;
height: 20rem;
width: 20rem;
}
.player_intro .versions_ring path {
cursor: pointer;
fill: var(--fg-3);
}
.player_intro .versions_ring path:focus-visible,
.player_intro .versions_ring path:hover {
fill: var(--fg-1);
outline: none;
}
.player_intro .versions_ring text { font-size: .9rem; }
.player_intro .versions_ring text.size { fill: var(--fg-1); }
.player_wrapper { position: relative; }
.player_wrapper button { cursor: pointer; }
.player_wrapper button:hover { color: var(--fg-1); }
.playlist > * {
column-gap: 1rem;
display: flex;
outline: none;
column-gap: 1rem;
display: flex;
outline: none;
}
.playlist > *:focus-visible,
.playlist > *:hover { background-color: var(--bg-2); }
.playlist .description {
align-items: center;
color: var(--fg-1);
display: flex;
flex-grow: 1;
align-items: center;
color: var(--fg-1);
display: flex;
flex-grow: 1;
}
.playlist_context {
border: 1px solid var(--bg-2);
border-radius: .4rem;
/* Shadows only in light mode or different strategy between both color schemes */
box-shadow: 0 0 12rem #0001;
border: 1px solid var(--bg-2);
border-radius: .4rem;
/* TODO: Shadows only in light mode or different strategy between both color schemes */
box-shadow: 0 0 12rem #0001;
}
.playlist_context > a {
display: flex;
outline: none;
position: relative;
display: flex;
outline: none;
position: relative;
}
.playlist_context > a:first-child {
border-top-left-radius: .4rem;
border-top-right-radius: .4rem;
border-top-left-radius: .4rem;
border-top-right-radius: .4rem;
}
.playlist_context > a:first-child .thumbnail { border-top-left-radius: .4rem }
.playlist_context > a:last-child {
border-bottom-left-radius: .4rem;
border-bottom-right-radius: .4rem;
border-bottom-left-radius: .4rem;
border-bottom-right-radius: .4rem;
}
.playlist_context > a:last-child .thumbnail { border-bottom-left-radius: .4rem }
.playlist_context > a:focus-visible,
.playlist_context > a:hover { background-color: var(--bg-2); }
.playlist_context > a:not(:last-child) {
border-bottom: 1px solid var(--bg-1);
border-bottom: 1px solid var(--bg-1);
}
.playlist_context .current { background-color: var(--bg-3); }
.playlist_context .description {
align-items: flex-start;
color: var(--fg-1);
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
padding: .2rem .8rem;
align-items: flex-start;
color: var(--fg-1);
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
padding: .2rem .8rem;
}
.playlist_context .details { font-size: .8rem; }
.playlist_context .duration { color: var(--fg-3); }
.playlist_context .label {
font-size: .9rem;
max-width: 15rem; /* TODO: Resolve this hack, need dynamic fitting into max-width */
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: .9rem;
max-width: 15rem; /* TODO: Resolve this hack, need dynamic fitting into max-width */
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.playlist_context .more {
align-items: center;
display: flex;
height: 4rem;
justify-content: center;
align-items: center;
display: flex;
height: 4rem;
justify-content: center;
}
.playlist_context .number {
background-color: var(--bg-1);
border-bottom-left-radius: .2rem;
color: var(--fg-3);
font-size: .8rem;
padding: 0 .4rem 0 .5rem;
position: absolute;
right: 0;
top: 0;
background-color: var(--bg-1);
border-bottom-left-radius: .2rem;
color: var(--fg-3);
font-size: .8rem;
padding: 0 .4rem 0 .5rem;
position: absolute;
right: 0;
top: 0;
}
.playlist_context .thumbnail {
border-radius: 0;
display: block;
height: 4rem;
border-radius: 0;
display: block;
height: 4rem;
}
.playlist_header {
background-color: var(--bg-1);
padding: .8rem;
background-color: var(--bg-1);
padding: .8rem;
}
.release_date { color: var(--fg-3); }
.savings .bar { background-color: var(--green); }
.savings_percent {
color: var(--green);
position: absolute;
left: calc(100% + .5rem);
color: var(--green);
position: absolute;
left: calc(100% + .5rem);
}
.size {
color: var(--bg-mg);
position: absolute;
right: calc(100% + .5rem);
color: var(--bg-mg);
position: absolute;
right: calc(100% + .5rem);
}
.sidebar {
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
}
.site_title {
color: var(--fg-2);
font-size: 1.17rem;
color: var(--fg-2);
font-size: 1.17rem;
}
.split {
display: flex;
row-gap: 1rem;
display: flex;
row-gap: 1rem;
}
svg {
fill: currentColor;
display: block;
height: 1rem;
width: 1rem;
fill: currentColor;
display: block;
height: 1rem;
width: 1rem;
}
.system_info {
align-items: center;
color: var(--fg-3);
column-gap: .4rem;
display: flex;
font-size: .8rem;
text-decoration: underline;
text-underline-offset: .3rem;
align-items: center;
color: var(--fg-3);
column-gap: .4rem;
display: flex;
font-size: .8rem;
text-decoration: underline;
text-underline-offset: .3rem;
}
.thumbnail {
aspect-ratio: var(--poster-aspect);
background-color: var(--letterbox);
border-radius: 0.4rem;
flex-shrink: 0;
height: 9rem;
object-fit: contain;
aspect-ratio: var(--poster-aspect);
background-color: var(--letterbox);
border-radius: 0.4rem;
flex-shrink: 0;
height: 9rem;
object-fit: contain;
}
.thumbnail_wrapper { position: relative; }
.thumbnail_wrapper .duration {
background-color: hsla(0, 0%, 0%, .9);
border-radius: .2rem;
bottom: calc(var(--poster-aspect) * 5%);
color: #fff;
font-size: .8rem;
padding: .2rem .4rem;
position: absolute;
right: 5%;
background-color: hsla(0, 0%, 0%, .9);
border-radius: .2rem;
bottom: calc(var(--poster-aspect) * 5%);
color: var(--fg-1);
font-size: .8rem;
padding: .2rem .4rem;
position: absolute;
right: 5%;
}
#toggle_speed { font-weight: 600; }
.video {
color: var(--fg-2);
display: flex;
flex-direction: column;
color: var(--fg-2);
display: flex;
flex-direction: column;
}
.video_title {
color: var(--fg-1);
margin: 0;
color: var(--fg-1);
margin: 0;
}
.videos {
column-gap: 1rem;
display: flex;
flex-flow: row wrap;
row-gap: 1rem;
column-gap: 1rem;
display: flex;
flex-flow: row wrap;
row-gap: 1rem;
}
.videos_grid {
display: grid;
grid-gap: 1rem;
grid-template-columns: 16rem auto;
display: grid;
grid-gap: 1rem;
grid-template-columns: 16rem auto;
}
@media (min-width: 80rem) {
.playlist_context {
left: calc(100% + 2rem);
position: absolute;
top: 3rem;
width: 24rem;
}
.video_info {
right: calc(100% + 2rem);
position: absolute;
top: 3rem;
width: 24rem;
}
.playlist_context {
left: calc(100% + 2rem);
position: absolute;
top: 3rem;
width: 24rem;
}
.video_info {
right: calc(100% + 2rem);
position: absolute;
top: 3rem;
width: 24rem;
}
}
@media (min-width: 100rem) {
.center {
max-width: 45vw;
}
.center {
max-width: 45vw;
}
}
+21
View File
@@ -6,6 +6,13 @@ pub struct Translations {
pub failed: String,
pub hyper8_logo: String,
pub light_color_scheme: String,
pub loud_volume: String,
pub maximize: String,
pub medium_volume: String,
pub minimize: String,
pub pause: String,
pub play: String,
pub sound_muted: String,
pub video_not_yet_available: String,
pub videos: String
}
@@ -19,6 +26,13 @@ impl Translations {
failed: String::from("Fehlgeschlagen"),
hyper8_logo: String::from("Hyper 8 Logo"),
light_color_scheme: String::from("Helles Farbschema"),
loud_volume: String::from("Hohe Lautstärke"),
maximize: String::from("Maximieren"),
medium_volume: String::from("Mittlere Lautstärke"),
minimize: String::from("Minimieren"),
pause: String::from("Pausieren"),
play: String::from("Abspielen"),
sound_muted: String::from("Ton stummgeschalten"),
video_not_yet_available: String::from("Video noch nicht verfügbar."),
videos: String::from("Videos")
}
@@ -32,6 +46,13 @@ impl Translations {
failed: String::from("Failed"),
hyper8_logo: String::from("Hyper 8 Logo"),
light_color_scheme: String::from("Light Color Scheme"),
loud_volume: String::from("Loud Volume"),
maximize: String::from("Maximize"),
medium_volume: String::from("Medium Volume"),
minimize: String::from("Minimize"),
pause: String::from("Pause"),
play: String::from("Play"),
sound_muted: String::from("Sound muted"),
video_not_yet_available: String::from("Video not yet available."),
videos: String::from("Videos")
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub fn video_html(
let index_suffix = context.index_suffix();
let description = if let Some(description) = &video.description { description } else { "" };
let title = video.title.as_deref().unwrap_or(video.slug());
let player_rendered = player(site, video, "");
let player_rendered = player(site, video);
let downloads_widget = downloads(video);
+178 -86
View File
@@ -4,7 +4,7 @@ use indoc::formatdoc;
use crate::{Site, Video, VideoFile};
use crate::icons;
use crate::util::{casual_duration, human_size};
use crate::util::{casual_duration, human_size, precise_duration};
pub struct VideoRepresentation {
pub saving_factor: f32,
@@ -92,7 +92,7 @@ pub fn downloads(video: &Video) -> String {
.join("<br>")
}
pub fn player(site: &Site, video: &Video, video_prefix: &str) -> String {
pub fn player(site: &Site, video: &Video) -> String {
if video.versions.is_empty() {
// TODO: Should use poster assets (different sizes)
let poster = match &video.poster {
@@ -103,7 +103,7 @@ pub fn player(site: &Site, video: &Video, video_prefix: &str) -> String {
String::new()
};
format!(r#"<img {alt}class="thumbnail" src="{video_prefix}poster.jpg">"#)
format!(r#"<img {alt}class="thumbnail" src="poster.jpg">"#)
}
None => String::new()
};
@@ -118,70 +118,7 @@ pub fn player(site: &Site, video: &Video, video_prefix: &str) -> String {
"#);
}
let representations = compute_representations(&video.versions)
.iter()
.map(|representation| {
let filename = &representation.version.file_name;
let format_label = "TODO";
let size = human_size(representation.version.file_meta.size);
let saving_factor = representation.saving_factor;
let saving_percent = (saving_factor * 100.0) as u8;
formatdoc!(r#"
<div class="chart" onclick="document.querySelector('video').setAttribute('src', '{video_prefix}{filename}'); document.querySelector('video').play();">
<span class="size">{size}</span>
<span class="label">{format_label}</span>
<div class="emissions" style="width: calc((1 - {saving_factor}) * 33rem);">
<div class="bar"></div>
</div>
<div class="savings" style="width: calc({saving_factor} * 33rem);">
<div class="bar"></div>
</div>
<span class="savings_percent" title="-{saving_percent}% less emissions">-{saving_percent}%</span>
</div>
"#)
})
.collect::<Vec<String>>()
.join("");
let height = video.versions.first().unwrap().video_meta.as_ref().unwrap().height as f32;
let width = video.versions.first().unwrap().video_meta.as_ref().unwrap().width as f32;
let scale = if width > height { 16.0 / width } else { 9.0 / height };
let experiment = compute_representations(&video.versions)
.iter()
.rev()
.map(|representation| {
let filename = &representation.version.file_name;
// let format_label = representation.version.format.label();
// let size = human_size(representation.version.size);
let saving_factor = representation.saving_factor;
// let saving_percent = (saving_factor * 100.0) as u8;
let divider = 1.0 - saving_factor; // e.g. 1/4
let sqrt = divider.sqrt() * 100.0;
// let relative_size_factor = (1.0 - saving_factor.exp2()) * 100.0;
// 100MB = 1 16x16
// 25MB = 1/4 = 0.25 8x8 => *2
// 6.25MB = 1/16 = 0.0625 4z4 => *4
formatdoc!(r#"
<div onclick="document.querySelector('video').setAttribute('src', '{video_prefix}{filename}'); document.querySelector('video').play();"
style="
border: 1px solid black;
left: 0;
bottom: 0;
height: {sqrt}%;
position: absolute;
width: {sqrt}%;
">
</div>
"#)
})
.collect::<Vec<String>>()
.join("");
let smallest_version = video.versions.first().unwrap();
let smallest_version = video.lowest_resolution_version().unwrap();
let poster = match &video.poster {
Some(poster) => match &poster.assets {
@@ -189,23 +126,13 @@ pub fn player(site: &Site, video: &Video, video_prefix: &str) -> String {
let largest = poster_assets.largest();
let height = largest.height;
let width = largest.width;
format!(r#"poster="{video_prefix}poster_{width}x{height}.jpg""#)
format!(r#"poster="poster_{width}x{height}.jpg""#)
}
None => format!(r#"poster="{video_prefix}poster.jpg""#)
None => format!(r#"poster="poster.jpg""#)
}
None => String::new()
};
let src = format!("{}{}", video_prefix, smallest_version.file_name);
let experiment_combined = formatdoc!(
r#"
<div style="height: {height}rem; position: relative; width: {width}rem;">{experiment}</div>
"#,
width = width * scale,
height = height * scale
);
let subtitles = video.subtitles
.iter()
.enumerate()
@@ -229,14 +156,72 @@ pub fn player(site: &Site, video: &Video, video_prefix: &str) -> String {
.collect::<Vec<String>>()
.join("\n");
let duration = smallest_version.video_meta.as_ref().unwrap().duration;
let duration_formatted = precise_duration(duration);
let default_resolution_label = smallest_version.vertical_resolution_label();
let src = &smallest_version.file_name;
let versions_ring_rendered = versions_ring(video);
let maximize_icon = icons::maximize(&site.language.translations.maximize);
let minimize_icon = icons::minimize(&site.language.translations.minimize);
let pause_icon = icons::pause(&site.language.translations.pause);
let play_icon = icons::play(&site.language.translations.play);
let volume_loud_icon = icons::volume_loud(&site.language.translations.loud_volume);
let volume_medium_icon = icons::volume_medium(&site.language.translations.medium_volume);
let volume_muted_icon = icons::volume_muted(&site.language.translations.sound_muted);
formatdoc!(r#"
<video controls {poster} preload="none" src="{src}">
{subtitles}
</video>
<!--
{representations}
{experiment_combined}
-->
<div class="player_wrapper">
<div class="player_intro">
<button id="default_play">{play_icon}</button>
{versions_ring_rendered}
</div>
<div class="player_controls">
<div class="timeline">
<div class="bar backdrop"></div>
<div class="bar progress"></div>
<input aria-valuenow="0" aria-valuetext="00:00" max="{duration}" min="0" step="any" type="range" value="0">
</div>
<div class="control_pane">
<div>
<button id="toggle_playback">{play_icon}</button>
<button id="toggle_volume">{volume_loud_icon}</button>
<button id="toggle_speed">1×</button>
<div>
<span id="time_current">00:00</span> / <span id="time_total">{duration_formatted}</span>
</div>
</div>
<div>
<button id="select_resolution">{default_resolution_label}</button>
<button id="toggle_fullscreen">{maximize_icon}</button>
</div>
</div>
</div>
<video {poster} preload="none" src="{src}">
{subtitles}
</video>
<template id="maximize_icon">
{maximize_icon}
</template>
<template id="minimize_icon">
{minimize_icon}
</template>
<template id="pause_icon">
{pause_icon}
</template>
<template id="play_icon">
{play_icon}
</template>
<template id="volume_loud_icon">
{volume_loud_icon}
</template>
<template id="volume_medium_icon">
{volume_medium_icon}
</template>
<template id="volume_muted_icon">
{volume_muted_icon}
</template>
</div>
"#)
}
@@ -284,3 +269,110 @@ pub fn thumbnail(video: &Video, video_prefix: &str, with_duration: bool) -> Stri
None => String::new()
}
}
fn versions_ring(video: &Video) -> String {
let radius = 190.0;
let diameter = radius * 2.0;
let mut sizes = Vec::new();
let mut total_size: u64 = 0;
let mut largest_size = 0;
for version in &video.versions {
let src = &version.file_name;
let size = version.file_meta.size;
let resolution = version.vertical_resolution_label();
sizes.push((size, src, resolution));
total_size += size;
if size > largest_size {
largest_size = size;
}
}
sizes.sort_unstable_by(|a, b| a.0.cmp(&b.0));
// Draws a ring segment. In clock terms we start at 12 o'clock and we go clockwise.
let slice = |arc_angle: f32, begin_angle: f32, lightness: f32, src: &str, tab_index: usize, resolution: &str, size: &str| -> String {
let fill = format!("hsl(0, 0%, {lightness}%)");
let large_arc_flag = if arc_angle < 180.0 { 0 } else { 1 };
let begin_angle_rad = begin_angle.to_radians();
let begin_x = begin_angle_rad.sin();
let begin_y = -begin_angle_rad.cos();
let end_angle_rad = (begin_angle + arc_angle).to_radians();
let end_x = end_angle_rad.sin();
let end_y = -end_angle_rad.cos();
let outer_radius = radius * 0.6;
let segment_outer_begin_x = radius + begin_x * outer_radius;
let segment_outer_begin_y = radius + begin_y * outer_radius;
let segment_outer_end_x = radius + end_x * outer_radius;
let segment_outer_end_y = radius + end_y * outer_radius;
let inner_radius = radius * 0.5;
let segment_inner_begin_x = radius + begin_x * inner_radius;
let segment_inner_begin_y = radius + begin_y * inner_radius;
let segment_inner_end_x = radius + end_x * inner_radius;
let segment_inner_end_y = radius + end_y * inner_radius;
let text_radius = radius * 0.8;
let text_angle_rad = (begin_angle + 0.5 * arc_angle).to_radians();
let text_x = radius + text_angle_rad.sin() * text_radius;
let text_resolution_y = radius + -text_angle_rad.cos() * text_radius - 9.0;
let text_size_y = radius + -text_angle_rad.cos() * text_radius + 9.0;
formatdoc!(r#"
<path
d="M {segment_outer_begin_x},{segment_outer_begin_y}
A {outer_radius} {outer_radius} 0 {large_arc_flag} 1 {segment_outer_end_x},{segment_outer_end_y}
L {segment_inner_end_x},{segment_inner_end_y}
A {inner_radius} {inner_radius} 0 {large_arc_flag} 0 {segment_inner_begin_x},{segment_inner_begin_y}
Z"
data-label="{resolution}"
data-src="{src}"
fill="{fill}"
tabindex="{tab_index}">
<title>{resolution} {size}</title>
</path>
<text text-anchor="middle" x="{text_x}" y="{text_resolution_y}">
{resolution}
</text>
<text class="size" text-anchor="middle" x="{text_x}" y="{text_size_y}">
{size}
</text>
"#)
};
let mut slices = String::new();
let gap_angle = 2.5;
let usable_angle = 360.0 - sizes.len() as f32 * gap_angle;
let mut begin_angle = gap_angle * 0.5;
for (index, (size, src, title)) in sizes.iter().enumerate() {
let arc_angle = if sizes.len() == 1 {
usable_angle
} else {
(*size as f32 / total_size as f32) * usable_angle
};
let offset_fill = index as f32 / (sizes.len() - 1) as f32;
let lightness = 75.0 - offset_fill * 50.0;
let size_humanized = human_size(*size);
slices.push_str(&slice(arc_angle, begin_angle, lightness, src, index + 1, title, &size_humanized));
begin_angle += arc_angle + gap_angle;
}
formatdoc!(r#"
<svg class="versions_ring" width="{diameter}" height="{diameter}" version="1.1" viewBox="0 0 {diameter} {diameter}" xmlns="http://www.w3.org/2000/svg">
{slices}
</svg>
"#)
}
+65
View File
@@ -54,6 +54,44 @@ pub fn logo(label: &str) -> String {
"#)
}
pub fn maximize(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m4.2695 59.731h17.506v-7.0907h-10.415v-10.415h-7.0907zm55.462 0v-17.506h-7.0907v10.415h-10.415v7.0907zm0-55.462h-17.506v7.0907h10.415v10.415h7.0907zm-55.462 0v17.506h7.0907v-10.415h10.415v-7.0907z"/>
</svg>
"#)
}
pub fn minimize(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m36.27 27.731h17.506v-7.0907h-10.415v-10.415h-7.0907zm-8.5385 0v-17.506h-7.0907v10.415h-10.415v7.0907zm0 8.5385h-17.506v7.0907h10.415v10.415h7.0907zm8.5385 0v17.506h7.0907v-10.415h10.415v-7.0907z"/>
</svg>
"#)
}
pub fn pause(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m36.915 56.995v-49.985h15.592v49.985zm-25.421 0v-49.985h15.592v49.985z">
</svg>
"#)
}
pub fn play(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m11.116 59.677 45.476-26.453-45.544-28.093z"/>
</svg>
"#)
}
pub fn playlist(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
@@ -71,3 +109,30 @@ pub fn video(label: &str) -> String {
</svg>
"#)
}
pub fn volume_loud(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m33.82 11.047-13.967 12.195h-6.0586c-2.4434 0-4.4121 1.9668-4.4121 4.4102v9.1797c0 2.4434 1.9688 4.4102 4.4121 4.4102h6.0586l13.967 12.193v-42.389zm4.2227 1.7285a2.4605 2.4605 0 0 0-2.4609 2.459 2.4605 2.4605 0 0 0 2.4609 2.4609c8.1842 0 14.76 6.5605 14.76 14.709 1e-6 8.1485-6.5756 14.709-14.76 14.709a2.4605 2.4605 0 0 0-2.4609 2.459 2.4605 2.4605 0 0 0 2.4609 2.4609c10.836 0 19.68-8.8131 19.68-19.629 0-10.816-8.8438-19.629-19.68-19.629zm0 9.2793a2.4605 2.4605 0 0 0-2.4609 2.4609 2.4605 2.4605 0 0 0 2.4609 2.4609c3.0438 0 5.4512 2.4046 5.4512 5.4277 0 3.0232-2.4074 5.4277-5.4512 5.4277a2.4605 2.4605 0 0 0-2.4609 2.4609 2.4605 2.4605 0 0 0 2.4609 2.4609c5.6955 0 10.373-4.6591 10.373-10.35s-4.6775-10.35-10.373-10.35z"/>
</svg>
"#)
}
pub fn volume_medium(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m33.82 11.047-13.967 12.195h-6.0586c-2.4434 0-4.4121 1.9668-4.4121 4.4102v9.1797c0 2.4434 1.9688 4.4102 4.4121 4.4102h6.0586l13.967 12.193zm4.2227 11.008c-1.3592-2.42e-4 -2.4612 1.1017-2.4609 2.4609-2.41e-4 1.3592 1.1017 2.4612 2.4609 2.4609 3.0438 0 5.4512 2.4046 5.4512 5.4277 0 3.0232-2.4074 5.4277-5.4512 5.4277-1.3592-2.42e-4 -2.4612 1.1017-2.4609 2.4609-2.41e-4 1.3592 1.1017 2.4612 2.4609 2.4609 5.6955 0 10.373-4.6591 10.373-10.35s-4.6775-10.35-10.373-10.35z"/>
</svg>
"#)
}
pub fn volume_muted(label: &str) -> String {
formatdoc!(r#"
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<title>{label}</title>
<path d="m33.82 11.047-13.967 12.195h-6.0586c-2.4434 0-4.4121 1.9668-4.4121 4.4102v9.1797c0 2.4434 1.9688 4.4102 4.4121 4.4102h6.0586l13.967 12.193v-42.389zm4.1875 11.416a2.4605 2.4605 0 0 0-1.7402 0.7207 2.4605 2.4605 0 0 0 0 3.4805l5.9844 5.9844-5.9844 5.9844a2.4605 2.4605 0 0 0 0 3.4785 2.4605 2.4605 0 0 0 3.4805 0l5.9824-5.9824 5.9844 5.9824a2.4605 2.4605 0 0 0 3.4805 0 2.4605 2.4605 0 0 0 0-3.4785l-5.9844-5.9844 5.9844-5.9844a2.4605 2.4605 0 0 0 0-3.4805 2.4605 2.4605 0 0 0-1.7402-0.7207 2.4605 2.4605 0 0 0-1.7402 0.7207l-5.9844 5.9844-5.9824-5.9844a2.4605 2.4605 0 0 0-1.7402-0.7207z"/>
</svg>
"#)
}
+6
View File
@@ -24,6 +24,8 @@ pub struct ThemeVars {
/// Background 1 (furthest away/bottom layer)
pub bg_1: String,
/// Background 2 (further away)
pub bg_1_overlay: String,
/// Background 2 (further away) with some transparency
pub bg_2: String,
/// Background 3 (far away)
pub bg_3: String,
@@ -85,6 +87,7 @@ impl ThemeVars {
ThemeVars {
accent: String::from("#fbdf46"),
bg_1: String::from("#161616"),
bg_1_overlay: String::from("rgba(22, 22, 22, .92)"),
bg_2: String::from("#262626"),
bg_3: String::from("#333"),
bg_mg: String::from("#444"),
@@ -98,6 +101,7 @@ impl ThemeVars {
pub fn light() -> ThemeVars {
ThemeVars {
accent: String::from("#9f2e02"),
bg_1_overlay: String::from("rgba(255, 255, 255, .92)"),
bg_1: String::from("#fff"),
bg_2: String::from("#eee"),
bg_3: String::from("#ddd"),
@@ -112,6 +116,7 @@ impl ThemeVars {
pub fn print(&self) -> String {
let accent = &self.accent;
let bg_1 = &self.bg_1;
let bg_1_overlay = &self.bg_1_overlay;
let bg_2 = &self.bg_2;
let bg_3 = &self.bg_3;
let bg_mg = &self.bg_mg;
@@ -123,6 +128,7 @@ impl ThemeVars {
formatdoc!(r#"
--accent: {accent};
--bg-1: {bg_1};
--bg-1-overlay: {bg_1_overlay};
--bg-2: {bg_2};
--bg-3: {bg_3};
--bg-mg: {bg_mg};
+46 -1
View File
@@ -19,7 +19,7 @@ use crate::{
SubtitleFile,
VideoMetaCached
};
use crate::util::hash;
use crate::util::{hash, human_size};
#[derive(Clone, Debug)]
pub struct Video {
@@ -233,6 +233,21 @@ impl Video {
)
}
pub fn lowest_resolution_version(&self) -> Option<&VideoFile> {
let mut version_candidate: Option<(usize, &VideoFile)> = None;
for version in &self.versions {
if let Some(video_meta) = &version.video_meta {
let pixels = video_meta.width * video_meta.height;
if version_candidate.is_none() || version_candidate.is_some_and(|pixels_version| pixels < pixels_version.0) {
version_candidate = Some((pixels, version));
}
}
}
version_candidate.map(|pixels_version| pixels_version.1)
}
fn new(path: String) -> Video {
Video {
description: None,
@@ -396,6 +411,36 @@ impl VideoFile {
video_meta
}
}
pub fn resolution_label(&self) -> String {
match &self.video_meta {
Some(video_meta) => {
let height = video_meta.height;
let width = video_meta.width;
format!("{width}×{height}")
}
None => String::new()
}
}
pub fn size_label(&self) -> String {
let size = human_size(self.file_meta.size);
match &self.video_meta {
Some(video_meta) => {
let height = video_meta.height;
let width = video_meta.width;
format!("{width}×{height} ({size})")
}
None => size
}
}
pub fn vertical_resolution_label(&self) -> String {
match &self.video_meta {
Some(video_meta) => format!("{}p", video_meta.height),
None => String::new()
}
}
}
impl VideoMeta {