diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts
index 303bda5d..1fe5f9c1 100644
--- a/src/components/player/display/base.ts
+++ b/src/components/player/display/base.ts
@@ -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
();
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;
+ },
};
}
diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts
index a54207b6..a7a9d4b7 100644
--- a/src/components/player/display/displayInterface.ts
+++ b/src/components/player/display/displayInterface.ts
@@ -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 {
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 {
setTime(t: number): void;
destroy(): void;
startAirplay(): void;
+ setPlaybackRate(rate: number): void;
}
diff --git a/src/components/player/internals/ContextUtils.tsx b/src/components/player/internals/ContextUtils.tsx
index 5a2e00f4..6e8c2025 100644
--- a/src/components/player/internals/ContextUtils.tsx
+++ b/src/components/player/internals/ContextUtils.tsx
@@ -135,7 +135,7 @@ function IconButton(props: { icon: Icons; onClick?: () => void }) {
}
function Divider() {
- return
;
+ return
;
}
function SmallText(props: { children: React.ReactNode }) {
diff --git a/src/components/player/internals/KeyboardEvents.tsx b/src/components/player/internals/KeyboardEvents.tsx
new file mode 100644
index 00000000..ed29ce60
--- /dev/null
+++ b/src/components/player/internals/KeyboardEvents.tsx
@@ -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 | 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;
+}
diff --git a/src/pages/parts/player/PlayerPart.tsx b/src/pages/parts/player/PlayerPart.tsx
index 3d6973f4..5f18c2c7 100644
--- a/src/pages/parts/player/PlayerPart.tsx
+++ b/src/pages/parts/player/PlayerPart.tsx
@@ -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) {
- {status === "playing" ? (
+ {status === playerStatus.PLAYING ? (
@@ -78,6 +78,8 @@ export function PlayerPart(props: PlayerPartProps) {
+
+