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-solid
ElementalAlchemist 2 weeks ago
parent 9bfe472468
commit 1239321b86

@ -9,3 +9,50 @@
.streamTimeSettingLabel { .streamTimeSettingLabel {
margin-right: 3px; 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%;
}

@ -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 { DateTime } from "luxon";
import { import {
TimeType, TimeType,
@ -10,6 +19,14 @@ import {
dateTimeFromTimeAgo, dateTimeFromTimeAgo,
} from "./convertTime"; } from "./convertTime";
import styles from "./video.module.scss"; 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; export const VIDEO_FRAMES_PER_SECOND = 30;
@ -179,6 +196,161 @@ export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) =>
); );
}; };
export interface VideoControlsProps {
mediaPlayer: Accessor<MediaPlayerElement>;
}
export const VideoControls: Component<VideoControlsProps> = (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<VideoQuality | null>(
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 (
<div class={styles.videoControls}>
<div class={styles.videoControlsBar}>
<div>
<img
src={isPlaying() ? pauseImage : playImage}
alt={isPlaying() ? "pause" : "play"}
class="click"
onClick={(event) => (props.mediaPlayer().paused = !props.mediaPlayer().paused)}
/>
</div>
<div>
{playerTimeDisplay()} / {durationDisplay()}
</div>
<div class={styles.videoControlsSpacer}></div>
<div class={styles.videoControlsVolume}>
<img
src={isMuted() ? volumeMuteImage : volumeImage}
alt={isMuted() ? "muted" : "volume"}
class="click"
onClick={(event) => (props.mediaPlayer().muted = !props.mediaPlayer().muted)}
/>
<progress
value={volume()}
class={`click ${styles.videoControlsVolumeLevel}`}
onClick={(event) => {
const player = props.mediaPlayer();
player.volume = event.offsetX / event.currentTarget.offsetWidth;
}}
/>
</div>
<div>
<select
value={playbackRate()}
onSelect={(event) => (props.mediaPlayer().playbackRate = +event.currentTarget.value)}
>
<For each={PLAYBACK_RATES}>
{(item, index) => <option value={item}>{item}x</option>}
</For>
</select>
</div>
<div>
<select
value={qualityLevel() ? qualityLevel().id : ""}
onSelect={(event) =>
(props.mediaPlayer().qualities[event.currentTarget.selectedIndex].selected = true)
}
>
<For each={qualityLevelList()}>
{(item, index) => <option value={index()}>{item.id}</option>}
</For>
</select>
</div>
<div>
<img
src={fullscreenImage}
alt="fullscreen"
class="click"
onClick={(event) => {
const player = props.mediaPlayer();
if (!player.state.canFullscreen) {
return;
}
if (isFullscreen()) {
player.exitFullscreen();
} else {
player.requestFullscreen();
}
}}
/>
</div>
</div>
<progress
class={`click ${styles.videoControlsPlaybackPosition}`}
value={duration() === 0 ? 0 : (playerTime() / duration())}
onClick={(event) => {
const player = props.mediaPlayer();
const progressProportion = event.offsetX / event.currentTarget.offsetWidth;
const time = progressProportion * duration();
player.currentTime = time;
}}
/>
</div>
);
};
export interface KeyboardShortcutProps { export interface KeyboardShortcutProps {
includeEditorShortcuts: boolean; includeEditorShortcuts: boolean;
} }

@ -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 { .hidden {
display: none; display: none;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 B

@ -12,7 +12,12 @@ import {
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import styles from "./Restreamer.module.scss"; import styles from "./Restreamer.module.scss";
import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime"; 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/theme.css";
import "vidstack/player/styles/default/layouts/video.css"; import "vidstack/player/styles/default/layouts/video.css";
@ -94,6 +99,7 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
streamStartTime: DateTime.utc().minus({ minutes: 10 }), streamStartTime: DateTime.utc().minus({ minutes: 10 }),
streamEndTime: null, streamEndTime: null,
}); });
const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
createEffect(() => { createEffect(() => {
const info = streamVideoInfo(); const info = streamVideoInfo();
@ -116,6 +122,12 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
return url; return url;
}; };
createEffect(() => {
const player = mediaPlayer();
const srcURL = videoURL();
player.src = srcURL;
});
return ( return (
<> <>
<StreamTimeSettings <StreamTimeSettings
@ -126,10 +138,11 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
errorList={props.errorList} errorList={props.errorList}
setErrorList={props.setErrorList} setErrorList={props.setErrorList}
/> />
<media-player src={videoURL()} preload="auto"> <media-player src={videoURL()} preload="auto" ref={setMediaPlayer}>
<media-provider /> <media-provider />
<media-video-layout /> <media-video-layout />
</media-player> </media-player>
<VideoControls mediaPlayer={mediaPlayer} />
</> </>
); );
}; };

Loading…
Cancel
Save