Compare commits

..

5 Commits

Author SHA1 Message Date
ElementalAlchemist 4298937535 Fix losing milliseconds sometimes 2 weeks ago
ElementalAlchemist 5143d15f68 Fix time ago conversion occurring in the wrong direction 2 weeks ago
ElementalAlchemist 0fd6a09b1c Store player settings 2 weeks ago
ElementalAlchemist 2095880d10 Switch to more native video controls 2 weeks ago
ElementalAlchemist bb85eb494d Remove the default video controls 2 weeks ago

@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"hls.js": "1.5.17", "hls.js": "1.5.17",
"luxon": "3.4.4", "luxon": "3.4.4",
"media-icons": "1.1.5",
"solid-js": "1.9.3", "solid-js": "1.9.3",
"vidstack": "1.12.12" "vidstack": "1.12.12"
} }

@ -37,7 +37,7 @@ function dateTimeMathObjectFromBusTime(busTime: string): DateTimeMathObject | nu
const parts = busTime.split(":", 3); const parts = busTime.split(":", 3);
const hours = parseInt(parts[0], 10) * direction; const hours = parseInt(parts[0], 10) * direction;
const minutes = parts.length > 1 ? parseInt(parts[1], 10) * direction : 0; const minutes = parts.length > 1 ? parseInt(parts[1], 10) * direction : 0;
const seconds = parts.length > 2 ? parseInt(parts[2], 10) * direction : 0; const seconds = parts.length > 2 ? +parts[2] * direction : 0;
return { hours: hours, minutes: minutes, seconds: seconds }; return { hours: hours, minutes: minutes, seconds: seconds };
} }
@ -69,7 +69,7 @@ export function dateTimeFromTimeAgo(timeAgo: string): DateTime | null {
return null; return null;
} }
const nextProp = properties.pop(); const nextProp = properties.pop();
const partNumber = parseInt(nextPart, 10); const partNumber = +nextPart;
if (isNaN(partNumber)) { if (isNaN(partNumber)) {
return null; return null;
} }
@ -77,7 +77,7 @@ export function dateTimeFromTimeAgo(timeAgo: string): DateTime | null {
} }
const now = DateTime.utc(); const now = DateTime.utc();
return now.plus(mathObj); return now.minus(mathObj);
} }
export function timeAgoFromDateTime(dateTime: DateTime): string { export function timeAgoFromDateTime(dateTime: DateTime): string {
@ -91,7 +91,7 @@ export function timeAgoFromDateTime(dateTime: DateTime): string {
timeAgoSeconds = -timeAgoSeconds; timeAgoSeconds = -timeAgoSeconds;
} }
const seconds = (((timeAgoSeconds % 60) * 1000) | 0) / 1000; const seconds = Math.floor((timeAgoSeconds % 60) * 1000) / 1000;
const secondsString = seconds < 10 ? `0${seconds}` : seconds.toString(); const secondsString = seconds < 10 ? `0${seconds}` : seconds.toString();
const minutes = (timeAgoSeconds / 60) % 60 | 0; const minutes = (timeAgoSeconds / 60) % 60 | 0;
const minutesString = minutes < 10 ? `0${minutes}` : minutes.toString(); const minutesString = minutes < 10 ? `0${minutes}` : minutes.toString();

@ -9,50 +9,3 @@
.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%;
}

@ -0,0 +1,29 @@
// Used to make the controls appear below the video player
media-player {
flex-direction: column;
}
media-provider, media-captions, video {
max-width: 100%;
max-height: 50vh;
}
// Used to make the controls appear below the video player
media-player:not([data-fullscreen]) media-controls {
position: relative;
height: auto;
}
media-controls-group {
display: flex;
align-items: center;
width: 100%;
}
media-volume-slider {
flex-basis: 100px;
}
.vds-slider-track-fill {
background-color: #f6f6f6;
}

