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 0000000..664bc2a Binary files /dev/null and b/thrimbletrimmer/src/images/video-controls/fullscreen.png differ diff --git a/thrimbletrimmer/src/images/video-controls/pause.png b/thrimbletrimmer/src/images/video-controls/pause.png new file mode 100644 index 0000000..8de238f Binary files /dev/null and b/thrimbletrimmer/src/images/video-controls/pause.png differ diff --git a/thrimbletrimmer/src/images/video-controls/play.png b/thrimbletrimmer/src/images/video-controls/play.png new file mode 100644 index 0000000..30d233a Binary files /dev/null and b/thrimbletrimmer/src/images/video-controls/play.png differ diff --git a/thrimbletrimmer/src/images/video-controls/volume-mute.png b/thrimbletrimmer/src/images/video-controls/volume-mute.png new file mode 100644 index 0000000..1f907e4 Binary files /dev/null and b/thrimbletrimmer/src/images/video-controls/volume-mute.png differ diff --git a/thrimbletrimmer/src/images/video-controls/volume.png b/thrimbletrimmer/src/images/video-controls/volume.png new file mode 100644 index 0000000..aeb1254 Binary files /dev/null and b/thrimbletrimmer/src/images/video-controls/volume.png differ 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} /> - + + ); };