From 2657510fd2491cf089f3e04b026e473963435bca Mon Sep 17 00:00:00 2001 From: Simon Repp Date: Mon, 22 Apr 2024 11:25:42 +0200 Subject: [PATCH] Introduce custom video player --- src/build/assets/scripts.js | 175 ++++++++++++++ src/build/assets/styles.css | 465 ++++++++++++++++++++++-------------- src/build/translations.rs | 21 ++ src/build/video.rs | 2 +- src/build/widgets.rs | 264 +++++++++++++------- src/icons.rs | 65 +++++ src/theme.rs | 6 + src/video.rs | 47 +++- 8 files changed, 777 insertions(+), 268 deletions(-) diff --git a/src/build/assets/scripts.js b/src/build/assets/scripts.js index 61837c3..2f1e2f4 100644 --- a/src/build/assets/scripts.js +++ b/src/build/assets/scripts.js @@ -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(); + }); +} diff --git a/src/build/assets/styles.css b/src/build/assets/styles.css index 882ffce..6eb3d35 100644 --- a/src/build/assets/styles.css +++ b/src/build/assets/styles.css @@ -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; + } } diff --git a/src/build/translations.rs b/src/build/translations.rs index da6c782..0cdcfd1 100644 --- a/src/build/translations.rs +++ b/src/build/translations.rs @@ -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") } diff --git a/src/build/video.rs b/src/build/video.rs index 7039934..dd9ac64 100644 --- a/src/build/video.rs +++ b/src/build/video.rs @@ -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); diff --git a/src/build/widgets.rs b/src/build/widgets.rs index a9b6e75..0fda248 100644 --- a/src/build/widgets.rs +++ b/src/build/widgets.rs @@ -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("
") } -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#""#) + format!(r#""#) } 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#" -
- {size} - {format_label} -
-
-
-
-
-
- -{saving_percent}% -
- "#) - }) - .collect::>() - .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#" -
-
- "#) - }) - .collect::>() - .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#" -
{experiment}
- "#, - 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::>() .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#" - - +
+
+ + {versions_ring_rendered} +
+
+
+
+
+ +
+
+
+ + + +
+ 00:00 / {duration_formatted} +
+
+
+ + +
+
+
+ + + + + + + + +
"#) } @@ -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#" + + {resolution} {size} + + + {resolution} + + + {size} + + "#) + }; + + 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#" + + {slices} + + "#) +} diff --git a/src/icons.rs b/src/icons.rs index 516987f..d5b5a5c 100644 --- a/src/icons.rs +++ b/src/icons.rs @@ -54,6 +54,44 @@ pub fn logo(label: &str) -> String { "#) } + +pub fn maximize(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + +pub fn minimize(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + +pub fn pause(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + + +pub fn play(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + pub fn playlist(label: &str) -> String { formatdoc!(r#" @@ -71,3 +109,30 @@ pub fn video(label: &str) -> String { "#) } + +pub fn volume_loud(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + +pub fn volume_medium(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} + +pub fn volume_muted(label: &str) -> String { + formatdoc!(r#" + + {label} + + + "#) +} \ No newline at end of file diff --git a/src/theme.rs b/src/theme.rs index 40b27c2..8500d82 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -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}; diff --git a/src/video.rs b/src/video.rs index bad052c..f38e381 100644 --- a/src/video.rs +++ b/src/video.rs @@ -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 {