@ -5,6 +5,7 @@ import {
createSignal, createSignal,
For, For,
onCleanup, onCleanup,
onMount,
Setter, Setter,
Show, Show,
} from "solid-js"; } from "solid-js";
@ -19,14 +20,15 @@ import {
dateTimeFromTimeAgo, dateTimeFromTimeAgo,
} from "./convertTime"; } from "./convertTime";
import styles from "./video.module.scss"; import styles from "./video.module.scss";
import "./video.scss";
import { MediaPlayerElement } from "vidstack/elements"; import { MediaPlayerElement } from "vidstack/elements";
import { VideoQuality } from "vidstack";
import playImage from "../images/video-controls/play.png"; import "vidstack/icons";
import pauseImage from "../images/video-controls/pause.png"; import "vidstack/player/styles/default/theme.css";
import volumeImage from "../images/video-controls/volume.png"; import "vidstack/player/styles/default/layouts/video.css";
import volumeMuteImage from "../images/video-controls/volume-mute.png"; import "vidstack/player";
import fullscreenImage from "../images/video-controls/fullscreen.png"; import "vidstack/player/layouts/default";
import "vidstack/player/ui";
export const VIDEO_FRAMES_PER_SECOND = 30; export const VIDEO_FRAMES_PER_SECOND = 30;
@ -196,158 +198,156 @@ export const StreamTimeSettings: Component<StreamTimeSettingsProps> = (props) =>
); );
}; };
export interface VideoControlsProps { export interface VideoPlayerProps {
mediaPlayer: Accessor<MediaPlayerElement>; src: Accessor<string>;
} }
export const VideoControls: Component<VideoControlsProps> = (props) => { export const VideoPlayer: Component<VideoPlayerProps> = (props) => {
const mediaPlayer = props.mediaPlayer(); let [mediaPlayer, setMediaPlayer] = createSignal<MediaPlayerElement>();
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(() => { createEffect(() => {
const player = props.mediaPlayer(); const player = mediaPlayer();
if (isFullscreen() && !player.controls.showing) { const srcURL = props.src();
player.controls.show(); player.src = srcURL;
} else if (!isFullscreen() && player.controls.showing) {
player.controls.hide();
}
}); });
onCleanup(() => unsubscribe()); let [playerTime, setPlayerTime] = createSignal(0);
let [duration, setDuration] = createSignal(0);
onMount(() => {
const player = mediaPlayer();
player.subscribe(({ currentTime, duration }) => {
setPlayerTime(currentTime);
setDuration(duration);
});
player.streamType = "on-demand";
});
const timeDisplay = (time: number) => { // The <media-time> elements provided by vidstack don't show milliseconds, so
// we need to run our own for millisecond display.
const formatTime = (time: number) => {
const hours = Math.floor(time / 3600); const hours = Math.floor(time / 3600);
const minutes = Math.floor((time / 60) % 60); const minutes = Math.floor((time / 60) % 60);
const seconds = Math.floor((time % 60) * 1000) / 1000; const milliseconds = Math.floor((time % 1) * 1000);
const seconds = Math.floor(time % 60);
const minutesDisplay = minutes < 10 ? `0${minutes}` : minutes.toString(); const minutesDisplay = minutes.toString().padStart(2, "0");
const secondsDisplay = seconds < 10 ? `0${seconds}` : seconds.toString(); const secondsDisplay = seconds.toString().padStart(2, "0");
const millisecondsDisplay = milliseconds.toString().padStart(3, "0");
if (hours === 0) { if (hours === 0) {
return `${minutesDisplay}:${secondsDisplay}`; return `${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
} }
return `${hours}:${minutesDisplay}:${secondsDisplay}`; return `${hours}:${minutesDisplay}:${secondsDisplay}.${millisecondsDisplay}`;
}; };
const playerTimeDisplay = () => timeDisplay(playerTime());
const durationDisplay = () => timeDisplay(duration());
return ( return (
<div class={styles.videoControls}> <media-player
<div class={styles.videoControlsBar}> src={props.src()}
<div> ref={setMediaPlayer}
<img preload="auto"
src={isPlaying() ? pauseImage : playImage} controlsDelay={0}
alt={isPlaying() ? "pause" : "play"} storage="thrimbletrimmer"
class="click" >
onClick={(event) => (props.mediaPlayer().paused = !props.mediaPlayer().paused)} <media-provider
/>
</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) => { onClick={(event) => {
const player = props.mediaPlayer(); const player = mediaPlayer();
const progressProportion = event.offsetX / event.currentTarget.offsetWidth; if (player.paused) {
const time = progressProportion * duration(); player.play(event);
player.currentTime = time; } else {
player.pause(event);
}
}} }}
/> />
</div> <media-captions class="vds-captions" />
<media-controls class="vds-controls">
<media-controls-group class="vds-controls-group">
<media-tooltip>
<media-tooltip-trigger>
<media-play-button class="vds-button">
<media-icon type="play" class="vds-play-icon" />
<media-icon type="pause" class="vds-pause-icon" />
</media-play-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-play-tooltip-text">Play</span>
<span class="vds-pause-tooltip-text">Pause</span>
</media-tooltip-content>
</media-tooltip>
<media-tooltip>
<media-tooltip-trigger>
<media-mute-button class="vds-button">
<media-icon type="mute" class="vds-mute-icon" />
<media-icon type="volume-low" class="vds-volume-low-icon" />
<media-icon type="volume-high" class="vds-volume-high-icon" />
</media-mute-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-mute-tooltip-text">Unmute</span>
<span class="vds-unmute-tooltip-text">Mute</span>
</media-tooltip-content>
</media-tooltip>
<media-volume-slider class="vds-slider">
<div class="vds-slider-track"></div>
<div class="vds-slider-track vds-slider-track-fill"></div>
<media-slider-preview class="vds-slider-preview">
<media-slider-value class="vds-slider-value" />
</media-slider-preview>
<div class="vds-slider-thumb"></div>
</media-volume-slider>
<div>
<span>{formatTime(playerTime())}</span>
<span class="vds-time-divider">/</span>
<span>{formatTime(duration())}</span>
</div>
<div class="vds-controls-spacer"></div>
<media-tooltip>
<media-tooltip-trigger>
<media-caption-button class="vds-button">
<media-icon class="vds-cc-on-icon" type="closed-captions-on" />
<media-icon class="vds-cc-off-icon" type="closed-captions" />
</media-caption-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top">
<span class="vds-cc-on-tooltip-text">Turn Closed Captions Off</span>
<span class="vds-cc-off-tooltip-text">Turn Closed Captions On</span>
</media-tooltip-content>
</media-tooltip>
<media-tooltip>
<media-tooltip-trigger>
<media-fullscreen-button class="vds-button">
<media-icon class="vds-fs-enter-icon" type="fullscreen" />
<media-icon class="vds-fs-exit-icon" type="fullscreen-exit" />
</media-fullscreen-button>
</media-tooltip-trigger>
<media-tooltip-content class="vds-tooltip-content" placement="top end">
<span class="vds-fs-enter-tooltip-text">Enter Fullscreen</span>
<span class="vds-fs-exit-tooltip-text">Exit Fullscreen</span>
</media-tooltip-content>
</media-tooltip>
</media-controls-group>
<media-controls-group class="vds-controls-group">
<media-time-slider class="vds-time-slider vds-slider">
<media-slider-chapters class="vds-slider-chapters">
<template>
<div class="vds-slider-chapter">
<div class="vds-slider-track"></div>
<div class="vds-slider-track vds-slider-track-fill"></div>
</div>
</template>
</media-slider-chapters>
<media-slider-preview class="vds-slider-preview">
<media-slider-value class="vds-slider-value" />
</media-slider-preview>
</media-time-slider>
</media-controls-group>
</media-controls>
</media-player>
); );
}; };

@ -44,9 +44,3 @@ a,
.hidden { .hidden {
display: none; display: none;
} }
media-player,
video {
max-width: 100%;
max-height: 50vh;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 747 B

@ -16,16 +16,9 @@ import {
KeyboardShortcuts, KeyboardShortcuts,
StreamTimeSettings, StreamTimeSettings,
StreamVideoInfo, StreamVideoInfo,
VideoControls, VideoPlayer,
} from "../common/video"; } from "../common/video";
import "vidstack/player/styles/default/theme.css";
import "vidstack/player/styles/default/layouts/video.css";
import "vidstack/player";
import "vidstack/player/layouts/default";
import "vidstack/player/ui";
import { MediaPlayerElement } from "vidstack/elements";
export interface DefaultsData { export interface DefaultsData {
video_channel: string; video_channel: string;
bustime_start: string; bustime_start: string;
@ -99,12 +92,6 @@ 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(() => {
const info = streamVideoInfo();
console.log(info);
});
const videoURL = () => { const videoURL = () => {
const streamInfo = streamVideoInfo(); const streamInfo = streamVideoInfo();
@ -122,12 +109,6 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
return url; return url;
}; };
createEffect(() => {
const player = mediaPlayer();
const srcURL = videoURL();
player.src = srcURL;
});
return ( return (
<> <>
<StreamTimeSettings <StreamTimeSettings
@ -138,11 +119,7 @@ const RestreamerWithDefaults: Component<RestreamerDefaultProps> = (props) => {
errorList={props.errorList} errorList={props.errorList}
setErrorList={props.setErrorList} setErrorList={props.setErrorList}
/> />
<media-player src={videoURL()} preload="auto" ref={setMediaPlayer}> <VideoPlayer src={videoURL} />
<media-provider />
<media-video-layout />
</media-player>
<VideoControls mediaPlayer={mediaPlayer} />
</> </>
); );
}; };

Loading…
Cancel
Save