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.
ElementalAlchemist 10 months ago
parent aeebf0b7ad
commit aa649ac4ac

@ -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%;
}

@ -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<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 {
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 {
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 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<RestreamerDefaultProps> = (props) => {
streamStartTime: DateTime.utc().minus({ minutes: 10 }),
streamEndTime: null,
});
const [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
createEffect(() => {
const info = streamVideoInfo();
@ -116,6 +122,12 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
return url;
};
createEffect(() => {
const player = mediaPlayer();
const srcURL = videoURL();
player.src = srcURL;
});
return (
<>
<StreamTimeSettings
@ -126,10 +138,11 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
errorList={props.errorList}
setErrorList={props.setErrorList}
/>
<media-player src={videoURL()} preload="auto">
<media-player src={videoURL()} preload="auto" ref={setMediaPlayer}>
<media-provider />
<media-video-layout />
</media-player>
<VideoControls mediaPlayer={mediaPlayer} />
</>
);
};

Loading…
Cancel
Save