mirror of
https://github.com/sussy-code/smov.git
synced 2025-01-20 02:21:25 +01:00
quality selection HLS, keyboard shortcuts, playback settings
This commit is contained in:
parent
6aa79c64c8
commit
2c38e8281c
20 changed files with 384 additions and 29 deletions
|
@ -17,6 +17,7 @@ import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
import { CaptionSettingsView } from "./settings/CaptionSettingsView";
|
||||||
import { CaptionsView } from "./settings/CaptionsView";
|
import { CaptionsView } from "./settings/CaptionsView";
|
||||||
|
import { PlaybackSettingsView } from "./settings/PlaybackSettingsView";
|
||||||
import { QualityView } from "./settings/QualityView";
|
import { QualityView } from "./settings/QualityView";
|
||||||
|
|
||||||
function SettingsOverlay({ id }: { id: string }) {
|
function SettingsOverlay({ id }: { id: string }) {
|
||||||
|
@ -39,7 +40,7 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<OverlayPage id={id} path="/" width={343} height={431}>
|
<OverlayPage id={id} path="/" width={343} height={431}>
|
||||||
<SettingsMenu id={id} />
|
<SettingsMenu id={id} />
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/quality" width={343} height={431}>
|
<OverlayPage id={id} path="/quality" width={343} height={400}>
|
||||||
<Context.Card>
|
<Context.Card>
|
||||||
<QualityView id={id} />
|
<QualityView id={id} />
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
|
@ -64,6 +65,11 @@ function SettingsOverlay({ id }: { id: string }) {
|
||||||
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
|
<EmbedSelectionView id={id} sourceId={chosenSourceId} />
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
|
<OverlayPage id={id} path="/playback" width={343} height={215}>
|
||||||
|
<Context.Card>
|
||||||
|
<PlaybackSettingsView id={id} />
|
||||||
|
</Context.Card>
|
||||||
|
</OverlayPage>
|
||||||
</OverlayRouter>
|
</OverlayRouter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
);
|
);
|
||||||
|
|
43
src/components/player/atoms/VolumeChangedPopout.tsx
Normal file
43
src/components/player/atoms/VolumeChangedPopout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -11,3 +11,4 @@ export * from "./EpisodeTitle";
|
||||||
export * from "./Settings";
|
export * from "./Settings";
|
||||||
export * from "./Episodes";
|
export * from "./Episodes";
|
||||||
export * from "./Airplay";
|
export * from "./Airplay";
|
||||||
|
export * from "./VolumeChangedPopout";
|
||||||
|
|
|
@ -43,7 +43,6 @@ function CaptionSetting(props: {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 200 - 100 150 - 100
|
|
||||||
const currentPercentage = (props.value - props.min) / (props.max - props.min);
|
const currentPercentage = (props.value - props.min) / (props.max - props.min);
|
||||||
const commit = useCallback(
|
const commit = useCallback(
|
||||||
(percentage) => {
|
(percentage) => {
|
||||||
|
@ -173,7 +172,7 @@ export function CaptionSettingsView({ id }: { id: string }) {
|
||||||
<CaptionSetting
|
<CaptionSetting
|
||||||
label="Text size"
|
label="Text size"
|
||||||
max={200}
|
max={200}
|
||||||
min={10}
|
min={1}
|
||||||
textTransformer={(s) => `${s}%`}
|
textTransformer={(s) => `${s}%`}
|
||||||
onChange={(v) => updateStyling({ size: v / 100 })}
|
onChange={(v) => updateStyling({ size: v / 100 })}
|
||||||
value={styling.size * 100}
|
value={styling.size * 100}
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -43,20 +43,29 @@ export function QualityView({ id }: { id: string }) {
|
||||||
const availableQualities = usePlayerStore((s) => s.qualities);
|
const availableQualities = usePlayerStore((s) => s.qualities);
|
||||||
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
||||||
const switchQuality = usePlayerStore((s) => s.switchQuality);
|
const switchQuality = usePlayerStore((s) => s.switchQuality);
|
||||||
|
const enableAutomaticQuality = usePlayerStore(
|
||||||
|
(s) => s.enableAutomaticQuality
|
||||||
|
);
|
||||||
const setAutomaticQuality = useQualityStore((s) => s.setAutomaticQuality);
|
const setAutomaticQuality = useQualityStore((s) => s.setAutomaticQuality);
|
||||||
const setLastChosenQuality = useQualityStore((s) => s.setLastChosenQuality);
|
const setLastChosenQuality = useQualityStore((s) => s.setLastChosenQuality);
|
||||||
const autoQuality = useQualityStore((s) => s.quality.automaticQuality);
|
const autoQuality = useQualityStore((s) => s.quality.automaticQuality);
|
||||||
|
|
||||||
const change = useCallback(
|
const change = useCallback(
|
||||||
(q: SourceQuality) => {
|
(q: SourceQuality) => {
|
||||||
switchQuality(q);
|
|
||||||
setLastChosenQuality(q);
|
setLastChosenQuality(q);
|
||||||
setAutomaticQuality(false);
|
setAutomaticQuality(false);
|
||||||
|
switchQuality(q);
|
||||||
router.close();
|
router.close();
|
||||||
},
|
},
|
||||||
[router, switchQuality, setLastChosenQuality, setAutomaticQuality]
|
[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");
|
const allVisibleQualities = allQualities.filter((t) => t !== "unknown");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -80,10 +89,7 @@ export function QualityView({ id }: { id: string }) {
|
||||||
<Context.Divider />
|
<Context.Divider />
|
||||||
<Context.Link>
|
<Context.Link>
|
||||||
<Context.LinkTitle>Automatic quality</Context.LinkTitle>
|
<Context.LinkTitle>Automatic quality</Context.LinkTitle>
|
||||||
<Toggle
|
<Toggle onClick={changeAutomatic} enabled={autoQuality} />
|
||||||
onClick={() => setAutomaticQuality(!autoQuality)}
|
|
||||||
enabled={autoQuality}
|
|
||||||
/>
|
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
<Context.SmallText>
|
<Context.SmallText>
|
||||||
You can try{" "}
|
You can try{" "}
|
||||||
|
|
|
@ -69,7 +69,7 @@ export function SettingsMenu({ id }: { id: string }) {
|
||||||
{selectedCaptionLanguage ?? ""}
|
{selectedCaptionLanguage ?? ""}
|
||||||
</Context.LinkChevron>
|
</Context.LinkChevron>
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
<Context.Link>
|
<Context.Link onClick={() => router.navigate("/playback")}>
|
||||||
<Context.LinkTitle>Playback settings</Context.LinkTitle>
|
<Context.LinkTitle>Playback settings</Context.LinkTitle>
|
||||||
<Context.LinkChevron />
|
<Context.LinkChevron />
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { ReactNode, RefObject, useEffect, useRef } from "react";
|
||||||
|
|
||||||
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
|
import { OverlayDisplay } from "@/components/overlays/OverlayDisplay";
|
||||||
import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
|
import { HeadUpdater } from "@/components/player/internals/HeadUpdater";
|
||||||
|
import { KeyboardEvents } from "@/components/player/internals/KeyboardEvents";
|
||||||
import { ProgressSaver } from "@/components/player/internals/ProgressSaver";
|
import { ProgressSaver } from "@/components/player/internals/ProgressSaver";
|
||||||
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
|
import { VideoClickTarget } from "@/components/player/internals/VideoClickTarget";
|
||||||
import { VideoContainer } from "@/components/player/internals/VideoContainer";
|
import { VideoContainer } from "@/components/player/internals/VideoContainer";
|
||||||
|
@ -82,6 +83,7 @@ export function Container(props: PlayerProps) {
|
||||||
<BaseContainer>
|
<BaseContainer>
|
||||||
<VideoContainer />
|
<VideoContainer />
|
||||||
<ProgressSaver />
|
<ProgressSaver />
|
||||||
|
<KeyboardEvents />
|
||||||
<div className="relative h-screen overflow-hidden">
|
<div className="relative h-screen overflow-hidden">
|
||||||
<VideoClickTarget />
|
<VideoClickTarget />
|
||||||
<HeadUpdater />
|
<HeadUpdater />
|
||||||
|
|
|
@ -6,7 +6,11 @@ import {
|
||||||
DisplayInterfaceEvents,
|
DisplayInterfaceEvents,
|
||||||
} from "@/components/player/display/displayInterface";
|
} from "@/components/player/display/displayInterface";
|
||||||
import { handleBuffered } from "@/components/player/utils/handleBuffered";
|
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 {
|
import {
|
||||||
canChangeVolume,
|
canChangeVolume,
|
||||||
canFullscreen,
|
canFullscreen,
|
||||||
|
@ -26,6 +30,18 @@ function hlsLevelToQuality(level: Level): SourceQuality | null {
|
||||||
return levelConversionMap[level.height] ?? 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 {
|
export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
|
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
|
||||||
let source: LoadableSource | null = null;
|
let source: LoadableSource | null = null;
|
||||||
|
@ -36,6 +52,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
let isPausedBeforeSeeking = false;
|
let isPausedBeforeSeeking = false;
|
||||||
let isSeeking = false;
|
let isSeeking = false;
|
||||||
let startAt = 0;
|
let startAt = 0;
|
||||||
|
let automaticQuality = false;
|
||||||
|
let preferenceQuality: SourceQuality | null = null;
|
||||||
|
|
||||||
function reportLevels() {
|
function reportLevels() {
|
||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
|
@ -46,6 +64,34 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
emit("qualities", convertedLevels);
|
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) {
|
function setupSource(vid: HTMLVideoElement, src: LoadableSource) {
|
||||||
if (src.type === "hls") {
|
if (src.type === "hls") {
|
||||||
if (!Hls.isSupported()) throw new Error("HLS not supported");
|
if (!Hls.isSupported()) throw new Error("HLS not supported");
|
||||||
|
@ -63,12 +109,12 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
hls.on(Hls.Events.MANIFEST_LOADED, () => {
|
hls.on(Hls.Events.MANIFEST_LOADED, () => {
|
||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
reportLevels();
|
reportLevels();
|
||||||
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
setupQualityForHls();
|
||||||
emit("changedquality", quality);
|
|
||||||
});
|
});
|
||||||
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
hls.on(Hls.Events.LEVEL_SWITCHED, () => {
|
||||||
if (!hls) return;
|
if (!hls) return;
|
||||||
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
|
||||||
|
console.log("EVENT updating quality menu", quality);
|
||||||
emit("changedquality", quality);
|
emit("changedquality", quality);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -124,6 +170,9 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
videoElement.addEventListener("ratechange", () => {
|
||||||
|
if (videoElement) emit("playbackrate", videoElement.playbackRate);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function unloadSource() {
|
function unloadSource() {
|
||||||
|
@ -157,13 +206,21 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
destroyVideoElement();
|
destroyVideoElement();
|
||||||
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
|
fscreen.removeEventListener("fullscreenchange", fullscreenChange);
|
||||||
},
|
},
|
||||||
load(newSource, startAtInput) {
|
load(ops) {
|
||||||
if (!newSource) unloadSource();
|
if (!ops.source) unloadSource();
|
||||||
source = newSource;
|
automaticQuality = ops.automaticQuality;
|
||||||
|
preferenceQuality = ops.preferredQuality;
|
||||||
|
source = ops.source;
|
||||||
emit("loading", true);
|
emit("loading", true);
|
||||||
startAt = startAtInput;
|
startAt = ops.startAt;
|
||||||
setSource();
|
setSource();
|
||||||
},
|
},
|
||||||
|
changeQuality(newAutomaticQuality, newPreferredQuality) {
|
||||||
|
if (source?.type !== "hls") return;
|
||||||
|
automaticQuality = newAutomaticQuality;
|
||||||
|
preferenceQuality = newPreferredQuality;
|
||||||
|
setupQualityForHls();
|
||||||
|
},
|
||||||
|
|
||||||
processVideoElement(video) {
|
processVideoElement(video) {
|
||||||
destroyVideoElement();
|
destroyVideoElement();
|
||||||
|
@ -251,5 +308,8 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
videoPlayer.webkitShowPlaybackTargetPicker();
|
videoPlayer.webkitShowPlaybackTargetPicker();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setPlaybackRate(rate) {
|
||||||
|
if (videoElement) videoElement.playbackRate = rate;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,12 +14,24 @@ export type DisplayInterfaceEvents = {
|
||||||
changedquality: SourceQuality | null;
|
changedquality: SourceQuality | null;
|
||||||
needstrack: boolean;
|
needstrack: boolean;
|
||||||
canairplay: boolean;
|
canairplay: boolean;
|
||||||
|
playbackrate: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface qualityChangeOptions {
|
||||||
|
source: LoadableSource | null;
|
||||||
|
automaticQuality: boolean;
|
||||||
|
preferredQuality: SourceQuality | null;
|
||||||
|
startAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
||||||
play(): void;
|
play(): void;
|
||||||
pause(): 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;
|
processVideoElement(video: HTMLVideoElement): void;
|
||||||
processContainerElement(container: HTMLElement): void;
|
processContainerElement(container: HTMLElement): void;
|
||||||
toggleFullscreen(): void;
|
toggleFullscreen(): void;
|
||||||
|
@ -28,4 +40,5 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
||||||
setTime(t: number): void;
|
setTime(t: number): void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
startAirplay(): void;
|
startAirplay(): void;
|
||||||
|
setPlaybackRate(rate: number): void;
|
||||||
}
|
}
|
||||||
|
|
|
@ -135,7 +135,7 @@ function IconButton(props: { icon: Icons; onClick?: () => void }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function Divider() {
|
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 }) {
|
function SmallText(props: { children: React.ReactNode }) {
|
||||||
|
|
109
src/components/player/internals/KeyboardEvents.tsx
Normal file
109
src/components/player/internals/KeyboardEvents.tsx
Normal 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;
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { ReactNode } from "react";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
import { Player } from "@/components/player";
|
import { Player } from "@/components/player";
|
||||||
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
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";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
export interface PlayerPartProps {
|
export interface PlayerPartProps {
|
||||||
|
@ -23,7 +23,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
<Player.BlackOverlay show={showTargets} />
|
<Player.BlackOverlay show={showTargets} />
|
||||||
<Player.SubtitleView controlsShown={showTargets} />
|
<Player.SubtitleView controlsShown={showTargets} />
|
||||||
|
|
||||||
{status === "playing" ? (
|
{status === playerStatus.PLAYING ? (
|
||||||
<Player.CenterControls>
|
<Player.CenterControls>
|
||||||
<Player.LoadingSpinner />
|
<Player.LoadingSpinner />
|
||||||
<Player.AutoPlayStart />
|
<Player.AutoPlayStart />
|
||||||
|
@ -78,6 +78,8 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Player.BottomControls>
|
</Player.BottomControls>
|
||||||
|
|
||||||
|
<Player.VolumeChangedPopout />
|
||||||
</Player.Container>
|
</Player.Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -85,13 +85,23 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
|
||||||
s.interface.canAirplay = canAirplay;
|
s.interface.canAirplay = canAirplay;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
newDisplay.on("playbackrate", (rate) => {
|
||||||
|
set((s) => {
|
||||||
|
s.mediaPlaying.playbackRate = rate;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.display = newDisplay;
|
s.display = newDisplay;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
get().display?.load(null, 0);
|
get().display?.load({
|
||||||
|
source: null,
|
||||||
|
startAt: 0,
|
||||||
|
automaticQuality: false,
|
||||||
|
preferredQuality: null,
|
||||||
|
});
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.status = playerStatus.IDLE;
|
s.status = playerStatus.IDLE;
|
||||||
s.meta = null;
|
s.meta = null;
|
||||||
|
|
|
@ -9,7 +9,7 @@ export interface PlayingSlice {
|
||||||
isLoading: boolean; // buffering or not
|
isLoading: boolean; // buffering or not
|
||||||
hasPlayedOnce: boolean; // has the video played at all?
|
hasPlayedOnce: boolean; // has the video played at all?
|
||||||
volume: number;
|
volume: number;
|
||||||
playbackSpeed: number;
|
playbackRate: number;
|
||||||
};
|
};
|
||||||
play(): void;
|
play(): void;
|
||||||
pause(): void;
|
pause(): void;
|
||||||
|
@ -22,10 +22,9 @@ export const createPlayingSlice: MakeSlice<PlayingSlice> = (set) => ({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSeeking: false,
|
isSeeking: false,
|
||||||
isDragSeeking: false,
|
isDragSeeking: false,
|
||||||
isFirstLoading: true,
|
|
||||||
hasPlayedOnce: false,
|
hasPlayedOnce: false,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
playbackSpeed: 1,
|
playbackRate: 1,
|
||||||
},
|
},
|
||||||
play() {
|
play() {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
|
|
@ -59,6 +59,7 @@ export interface SourceSlice {
|
||||||
setMeta(meta: PlayerMeta): void;
|
setMeta(meta: PlayerMeta): void;
|
||||||
setCaption(caption: Caption | null): void;
|
setCaption(caption: Caption | null): void;
|
||||||
setSourceId(id: string | null): void;
|
setSourceId(id: string | null): void;
|
||||||
|
enableAutomaticQuality(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
|
||||||
|
@ -128,7 +129,12 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
s.currentQuality = loadableStream.quality;
|
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) {
|
switchQuality(quality) {
|
||||||
const store = get();
|
const store = get();
|
||||||
|
@ -139,7 +145,18 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.currentQuality = quality;
|
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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -35,7 +35,7 @@ const sortedQualities: SourceQuality[] = Object.entries(qualitySorting)
|
||||||
.sort((a, b) => b[1] - a[1])
|
.sort((a, b) => b[1] - a[1])
|
||||||
.map<SourceQuality>((v) => v[0] as SourceQuality);
|
.map<SourceQuality>((v) => v[0] as SourceQuality);
|
||||||
|
|
||||||
function getPreferredQuality(
|
export function getPreferredQuality(
|
||||||
availableQualites: SourceQuality[],
|
availableQualites: SourceQuality[],
|
||||||
qualityPreferences: QualityStore["quality"]
|
qualityPreferences: QualityStore["quality"]
|
||||||
) {
|
) {
|
||||||
|
|
|
@ -9,7 +9,7 @@ export interface SubtitleStyling {
|
||||||
color: string;
|
color: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* size percentage, ranges between 0 and 2
|
* size percentage, ranges between 0.01 and 2
|
||||||
*/
|
*/
|
||||||
size: number;
|
size: number;
|
||||||
|
|
||||||
|
@ -45,7 +45,7 @@ export const useSubtitleStore = create(
|
||||||
if (newStyling.color !== undefined)
|
if (newStyling.color !== undefined)
|
||||||
s.styling.color = newStyling.color.toLowerCase();
|
s.styling.color = newStyling.color.toLowerCase();
|
||||||
if (newStyling.size !== undefined)
|
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) {
|
setLanguage(lang) {
|
||||||
|
|
|
@ -7,6 +7,11 @@ export interface VolumeStore {
|
||||||
setVolume(v: number): void;
|
setVolume(v: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmpheralVolumeStore {
|
||||||
|
showVolume: boolean;
|
||||||
|
setShowVolume(v: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO add migration from previous stored volume
|
// TODO add migration from previous stored volume
|
||||||
export const useVolumeStore = create(
|
export const useVolumeStore = create(
|
||||||
persist(
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
|
@ -142,6 +142,11 @@ module.exports = {
|
||||||
slider: "#8787A8",
|
slider: "#8787A8",
|
||||||
sliderFilled: "#A75FC9",
|
sliderFilled: "#A75FC9",
|
||||||
|
|
||||||
|
buttons: {
|
||||||
|
list: "#161C26",
|
||||||
|
active: "#0D1317"
|
||||||
|
},
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
main: "#617A8A",
|
main: "#617A8A",
|
||||||
secondary: "#374A56",
|
secondary: "#374A56",
|
||||||
|
|
Loading…
Reference in a new issue