quality selection HLS, keyboard shortcuts, playback settings

This commit is contained in:
mrjvs 2023-10-19 19:27:21 +02:00
parent 6aa79c64c8
commit 2c38e8281c
20 changed files with 384 additions and 29 deletions

View File

@ -17,6 +17,7 @@ import { usePlayerStore } from "@/stores/player/store";
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
import { CaptionsView } from "./settings/CaptionsView";
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
import { QualityView } from "./settings/QualityView";
function SettingsOverlay({ id }: { id: string }) {
@ -39,7 +40,7 @@ function SettingsOverlay({ id }: { id: string }) {
<OverlayPage id={id} path="/" width={343} height={431}>
<SettingsMenu id={id} />
</OverlayPage>
<OverlayPage id={id} path="/quality" width={343} height={431}>
<OverlayPage id={id} path="/quality" width={343} height={400}>
<Context.Card>
<QualityView id={id} />
</Context.Card>
@ -64,6 +65,11 @@ function SettingsOverlay({ id }: { id: string }) {
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/playback" width={343} height={215}>
<Context.Card>
<PlaybackSettingsView id={id} />
</Context.Card>
</OverlayPage>
</OverlayRouter>
</Overlay>
);

View File

@ -0,0 +1,43 @@
import { Icon, Icons } from "@/components/Icon";
import { Transition } from "@/components/Transition";
import { Flare } from "@/components/utils/Flare";
import { usePlayerStore } from "@/stores/player/store";
import { useEmpheralVolumeStore } from "@/stores/volume";
export function VolumeChangedPopout() {
const empheralVolume = useEmpheralVolumeStore();
const volume = usePlayerStore((s) => s.mediaPlaying.volume);
return (
<Transition
animation="slide-down"
show={empheralVolume.showVolume}
className="absolute inset-x-0 top-4 flex justify-center"
>
<Flare.Base className="hover:flare-enabled bg-video-context-background pl-4 pr-6 py-3 group w-72 h-full rounded-lg transition-colors text-video-context-type-main">
<Flare.Light
enabled
flareSize={200}
cssColorVar="--colors-video-context-light"
backgroundClass="bg-video-context-background duration-100"
className="rounded-lg"
/>
<Flare.Child className="grid grid-cols-[auto,1fr] gap-3 pointer-events-auto relative transition-transform">
<Icon
className="text-2xl"
icon={volume > 0 ? Icons.VOLUME : Icons.VOLUME_X}
/>
<div className="w-full flex items-center">
<div className="w-full h-1.5 rounded-full bg-video-context-slider bg-opacity-25">
<div
className="h-full bg-video-context-sliderFilled rounded-full transition-[width] duration-100"
style={{ width: `${volume * 100}%` }}
/>
</div>
</div>
</Flare.Child>
</Flare.Base>
</Transition>
);
}

View File

@ -11,3 +11,4 @@ export * from "./EpisodeTitle";
export * from "./Settings";
export * from "./Episodes";
export * from "./Airplay";
export * from "./VolumeChangedPopout";

View File

@ -43,7 +43,6 @@ function CaptionSetting(props: {
const inputRef = useRef<HTMLInputElement | null>(null);
const ref = useRef<HTMLDivElement>(null);
// 200 - 100 150 - 100
const currentPercentage = (props.value - props.min) / (props.max - props.min);
const commit = useCallback(
(percentage) => {
@ -173,7 +172,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
<CaptionSetting
label="Text size"
max={200}
min={10}
min={1}
textTransformer={(s) => `${s}%`}
onChange={(v) => updateStyling({ size: v / 100 })}
value={styling.size * 100}

View File

@ -0,0 +1,67 @@
import classNames from "classnames";
import { useCallback } from "react";
import { Context } from "@/components/player/internals/ContextUtils";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
function ButtonList(props: {
options: number[];
selected: number;
onClick: (v: any) => void;
}) {
return (
<div className="flex items-center bg-video-context-buttons-list p-1 rounded-lg">
{props.options.map((option) => {
return (
<button
type="button"
className={classNames(
"w-full px-2 py-1 rounded-md",
props.selected === option
? "bg-video-context-buttons-active text-white"
: null
)}
onClick={() => props.onClick(option)}
key={option}
>
{option}x
</button>
);
})}
</div>
);
}
export function PlaybackSettingsView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const playbackRate = usePlayerStore((s) => s.mediaPlaying.playbackRate);
const display = usePlayerStore((s) => s.display);
const setPlaybackRate = useCallback(
(v: number) => {
display?.setPlaybackRate(v);
},
[display]
);
const options = [0.25, 0.5, 1, 1.25, 2];
return (
<>
<Context.BackLink onClick={() => router.navigate("/")}>
Playback settings
</Context.BackLink>
<Context.Section>
<div className="space-y-4 mt-3">
<Context.FieldTitle>Playback speed</Context.FieldTitle>
<ButtonList
options={options}
selected={playbackRate}
onClick={setPlaybackRate}
/>
</div>
</Context.Section>
</>
);
}

View File

@ -43,20 +43,29 @@ export function QualityView({ id }: { id: string }) {
const availableQualities = usePlayerStore((s) => s.qualities);
const currentQuality = usePlayerStore((s) => s.currentQuality);
const switchQuality = usePlayerStore((s) => s.switchQuality);
const enableAutomaticQuality = usePlayerStore(
(s) => s.enableAutomaticQuality
);
const setAutomaticQuality = useQualityStore((s) => s.setAutomaticQuality);
const setLastChosenQuality = useQualityStore((s) => s.setLastChosenQuality);
const autoQuality = useQualityStore((s) => s.quality.automaticQuality);
const change = useCallback(
(q: SourceQuality) => {
switchQuality(q);
setLastChosenQuality(q);
setAutomaticQuality(false);
switchQuality(q);
router.close();
},
[router, switchQuality, setLastChosenQuality, setAutomaticQuality]
);
const changeAutomatic = useCallback(() => {
const newValue = !autoQuality;
setAutomaticQuality(newValue);
if (newValue) enableAutomaticQuality();
}, [setAutomaticQuality, autoQuality, enableAutomaticQuality]);
const allVisibleQualities = allQualities.filter((t) => t !== "unknown");
return (
@ -80,10 +89,7 @@ export function QualityView({ id }: { id: string }) {
<Context.Divider />
<Context.Link>
<Context.LinkTitle>Automatic quality</Context.LinkTitle>
<Toggle
onClick={() => setAutomaticQuality(!autoQuality)}
enabled={autoQuality}
/>
<Toggle onClick={changeAutomatic} enabled={autoQuality} />
</Context.Link>
<Context.SmallText>
You can try{" "}

View File

@ -69,7 +69,7 @@ export function SettingsMenu({ id }: { id: string }) {
{selectedCaptionLanguage ?? ""}
</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.Link onClick={() => router.navigate("/playback")}>
<Context.LinkTitle>Playback settings</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>

View File

@ -2,6 +2,7 @@ import { ReactNode, RefObject, useEffect, useRef } from "react";
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents";
import { ProgressSaver } from "@/components/player/internals/ProgressSaver";
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
import { VideoContainer } from "@/components/player/internals/VideoContainer";
@ -82,6 +83,7 @@ export function Container(props: PlayerProps) {
<BaseContainer>
<VideoContainer />
<ProgressSaver />
<KeyboardEvents />
<div className="relative h-screen overflow-hidden">
<VideoClickTarget />
<HeadUpdater />

View File

@ -6,7 +6,11 @@ import {
DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface";
import { handleBuffered } from "@/components/player/utils/handleBuffered";
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
import {
LoadableSource,
SourceQuality,
getPreferredQuality,
} from "@/stores/player/utils/qualities";
import {
canChangeVolume,
canFullscreen,
@ -26,6 +30,18 @@ function hlsLevelToQuality(level: Level): SourceQuality | null {
return levelConversionMap[level.height] ?? null;
}
function qualityToHlsLevel(quality: SourceQuality): number | null {
const found = Object.entries(levelConversionMap).find(
(entry) => entry[1] === quality
);
return found ? +found[0] : null;
}
function hlsLevelsToQualities(levels: Level[]): SourceQuality[] {
return levels
.map((v) => hlsLevelToQuality(v))
.filter((v): v is SourceQuality => !!v);
}
export function makeVideoElementDisplayInterface(): DisplayInterface {
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
let source: LoadableSource | null = null;
@ -36,6 +52,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
let isPausedBeforeSeeking = false;
let isSeeking = false;
let startAt = 0;
let automaticQuality = false;
let preferenceQuality: SourceQuality | null = null;
function reportLevels() {
if (!hls) return;
@ -46,6 +64,34 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
emit("qualities", convertedLevels);
}
function setupQualityForHls() {
if (!hls) return;
if (!automaticQuality) {
const qualities = hlsLevelsToQualities(hls.levels);
const availableQuality = getPreferredQuality(qualities, {
lastChosenQuality: preferenceQuality,
automaticQuality,
});
if (availableQuality) {
const levelIndex = hls.levels.findIndex(
(v) => v.height === qualityToHlsLevel(availableQuality)
);
if (levelIndex !== -1) {
console.log("setting level", levelIndex, availableQuality);
hls.currentLevel = levelIndex;
hls.loadLevel = levelIndex;
}
}
} else {
console.log("setting to automatic");
hls.currentLevel = -1;
hls.loadLevel = -1;
}
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
console.log("updating quality menu", quality);
emit("changedquality", quality);
}
function setupSource(vid: HTMLVideoElement, src: LoadableSource) {
if (src.type === "hls") {
if (!Hls.isSupported()) throw new Error("HLS not supported");
@ -63,12 +109,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
hls.on(Hls.Events.MANIFEST_LOADED, () => {
if (!hls) return;
reportLevels();
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
emit("changedquality", quality);
setupQualityForHls();
});
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
if (!hls) return;
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
console.log("EVENT updating quality menu", quality);
emit("changedquality", quality);
});
}
@ -124,6 +170,9 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
}
}
);
videoElement.addEventListener("ratechange", () => {
if (videoElement) emit("playbackrate", videoElement.playbackRate);
});
}
function unloadSource() {
@ -157,13 +206,21 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
destroyVideoElement();
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
},
load(newSource, startAtInput) {
if (!newSource) unloadSource();
source = newSource;
load(ops) {
if (!ops.source) unloadSource();
automaticQuality = ops.automaticQuality;
preferenceQuality = ops.preferredQuality;
source = ops.source;
emit("loading", true);
startAt = startAtInput;
startAt = ops.startAt;
setSource();
},
changeQuality(newAutomaticQuality, newPreferredQuality) {
if (source?.type !== "hls") return;
automaticQuality = newAutomaticQuality;
preferenceQuality = newPreferredQuality;
setupQualityForHls();
},
processVideoElement(video) {
destroyVideoElement();
@ -251,5 +308,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
videoPlayer.webkitShowPlaybackTargetPicker();
}
},
setPlaybackRate(rate) {
if (videoElement) videoElement.playbackRate = rate;
},
};
}

View File

@ -14,12 +14,24 @@ export type DisplayInterfaceEvents = {
changedquality: SourceQuality | null;
needstrack: boolean;
canairplay: boolean;
playbackrate: number;
};
export interface qualityChangeOptions {
source: LoadableSource | null;
automaticQuality: boolean;
preferredQuality: SourceQuality | null;
startAt: number;
}
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
play(): void;
pause(): void;
load(source: LoadableSource | null, startAt: number): void;
load(ops: qualityChangeOptions): void;
changeQuality(
automaticQuality: boolean,
preferredQuality: SourceQuality | null
): void;
processVideoElement(video: HTMLVideoElement): void;
processContainerElement(container: HTMLElement): void;
toggleFullscreen(): void;
@ -28,4 +40,5 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
setTime(t: number): void;
destroy(): void;
startAirplay(): void;
setPlaybackRate(rate: number): void;
}

View File

@ -135,7 +135,7 @@ function IconButton(props: { icon: Icons; onClick?: () => void }) {
}
function Divider() {
return <hr className="my-4 border-0 w-full h-px bg-video-context-border" />;
return <hr className="!my-4 border-0 w-full h-px bg-video-context-border" />;
}
function SmallText(props: { children: React.ReactNode }) {

View File

@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from "react";
import { useVolume } from "@/components/player/hooks/useVolume";
import { usePlayerStore } from "@/stores/player/store";
import { useEmpheralVolumeStore } from "@/stores/volume";
export function KeyboardEvents() {
const display = usePlayerStore((s) => s.display);
const mediaPlaying = usePlayerStore((s) => s.mediaPlaying);
const time = usePlayerStore((s) => s.progress.time);
const { setVolume, toggleMute } = useVolume();
const setShowVolume = useEmpheralVolumeStore((s) => s.setShowVolume);
const [isRolling, setIsRolling] = useState(false);
const volumeDebounce = useRef<ReturnType<typeof setTimeout> | undefined>();
const dataRef = useRef({
setShowVolume,
setVolume,
toggleMute,
setIsRolling,
display,
mediaPlaying,
isRolling,
time,
});
useEffect(() => {
dataRef.current = {
setShowVolume,
setVolume,
toggleMute,
setIsRolling,
display,
mediaPlaying,
isRolling,
time,
};
}, [
setShowVolume,
setVolume,
toggleMute,
setIsRolling,
display,
mediaPlaying,
isRolling,
time,
]);
useEffect(() => {
const keyEventHandler = (evt: KeyboardEvent) => {
const k = evt.key;
// Volume
if (["ArrowUp", "ArrowDown", "m"].includes(k)) {
dataRef.current.setShowVolume(true);
if (volumeDebounce.current) clearTimeout(volumeDebounce.current);
volumeDebounce.current = setTimeout(() => {
dataRef.current.setShowVolume(false);
}, 3e3);
}
if (k === "ArrowUp")
dataRef.current.setVolume(
(dataRef.current.mediaPlaying?.volume || 0) + 0.15
);
if (k === "ArrowDown")
dataRef.current.setVolume(
(dataRef.current.mediaPlaying?.volume || 0) - 0.15
);
if (k === "m") dataRef.current.toggleMute();
// Video progress
if (k === "ArrowRight")
dataRef.current.display?.setTime(dataRef.current.time + 5);
if (k === "ArrowLeft")
dataRef.current.display?.setTime(dataRef.current.time - 5);
// Utils
if (k === "f") dataRef.current.display?.toggleFullscreen();
if (k === " ")
dataRef.current.display?.[
dataRef.current.mediaPlaying.isPaused ? "play" : "pause"
]();
// Do a barrell roll!
if (k === "r") {
if (dataRef.current.isRolling || evt.ctrlKey || evt.metaKey) return;
dataRef.current.setIsRolling(true);
document.querySelector(".popout-location")?.classList.add("roll");
document.body.setAttribute("data-no-scroll", "true");
setTimeout(() => {
document.querySelector(".popout-location")?.classList.remove("roll");
document.body.removeAttribute("data-no-scroll");
dataRef.current.setIsRolling(false);
}, 1e3);
}
};
window.addEventListener("keydown", keyEventHandler);
return () => {
window.removeEventListener("keydown", keyEventHandler);
};
}, []);
return null;
}

View File

@ -3,7 +3,7 @@ import { ReactNode } from "react";
import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { PlayerMeta } from "@/stores/player/slices/source";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
export interface PlayerPartProps {
@ -23,7 +23,7 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.BlackOverlay show={showTargets} />
<Player.SubtitleView controlsShown={showTargets} />
{status === "playing" ? (
{status === playerStatus.PLAYING ? (
<Player.CenterControls>
<Player.LoadingSpinner />
<Player.AutoPlayStart />
@ -78,6 +78,8 @@ export function PlayerPart(props: PlayerPartProps) {
</div>
</div>
</Player.BottomControls>
<Player.VolumeChangedPopout />
</Player.Container>
);
}

View File

@ -85,13 +85,23 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
s.interface.canAirplay = canAirplay;
});
});
newDisplay.on("playbackrate", (rate) => {
set((s) => {
s.mediaPlaying.playbackRate = rate;
});
});
set((s) => {
s.display = newDisplay;
});
},
reset() {
get().display?.load(null, 0);
get().display?.load({
source: null,
startAt: 0,
automaticQuality: false,
preferredQuality: null,
});
set((s) => {
s.status = playerStatus.IDLE;
s.meta = null;

View File

@ -9,7 +9,7 @@ export interface PlayingSlice {
isLoading: boolean; // buffering or not
hasPlayedOnce: boolean; // has the video played at all?
volume: number;
playbackSpeed: number;
playbackRate: number;
};
play(): void;
pause(): void;
@ -22,10 +22,9 @@ export const createPlayingSlice: MakeSlice<PlayingSlice> = (set) => ({
isLoading: false,
isSeeking: false,
isDragSeeking: false,
isFirstLoading: true,
hasPlayedOnce: false,
volume: 1,
playbackSpeed: 1,
playbackRate: 1,
},
play() {
set((state) => {

View File

@ -59,6 +59,7 @@ export interface SourceSlice {
setMeta(meta: PlayerMeta): void;
setCaption(caption: Caption | null): void;
setSourceId(id: string | null): void;
enableAutomaticQuality(): void;
}
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
@ -128,7 +129,12 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
s.currentQuality = loadableStream.quality;
});
store.display?.load(loadableStream.stream, startAt);
store.display?.load({
source: loadableStream.stream,
startAt,
automaticQuality: qualityPreferences.quality.automaticQuality,
preferredQuality: qualityPreferences.quality.lastChosenQuality,
});
},
switchQuality(quality) {
const store = get();
@ -139,7 +145,18 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
set((s) => {
s.currentQuality = quality;
});
store.display?.load(selectedQuality, store.progress.time);
store.display?.load({
source: selectedQuality,
startAt: store.progress.time,
automaticQuality: false,
preferredQuality: quality,
});
} else if (store.source.type === "hls") {
store.display?.changeQuality(false, quality);
}
},
enableAutomaticQuality() {
const store = get();
store.display?.changeQuality(true, null);
},
});

View File

@ -35,7 +35,7 @@ const sortedQualities: SourceQuality[] = Object.entries(qualitySorting)
.sort((a, b) => b[1] - a[1])
.map<SourceQuality>((v) => v[0] as SourceQuality);
function getPreferredQuality(
export function getPreferredQuality(
availableQualites: SourceQuality[],
qualityPreferences: QualityStore["quality"]
) {

View File

@ -9,7 +9,7 @@ export interface SubtitleStyling {
color: string;
/**
* size percentage, ranges between 0 and 2
* size percentage, ranges between 0.01 and 2
*/
size: number;
@ -45,7 +45,7 @@ export const useSubtitleStore = create(
if (newStyling.color !== undefined)
s.styling.color = newStyling.color.toLowerCase();
if (newStyling.size !== undefined)
s.styling.size = Math.min(2, Math.max(0.1, newStyling.size));
s.styling.size = Math.min(2, Math.max(0.01, newStyling.size));
});
},
setLanguage(lang) {

View File

@ -7,6 +7,11 @@ export interface VolumeStore {
setVolume(v: number): void;
}
export interface EmpheralVolumeStore {
showVolume: boolean;
setShowVolume(v: boolean): void;
}
// TODO add migration from previous stored volume
export const useVolumeStore = create(
persist(
@ -23,3 +28,14 @@ export const useVolumeStore = create(
}
)
);
export const useEmpheralVolumeStore = create(
immer<EmpheralVolumeStore>((set) => ({
showVolume: false,
setShowVolume(bool: boolean) {
set((s) => {
s.showVolume = bool;
});
},
}))
);

View File

@ -142,6 +142,11 @@ module.exports = {
slider: "#8787A8",
sliderFilled: "#A75FC9",
buttons: {
list: "#161C26",
active: "#0D1317"
},
type: {
main: "#617A8A",
secondary: "#374A56",