From 1239321b86b55171489a6ca10cabed244f75e619 Mon Sep 17 00:00:00 2001 From: ElementalAlchemist Date: Tue, 12 Nov 2024 03:34:38 -0600 Subject: [PATCH] Implement basic video controls These will be expanded or replaced with vidstack's controls in the future, but this at least gives us most of the basic requirements. --- thrimbletrimmer/src/common/video.module.scss | 47 +++++ thrimbletrimmer/src/common/video.tsx | 174 +++++++++++++++++- thrimbletrimmer/src/globalStyle.scss | 43 +++++ .../src/images/video-controls/fullscreen.png | Bin 0 -> 633 bytes .../src/images/video-controls/pause.png | Bin 0 -> 600 bytes .../src/images/video-controls/play.png | Bin 0 -> 667 bytes .../src/images/video-controls/volume-mute.png | Bin 0 -> 756 bytes .../src/images/video-controls/volume.png | Bin 0 -> 747 bytes thrimbletrimmer/src/restreamer/Restreamer.tsx | 17 +- 9 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 thrimbletrimmer/src/images/video-controls/fullscreen.png create mode 100644 thrimbletrimmer/src/images/video-controls/pause.png create mode 100644 thrimbletrimmer/src/images/video-controls/play.png create mode 100644 thrimbletrimmer/src/images/video-controls/volume-mute.png create mode 100644 thrimbletrimmer/src/images/video-controls/volume.png diff --git a/thrimbletrimmer/src/common/video.module.scss b/thrimbletrimmer/src/common/video.module.scss index f543f05..f3f27b1 100644 --- a/thrimbletrimmer/src/common/video.module.scss +++ b/thrimbletrimmer/src/common/video.module.scss @@ -9,3 +9,50 @@ .streamTimeSettingLabel { margin-right: 3px; } + +.videoControls { + font-size: 110%; +} + +.videoControls select { + appearance: none; + font-size: inherit; + background: inherit; + border: none; + text-align: center; +} + +.videoControls option { + background: #222; + padding: 2px; +} + +.videoControlsBar { + display: flex; + align-items: center; + gap: 8px; +} + +.videoControlsSpacer { + flex-grow: 1; +} + +.videoControlsVolume { + display: flex; + align-items: center; + gap: 2px; +} + +.videoControlsVolumeLevel { + width: 100px; + height: 8px; + border-radius: 0; + border: 0; +} + +.videoControlsPlaybackPosition { + height: 10px; + border-radius: 0; + border: 0; + width: 100%; +} \ No newline at end of file diff --git a/thrimbletrimmer/src/common/video.tsx b/thrimbletrimmer/src/common/video.tsx index 73086b2..1a7bbb2 100644 --- a/thrimbletrimmer/src/common/video.tsx +++ b/thrimbletrimmer/src/common/video.tsx @@ -1,4 +1,13 @@ -import { Accessor, Component, createEffect, createSignal, Setter, Show } from "solid-js"; +import { + Accessor, + Component, + createEffect, + createSignal, + For, + onCleanup, + Setter, + Show, +} from "solid-js"; import { DateTime } from "luxon"; import { TimeType, @@ -10,6 +19,14 @@ import { dateTimeFromTimeAgo, } from "./convertTime"; import styles from "./video.module.scss"; +import { MediaPlayerElement } from "vidstack/elements"; +import { VideoQuality } from "vidstack"; + +import playImage from "../images/video-controls/play.png"; +import pauseImage from "../images/video-controls/pause.png"; +import volumeImage from "../images/video-controls/volume.png"; +import volumeMuteImage from "../images/video-controls/volume-mute.png"; +import fullscreenImage from "../images/video-controls/fullscreen.png"; export const VIDEO_FRAMES_PER_SECOND = 30; @@ -179,6 +196,161 @@ export const StreamTimeSettings: Component = (props) => ); }; +export interface VideoControlsProps { + mediaPlayer: Accessor; +} + +export const VideoControls: Component = (props) => { + const mediaPlayer = props.mediaPlayer(); + if (!mediaPlayer) { + return <>; + } + + const [isPlaying, setIsPlaying] = createSignal(!props.mediaPlayer().paused); + const [playerTime, setPlayerTime] = createSignal(props.mediaPlayer().currentTime); + const [duration, setDuration] = createSignal(props.mediaPlayer().duration); + const [isMuted, setIsMuted] = createSignal(props.mediaPlayer().muted); + const [volume, setVolume] = createSignal(props.mediaPlayer().volume); + const [playbackRate, setPlaybackRate] = createSignal(props.mediaPlayer().playbackRate); + const [qualityLevel, setQualityLevel] = createSignal( + props.mediaPlayer().state.quality, + ); + const [qualityLevelList, setQualityLevelList] = createSignal(props.mediaPlayer().state.qualities); + const [isFullscreen, setIsFullscreen] = createSignal(false); + + const unsubscribe = props.mediaPlayer().subscribe((playerState) => { + setIsPlaying(!playerState.paused); + setPlayerTime(playerState.currentTime); + setDuration(playerState.duration); + setIsMuted(playerState.muted); + setVolume(playerState.volume); + setPlaybackRate(playerState.playbackRate); + setQualityLevel(playerState.quality); + setQualityLevelList(playerState.qualities); + setIsFullscreen(playerState.fullscreen); + + if (playerState.fullscreen) { + props.mediaPlayer().controls.show(); + } else { + props.mediaPlayer().controls.hide(); + } + }); + + createEffect(() => { + const player = props.mediaPlayer(); + if (isFullscreen() && !player.controls.showing) { + player.controls.show(); + } else if (!isFullscreen() && player.controls.showing) { + player.controls.hide(); + } + }); + + onCleanup(() => unsubscribe()); + + const timeDisplay = (time: number) => { + const hours = Math.floor(time / 3600); + const minutes = Math.floor((time / 60) % 60); + const seconds = Math.floor((time % 60) * 1000) / 1000; + + const minutesDisplay = minutes < 10 ? `0${minutes}` : minutes.toString(); + const secondsDisplay = seconds < 10 ? `0${seconds}` : seconds.toString(); + + if (hours === 0) { + return `${minutesDisplay}:${secondsDisplay}`; + } + return `${hours}:${minutesDisplay}:${secondsDisplay}`; + }; + + const playerTimeDisplay = () => timeDisplay(playerTime()); + + const durationDisplay = () => timeDisplay(duration()); + + return ( +
+
+
+ {isPlaying() (props.mediaPlayer().paused = !props.mediaPlayer().paused)} + /> +
+
+ {playerTimeDisplay()} / {durationDisplay()} +
+
+
+ {isMuted() (props.mediaPlayer().muted = !props.mediaPlayer().muted)} + /> + { + const player = props.mediaPlayer(); + player.volume = event.offsetX / event.currentTarget.offsetWidth; + }} + /> +
+
+ +
+
+ +
+
+ fullscreen { + const player = props.mediaPlayer(); + if (!player.state.canFullscreen) { + return; + } + if (isFullscreen()) { + player.exitFullscreen(); + } else { + player.requestFullscreen(); + } + }} + /> +
+
+ { + const player = props.mediaPlayer(); + const progressProportion = event.offsetX / event.currentTarget.offsetWidth; + const time = progressProportion * duration(); + player.currentTime = time; + }} + /> +
+ ); +}; + export interface KeyboardShortcutProps { includeEditorShortcuts: boolean; } diff --git a/thrimbletrimmer/src/globalStyle.scss b/thrimbletrimmer/src/globalStyle.scss index 8f92aa7..d4d196d 100644 --- a/thrimbletrimmer/src/globalStyle.scss +++ b/thrimbletrimmer/src/globalStyle.scss @@ -1,3 +1,46 @@ +body { + // Firefox has a weird default font, which is a different size from the one in Chrome + // and makes some renderings bad. + font-family: "Arial", sans-serif; + + background: #222; + color: #fff; + height: 100vh; + margin: 0; +} + +a { + color: #ccf; +} + +input, +textarea { + background: #222; + color: #fff; + border-color: #444; +} + +textarea { + // Text areas look better with the same borders as input fields. + border-style: inset; + border-width: 2px; +} + +button, +select { + background: #333; + color: #fff; +} + +button:active { + background: #000; +} + +a, +.click { + cursor: pointer; +} + .hidden { display: none; } diff --git a/thrimbletrimmer/src/images/video-controls/fullscreen.png b/thrimbletrimmer/src/images/video-controls/fullscreen.png new file mode 100644 index 0000000000000000000000000000000000000000..664bc2a31e05f614ba8f6eaedb17a057f7aec9e3 GIT binary patch literal 633 zcmV-<0*3vGP)EX>4Tx04R}tkv&MmKpe$iQ?;TM5j%)DWT;LSL`5963Pq?8YK2xEOfLNpnlvOS zE{=k0!NHHks)LKOt`4q(Aou~|=H{g6A|?JWDYS_3;J6>}?mh0_0Ya_BG^=e4&~)2O zCE{WxyCQ~O(Ty-V5JI2KEMr!ZlJFg0_XzOyF2=L`&;2=i)SShDfJi*U4AUlFC!X50 z4bJ<-5muB{;&b9rlP*a7$aTfzH_io@1)do()2TV)2(egbVWovx(bR}1iKD8fQ@)V# zSmnIMSu0goAMvPXS6bmWZkNfxsUB5&wg`Uwzx2Cnp`zgz>RKS{4P zwdfJhyA51iH#KDsxZD8-o($QPT`5RY$mfCgGy0}1(0>bbt$MvR_Hp_Eq^Yaq4RCM> zj1(w)&F9^nt-bwwrqSOI@jh~M85YVL00006VoOIv0RI600RN!9r;`8x010qNS#tmY zE+YT{E+YYWr9XB6000McNlirueSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{002=*L_t(Y$L*9c4geqs1FPX{ytrmY`P+KZT^Nsu%I!UsKsdkNl!Kv?7NAGwmSO T?#<3900000NkvXXu0mjfMqLRa literal 0 HcmV?d00001 diff --git a/thrimbletrimmer/src/images/video-controls/pause.png b/thrimbletrimmer/src/images/video-controls/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..8de238f044208ef9521d95c09884402b23de5520 GIT binary patch literal 600 zcmV-e0;m0nP)EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{001yaL_t(Y$75g^1p^Hj(FJzz-u)k&LRVK;Msn0K(b@;2 m7K~ajYQd-lqZW*U;Q;{Qi3lLkiMJvE0000EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|hk5?EC-#02y>eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{0047IL_t(Y$L-ZY3V<*S1i)4DH1}QluXriwyY%8wO4B4D z(mmz01Eo!Yk^F`lc7)dd3sLMsm*r5COFSF319OK|O#8@&HXx1W<+M>~|#r z9n~X}tYQyfx~7sEX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{007KML_t(Y$L*Cp3c^4Tg}=>7;w7vUM0ll@BIG36X{jd> zL>t=yv9R(Kxrc=|5oFbv%_i9R%Gu?8v-88kUv0_EmR0U05$20l03qhVfKc-wKppeS zfHHG4OUq);JGccV*a0rURMM&N7yw(KD=CixNOES@1NJ}$T!9^sNjlaZQx9?m#)a|W z*}M_JyHZClnpf6-k^KZ10UI+L{-((#S#OeNOk9AD(At;)0000EX>4Tx04R}tkv&MmKp2MKrbz@3D;ex)r#C4j(NMQkskRU=q4HZ;jBTl=bb^8^S!16O+6ztI4uKS{5* zwb&6bunk;Xw>4!CxZD8-pA6ZQT`5RYC>DYDGy0}H5V-|eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{006^DL_t(Y$L*D|4Z<)GMPI04m{icF_;r>+f(}Ypfg(FV zdq{MQz!nrt08J91$b>kyyL`#gFHZm2x;q&rrIMNLht$mi!gomkKH=T~-*7L$nDD`X zzHl>Jx_qOoQ~dV4pnwX<>r(-_q-W3+wdv9oI0Nf%A3UZVoq*EeqXAyP+~K1EO5pDB zQJTC0EI3b0Nd>w(MhO4_002ovPDHLkV1lW_Ml=8b literal 0 HcmV?d00001 diff --git a/thrimbletrimmer/src/restreamer/Restreamer.tsx b/thrimbletrimmer/src/restreamer/Restreamer.tsx index d74c73d..d2da160 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.tsx +++ b/thrimbletrimmer/src/restreamer/Restreamer.tsx @@ -12,7 +12,12 @@ import { import { DateTime } from "luxon"; import styles from "./Restreamer.module.scss"; import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime"; -import { KeyboardShortcuts, StreamTimeSettings, StreamVideoInfo } from "../common/video"; +import { + KeyboardShortcuts, + StreamTimeSettings, + StreamVideoInfo, + VideoControls, +} from "../common/video"; import "vidstack/player/styles/default/theme.css"; import "vidstack/player/styles/default/layouts/video.css"; @@ -94,6 +99,7 @@ const RestreamerWithDefaults: Component = (props) => { streamStartTime: DateTime.utc().minus({ minutes: 10 }), streamEndTime: null, }); + const [mediaPlayer, setMediaPlayer] = createSignal(); createEffect(() => { const info = streamVideoInfo(); @@ -116,6 +122,12 @@ const RestreamerWithDefaults: Component = (props) => { return url; }; + createEffect(() => { + const player = mediaPlayer(); + const srcURL = videoURL(); + player.src = srcURL; + }); + return ( <> = (props) => { errorList={props.errorList} setErrorList={props.setErrorList} /> - + + ); };