quality selection, context menu style fully implemented

Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
mrjvs 2023-10-14 16:06:25 +02:00
parent 7222abf567
commit 8f8bbf28c1
12 changed files with 371 additions and 64 deletions

View File

@ -42,6 +42,7 @@ export enum Icons {
CHECKMARK = "checkmark",
TACHOMETER = "tachometer",
MAIL = "mail",
CIRCLE_CHECK = "circle_check",
}
export interface IconProps {
@ -91,6 +92,7 @@ const iconList: Record<Icons, string> = {
checkmark: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 24 24"><path d="M9 22l-10-10.598 2.798-2.859 7.149 7.473 13.144-14.016 2.909 2.806z" /></svg>`,
tachometer: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" fill="currentColor" viewBox="0 0 576 512"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M128 288c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zm154.65-97.08l16.24-48.71c1.16-3.45 3.18-6.35 4.92-9.43-4.73-2.76-9.94-4.78-15.81-4.78-17.67 0-32 14.33-32 32 0 15.78 11.63 28.29 26.65 30.92zM176 176c-17.67 0-32 14.33-32 32s14.33 32 32 32 32-14.33 32-32-14.33-32-32-32zM288 32C128.94 32 0 160.94 0 320c0 52.8 14.25 102.26 39.06 144.8 5.61 9.62 16.3 15.2 27.44 15.2h443c11.14 0 21.83-5.58 27.44-15.2C561.75 422.26 576 372.8 576 320c0-159.06-128.94-288-288-288zm212.27 400H75.73C57.56 397.63 48 359.12 48 320 48 187.66 155.66 80 288 80s240 107.66 240 240c0 39.12-9.56 77.63-27.73 112zM416 320c0 17.67 14.33 32 32 32s32-14.33 32-32-14.33-32-32-32-32 14.33-32 32zm-56.41-182.77c-12.72-4.23-26.16 2.62-30.38 15.17l-45.34 136.01C250.49 290.58 224 318.06 224 352c0 11.72 3.38 22.55 8.88 32h110.25c5.5-9.45 8.88-20.28 8.88-32 0-19.45-8.86-36.66-22.55-48.4l45.34-136.01c4.17-12.57-2.64-26.17-15.21-30.36zM432 208c0-15.8-11.66-28.33-26.72-30.93-.07.21-.07.43-.14.65l-19.5 58.49c4.37 2.24 9.11 3.8 14.36 3.8 17.67-.01 32-14.34 32-32.01z"/></svg>`,
mail: `<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19.25 4.125H2.75C2.56766 4.125 2.3928 4.19743 2.26386 4.32636C2.13493 4.4553 2.0625 4.63016 2.0625 4.8125V16.5C2.0625 16.8647 2.20737 17.2144 2.46523 17.4723C2.72309 17.7301 3.07283 17.875 3.4375 17.875H18.5625C18.9272 17.875 19.2769 17.7301 19.5348 17.4723C19.7926 17.2144 19.9375 16.8647 19.9375 16.5V4.8125C19.9375 4.63016 19.8651 4.4553 19.7361 4.32636C19.6072 4.19743 19.4323 4.125 19.25 4.125ZM8.48289 11L3.4375 15.6243V6.3757L8.48289 11ZM9.50039 11.9324L10.5316 12.882C10.6585 12.9985 10.8244 13.0631 10.9966 13.0631C11.1687 13.0631 11.3346 12.9985 11.4615 12.882L12.4927 11.9324L17.4771 16.5H4.51773L9.50039 11.9324ZM13.5171 11L18.5625 6.37484V15.6252L13.5171 11Z" fill="currentColor" /></svg>`,
circle_check: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"><path fill="currentColor" d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>`,
};
function ChromeCastButton() {

View File

@ -1,6 +1,6 @@
import { useEffect } from "react";
import { useCallback, useEffect } from "react";
import { Icons } from "@/components/Icon";
import { Icon, Icons } from "@/components/Icon";
import { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
import { Overlay } from "@/components/overlays/OverlayDisplay";
import { OverlayPage } from "@/components/overlays/OverlayPage";
@ -9,37 +9,138 @@ import { VideoPlayerButton } from "@/components/player/internals/Button";
import { Context } from "@/components/player/internals/ContextUtils";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { usePlayerStore } from "@/stores/player/store";
import {
SourceQuality,
qualityToString,
} from "@/stores/player/utils/qualities";
function QualityOption(props: {
children: React.ReactNode;
selected?: boolean;
disabled?: boolean;
onClick?: () => void;
}) {
let textClasses;
if (props.selected) textClasses = "text-white";
if (props.disabled)
textClasses = "text-video-context-type-main text-opacity-40";
return (
<Context.Link noHover={props.disabled} onClick={props.onClick}>
<Context.LinkTitle textClass={textClasses}>
{props.children}
</Context.LinkTitle>
{props.selected ? (
<Icon
icon={Icons.CIRCLE_CHECK}
className="text-xl text-video-context-type-accent"
/>
) : null}
</Context.Link>
);
}
function QualityView({ id }: { id: string }) {
const router = useOverlayRouter(id);
const availableQualities = usePlayerStore((s) => s.qualities);
const currentQuality = usePlayerStore((s) => s.currentQuality);
const switchQuality = usePlayerStore((s) => s.switchQuality);
const change = useCallback(
(q: SourceQuality) => {
switchQuality(q);
router.close();
},
[router, switchQuality]
);
return (
<>
<Context.BackLink onClick={() => router.navigate("/")}>
Quality
</Context.BackLink>
<Context.Section>
{availableQualities.map((v) => (
<QualityOption
key={v}
selected={v === currentQuality}
onClick={() => change(v)}
>
{qualityToString(v)}
</QualityOption>
))}
<Context.Divider />
<Context.Link noHover onClick={() => router.navigate("/")}>
<Context.LinkTitle>Automatic quality</Context.LinkTitle>
<span>Toggle</span>
</Context.Link>
<Context.SmallText>
You can try{" "}
<Context.Anchor onClick={() => router.navigate("/source")}>
switching source
</Context.Anchor>{" "}
to get different quality options.
</Context.SmallText>
</Context.Section>
</>
);
}
function SettingsOverlay({ id }: { id: string }) {
const router = useOverlayRouter("settings");
const router = useOverlayRouter(id);
const currentQuality = usePlayerStore((s) => s.currentQuality);
return (
<Overlay id={id}>
<OverlayRouter id={id}>
<OverlayPage id={id} path="/" width={343} height={431}>
<Context.Card>
<Context.Title>Ba ba ba ba my title</Context.Title>
<Context.Title>Video settings</Context.Title>
<Context.Section>
Hi
<Context.Link onClick={() => router.navigate("/other")}>
<Context.LinkTitle>Go to page 2</Context.LinkTitle>
<Context.LinkChevron />
<Context.Link onClick={() => router.navigate("/quality")}>
<Context.LinkTitle>Quality</Context.LinkTitle>
<Context.LinkChevron>
{currentQuality ? qualityToString(currentQuality) : ""}
</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.Link onClick={() => router.navigate("/source")}>
<Context.LinkTitle>Video source</Context.LinkTitle>
<Context.LinkChevron>SuperStream</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.LinkTitle>Download</Context.LinkTitle>
<Context.IconButton icon={Icons.DOWNLOAD} />
</Context.Link>
</Context.Section>
<Context.Title>Viewing Experience</Context.Title>
<Context.Section>
<Context.Link onClick={() => router.navigate("/quality")}>
<Context.LinkTitle>Enable Captions</Context.LinkTitle>
<Context.IconButton icon={Icons.CHEVRON_DOWN} />
</Context.Link>
<Context.Link>
<Context.LinkTitle>Caption settings</Context.LinkTitle>
<Context.LinkChevron>English</Context.LinkChevron>
</Context.Link>
<Context.Link>
<Context.LinkTitle>Playback settings</Context.LinkTitle>
<Context.LinkChevron />
</Context.Link>
</Context.Section>
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/other" width={343} height={431}>
<OverlayPage id={id} path="/quality" width={343} height={431}>
<Context.Card>
<Context.Title>Some other bit</Context.Title>
<Context.Section>
<button type="button" onClick={() => router.navigate("/")}>
Go BACK PLS
</button>
</Context.Section>
<QualityView id={id} />
</Context.Card>
</OverlayPage>
<OverlayPage id={id} path="/source" width={343} height={431}>
<Context.Card>
<Context.BackLink onClick={() => router.navigate("/")}>
It&apos;s a minion!
</Context.BackLink>
<img src="https://media2.giphy.com/media/oa4Au5xDZ6HJYF6KGH/giphy.gif" />
</Context.Card>
</OverlayPage>
</OverlayRouter>

View File

@ -4,8 +4,8 @@ import {
DisplayInterface,
DisplayInterfaceEvents,
} from "@/components/player/display/displayInterface";
import { Source } from "@/components/player/hooks/usePlayer";
import { handleBuffered } from "@/components/player/utils/handleBuffered";
import { LoadableSource } from "@/stores/player/utils/qualities";
import {
canChangeVolume,
canFullscreen,
@ -16,7 +16,7 @@ import { makeEmitter } from "@/utils/events";
export function makeVideoElementDisplayInterface(): DisplayInterface {
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
let source: Source | null = null;
let source: LoadableSource | null = null;
let videoElement: HTMLVideoElement | null = null;
let containerElement: HTMLElement | null = null;
let isFullscreen = false;

View File

@ -1,4 +1,4 @@
import { Source } from "@/components/player/hooks/usePlayer";
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
import { Listener } from "@/utils/events";
export type DisplayInterfaceEvents = {
@ -10,12 +10,14 @@ export type DisplayInterfaceEvents = {
duration: number;
buffered: number;
loading: boolean;
qualities: SourceQuality[];
changedquality: SourceQuality | null;
};
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
play(): void;
pause(): void;
load(source: Source): void;
load(source: LoadableSource): void;
processVideoElement(video: HTMLVideoElement): void;
processContainerElement(container: HTMLElement): void;
toggleFullscreen(): void;

View File

@ -2,6 +2,7 @@ import { MWStreamType } from "@/backend/helpers/streams";
import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { SourceSliceSource } from "@/stores/player/utils/qualities";
export interface Source {
url: string;
@ -11,8 +12,8 @@ export interface Source {
export function usePlayer() {
const setStatus = usePlayerStore((s) => s.setStatus);
const setMeta = usePlayerStore((s) => s.setMeta);
const setSource = usePlayerStore((s) => s.setSource);
const status = usePlayerStore((s) => s.status);
const display = usePlayerStore((s) => s.display);
const reset = usePlayerStore((s) => s.reset);
const { init } = useInitializePlayer();
@ -22,8 +23,8 @@ export function usePlayer() {
setMeta(meta: PlayerMeta) {
setMeta(meta);
},
playMedia(source: Source) {
display?.load(source);
playMedia(source: SourceSliceSource) {
setSource(source);
setStatus(playerStatus.PLAYING);
init();
},

View File

@ -1,26 +1,50 @@
import classNames from "classnames";
import { Icon, Icons } from "@/components/Icon";
function Card(props: { children: React.ReactNode }) {
return <div className="px-6 py-8">{props.children}</div>;
return <div className="px-6 py-0">{props.children}</div>;
}
function Title(props: { children: React.ReactNode }) {
return (
<h3 className="uppercase font-bold text-video-context-type-secondary text-sm pl-1 pb-2 border-b border-opacity-25 border-video-context-border mb-6">
<h3 className="uppercase mt-8 font-bold text-video-context-type-secondary text-sm pl-1 pb-2.5 border-b border-opacity-25 border-video-context-border mb-6">
{props.children}
</h3>
);
}
function LinkTitle(props: { children: React.ReactNode; textClass?: string }) {
return (
<span
className={classNames([
"font-medium",
props.textClass || "text-video-context-type-main",
])}
>
<div>{props.children}</div>
</span>
);
}
function Section(props: { children: React.ReactNode }) {
return <div className="my-5">{props.children}</div>;
}
function Link(props: { onClick?: () => void; children: React.ReactNode }) {
function Link(props: {
onClick?: () => void;
children: React.ReactNode;
noHover?: boolean;
}) {
return (
<button
type="button"
className="flex justify-between items-center py-2 pl-3 pr-3 -ml-3 rounded hover:bg-video-context-border hover:bg-opacity-10 w-full"
className={classNames([
"flex justify-between items-center py-2 pl-3 pr-3 -ml-3 rounded w-full",
props.noHover
? "cursor-default"
: "hover:bg-video-context-border hover:bg-opacity-10",
])}
style={{ width: "calc(100% + 1.5rem)" }}
onClick={props.onClick}
>
@ -29,11 +53,25 @@ function Link(props: { onClick?: () => void; children: React.ReactNode }) {
);
}
function LinkTitle(props: { children: React.ReactNode }) {
function BackLink(props: {
onClick?: () => void;
children: React.ReactNode;
rightSide?: React.ReactNode;
}) {
return (
<span className="text-video-context-type-main font-medium">
{props.children}
</span>
<h3 className="font-bold text-video-context-type-main pb-4 pt-5 border-b border-opacity-25 border-video-context-border mb-6 flex justify-between items-center">
<div className="flex items-center space-x-3">
<button
type="button"
className="-ml-1 p-1 rounded hover:bg-video-context-light hover:bg-opacity-10"
onClick={props.onClick}
>
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
</button>
<span>{props.children}</span>
</div>
<div>{props.rightSide}</div>
</h3>
);
}
@ -46,11 +84,46 @@ function LinkChevron(props: { children?: React.ReactNode }) {
);
}
function IconButton(props: { icon: Icons; onClick?: () => void }) {
return (
<button type="button" onClick={props.onClick}>
<Icon className="text-xl" icon={props.icon} />
</button>
);
}
function Divider() {
return (
<hr className="my-4 border-0 w-full h-px bg-video-context-border bg-opacity-25" />
);
}
function SmallText(props: { children: React.ReactNode }) {
return <p className="text-sm mt-8 font-medium">{props.children}</p>;
}
function Anchor(props: { children: React.ReactNode; onClick: () => void }) {
return (
<a
type="button"
className="text-video-context-type-accent cursor-pointer"
onClick={props.onClick}
>
{props.children}
</a>
);
}
export const Context = {
Card,
Title,
BackLink,
Section,
Link,
LinkTitle,
LinkChevron,
IconButton,
Divider,
SmallText,
Anchor,
};

View File

@ -0,0 +1,52 @@
import { RunOutput } from "@movie-web/providers";
import {
SourceFileStream,
SourceQuality,
SourceSliceSource,
} from "@/stores/player/utils/qualities";
const allowedQualitiesMap: Record<SourceQuality, SourceQuality> = {
"1080": "1080",
"480": "480",
"360": "360",
"720": "720",
unknown: "unknown",
};
const allowedQualities = Object.keys(allowedQualitiesMap);
const allowedFileTypes = ["mp4"];
function isAllowedQuality(inp: string): inp is SourceQuality {
return allowedQualities.includes(inp);
}
export function convertRunoutputToSource(out: RunOutput): SourceSliceSource {
if (out.stream.type === "hls") {
return {
type: "hls",
url: out.stream.playlist,
};
}
if (out.stream.type === "file") {
const qualities: Partial<Record<SourceQuality, SourceFileStream>> = {};
Object.entries(out.stream.qualities).forEach((entry) => {
if (!isAllowedQuality(entry[0])) {
console.warn(`unrecognized quality: ${entry[0]}`);
return;
}
if (!allowedFileTypes.includes(entry[1].type)) {
console.warn(`unrecognized file type: ${entry[1].type}`);
return;
}
qualities[entry[0]] = {
type: entry[1].type,
url: entry[1].url,
};
});
return {
type: "file",
qualities,
};
}
throw new Error("unrecognized type");
}

View File

@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
import { MWStreamType } from "@/backend/helpers/streams";
import { usePlayer } from "@/components/player/hooks/usePlayer";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
import { MetaPart } from "@/pages/parts/player/MetaPart";
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
@ -26,25 +27,8 @@ export function PlayerView() {
const playAfterScrape = useCallback(
(out: RunOutput | null) => {
if (out?.stream.type !== "file") return;
const qualities = Object.keys(out.stream.qualities).sort(
(a, b) => Number(b) - Number(a)
) as (keyof typeof out.stream.qualities)[];
let file;
for (const quality of qualities) {
if (out.stream.qualities[quality]?.url) {
file = out.stream.qualities[quality];
break;
}
}
if (!file) return;
playMedia({
type: MWStreamType.MP4,
url: file.url,
});
if (!out) return;
playMedia(convertRunoutputToSource(out));
},
[playMedia]
);

View File

@ -65,6 +65,16 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
s.mediaPlaying.isLoading = isLoading;
})
);
newDisplay.on("qualities", (qualities) => {
set((s) => {
s.qualities = qualities;
});
});
newDisplay.on("changedquality", (quality) => {
set((s) => {
s.currentQuality = quality;
});
});
set((s) => {
s.display = newDisplay;

View File

@ -1,7 +1,12 @@
import { ScrapeMedia } from "@movie-web/providers";
import { MWStreamType } from "@/backend/helpers/streams";
import { MakeSlice } from "@/stores/player/slices/types";
import {
LoadableSource,
SourceQuality,
SourceSliceSource,
selectQuality,
} from "@/stores/player/utils/qualities";
import { ValuesOf } from "@/utils/typeguard";
export const playerStatus = {
@ -12,11 +17,6 @@ export const playerStatus = {
export type PlayerStatus = ValuesOf<typeof playerStatus>;
export interface SourceSliceSource {
url: string;
type: MWStreamType;
}
export interface PlayerMeta {
type: "movie" | "show";
title: string;
@ -38,9 +38,12 @@ export interface PlayerMeta {
export interface SourceSlice {
status: PlayerStatus;
source: SourceSliceSource | null;
qualities: SourceQuality[];
currentQuality: SourceQuality | null;
meta: PlayerMeta | null;
setStatus(status: PlayerStatus): void;
setSource(url: string, type: MWStreamType): void;
setSource(stream: SourceSliceSource): void;
switchQuality(quality: SourceQuality): void;
setMeta(meta: PlayerMeta): void;
}
@ -67,8 +70,10 @@ export function metaToScrapeMedia(meta: PlayerMeta): ScrapeMedia {
};
}
export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
export const createSourceSlice: MakeSlice<SourceSlice> = (set, get) => ({
source: null,
qualities: [],
currentQuality: null,
status: playerStatus.IDLE,
meta: null,
setStatus(status: PlayerStatus) {
@ -81,12 +86,30 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
s.meta = meta;
});
},
setSource(url: string, type: MWStreamType) {
setSource(stream: SourceSliceSource) {
let qualities: string[] = [];
if (stream.type === "file") qualities = Object.keys(stream.qualities);
const store = get();
const loadableStream = selectQuality(stream);
set((s) => {
s.source = {
type,
url,
};
s.source = stream;
s.qualities = qualities as SourceQuality[];
s.currentQuality = loadableStream.quality;
});
store.display?.load(loadableStream.stream);
},
switchQuality(quality) {
const store = get();
if (!store.source) return;
if (store.source.type === "file") {
const selectedQuality = store.source.qualities[quality];
if (!selectedQuality) return;
set((s) => {
s.currentQuality = quality;
});
store.display?.load(selectedQuality);
}
},
});

View File

@ -0,0 +1,58 @@
export type SourceQuality = "unknown" | "360" | "480" | "720" | "1080";
export type StreamType = "hls" | "mp4";
export type SourceFileStream = {
type: "mp4";
url: string;
};
export type LoadableSource = {
type: StreamType;
url: string;
};
export type SourceSliceSource =
| {
type: "file";
qualities: Partial<Record<SourceQuality, SourceFileStream>>;
}
| {
type: "hls";
url: string;
};
export function selectQuality(source: SourceSliceSource): {
stream: LoadableSource;
quality: null | SourceQuality;
} {
if (source.type === "hls")
return {
stream: source,
quality: null,
};
if (source.type === "file") {
const firstQuality = Object.keys(
source.qualities
)[0] as keyof typeof source.qualities;
const stream = source.qualities[firstQuality];
if (stream) {
return { stream, quality: firstQuality };
}
}
throw new Error("couldn't select quality");
}
const qualityMap: Record<SourceQuality, string> = {
"1080": "1080p",
"360": "360p",
"480": "480p",
"720": "720p",
unknown: "unknown",
};
export const allQualities = Object.keys(qualityMap);
export function qualityToString(quality: SourceQuality): string {
return qualityMap[quality];
}

View File

@ -132,7 +132,8 @@ module.exports = {
type: {
main: "#617A8A",
secondary: "#374A56"
secondary: "#374A56",
accent: "#A570FA"
}
}
}