mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 07:51:53 +01:00
quality selection, context menu style fully implemented
Co-authored-by: Jip Frijlink <JipFr@users.noreply.github.com>
This commit is contained in:
parent
7222abf567
commit
8f8bbf28c1
@ -42,6 +42,7 @@ export enum Icons {
|
|||||||
CHECKMARK = "checkmark",
|
CHECKMARK = "checkmark",
|
||||||
TACHOMETER = "tachometer",
|
TACHOMETER = "tachometer",
|
||||||
MAIL = "mail",
|
MAIL = "mail",
|
||||||
|
CIRCLE_CHECK = "circle_check",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
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>`,
|
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>`,
|
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>`,
|
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() {
|
function ChromeCastButton() {
|
||||||
|
@ -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 { OverlayAnchor } from "@/components/overlays/OverlayAnchor";
|
||||||
import { Overlay } from "@/components/overlays/OverlayDisplay";
|
import { Overlay } from "@/components/overlays/OverlayDisplay";
|
||||||
import { OverlayPage } from "@/components/overlays/OverlayPage";
|
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 { Context } from "@/components/player/internals/ContextUtils";
|
||||||
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
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 }) {
|
function SettingsOverlay({ id }: { id: string }) {
|
||||||
const router = useOverlayRouter("settings");
|
const router = useOverlayRouter(id);
|
||||||
|
const currentQuality = usePlayerStore((s) => s.currentQuality);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay id={id}>
|
<Overlay id={id}>
|
||||||
<OverlayRouter id={id}>
|
<OverlayRouter id={id}>
|
||||||
<OverlayPage id={id} path="/" width={343} height={431}>
|
<OverlayPage id={id} path="/" width={343} height={431}>
|
||||||
<Context.Card>
|
<Context.Card>
|
||||||
<Context.Title>Ba ba ba ba my title</Context.Title>
|
<Context.Title>Video settings</Context.Title>
|
||||||
<Context.Section>
|
<Context.Section>
|
||||||
Hi
|
<Context.Link onClick={() => router.navigate("/quality")}>
|
||||||
<Context.Link onClick={() => router.navigate("/other")}>
|
<Context.LinkTitle>Quality</Context.LinkTitle>
|
||||||
<Context.LinkTitle>Go to page 2</Context.LinkTitle>
|
<Context.LinkChevron>
|
||||||
<Context.LinkChevron />
|
{currentQuality ? qualityToString(currentQuality) : ""}
|
||||||
|
</Context.LinkChevron>
|
||||||
</Context.Link>
|
</Context.Link>
|
||||||
<Context.Link>
|
<Context.Link onClick={() => router.navigate("/source")}>
|
||||||
<Context.LinkTitle>Video source</Context.LinkTitle>
|
<Context.LinkTitle>Video source</Context.LinkTitle>
|
||||||
<Context.LinkChevron>SuperStream</Context.LinkChevron>
|
<Context.LinkChevron>SuperStream</Context.LinkChevron>
|
||||||
</Context.Link>
|
</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.Section>
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
<OverlayPage id={id} path="/other" width={343} height={431}>
|
<OverlayPage id={id} path="/quality" width={343} height={431}>
|
||||||
<Context.Card>
|
<Context.Card>
|
||||||
<Context.Title>Some other bit</Context.Title>
|
<QualityView id={id} />
|
||||||
<Context.Section>
|
</Context.Card>
|
||||||
<button type="button" onClick={() => router.navigate("/")}>
|
</OverlayPage>
|
||||||
Go BACK PLS
|
<OverlayPage id={id} path="/source" width={343} height={431}>
|
||||||
</button>
|
<Context.Card>
|
||||||
</Context.Section>
|
<Context.BackLink onClick={() => router.navigate("/")}>
|
||||||
|
It's a minion!
|
||||||
|
</Context.BackLink>
|
||||||
|
<img src="https://media2.giphy.com/media/oa4Au5xDZ6HJYF6KGH/giphy.gif" />
|
||||||
</Context.Card>
|
</Context.Card>
|
||||||
</OverlayPage>
|
</OverlayPage>
|
||||||
</OverlayRouter>
|
</OverlayRouter>
|
||||||
|
@ -4,8 +4,8 @@ import {
|
|||||||
DisplayInterface,
|
DisplayInterface,
|
||||||
DisplayInterfaceEvents,
|
DisplayInterfaceEvents,
|
||||||
} from "@/components/player/display/displayInterface";
|
} from "@/components/player/display/displayInterface";
|
||||||
import { Source } from "@/components/player/hooks/usePlayer";
|
|
||||||
import { handleBuffered } from "@/components/player/utils/handleBuffered";
|
import { handleBuffered } from "@/components/player/utils/handleBuffered";
|
||||||
|
import { LoadableSource } from "@/stores/player/utils/qualities";
|
||||||
import {
|
import {
|
||||||
canChangeVolume,
|
canChangeVolume,
|
||||||
canFullscreen,
|
canFullscreen,
|
||||||
@ -16,7 +16,7 @@ import { makeEmitter } from "@/utils/events";
|
|||||||
|
|
||||||
export function makeVideoElementDisplayInterface(): DisplayInterface {
|
export function makeVideoElementDisplayInterface(): DisplayInterface {
|
||||||
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
|
const { emit, on, off } = makeEmitter<DisplayInterfaceEvents>();
|
||||||
let source: Source | null = null;
|
let source: LoadableSource | null = null;
|
||||||
let videoElement: HTMLVideoElement | null = null;
|
let videoElement: HTMLVideoElement | null = null;
|
||||||
let containerElement: HTMLElement | null = null;
|
let containerElement: HTMLElement | null = null;
|
||||||
let isFullscreen = false;
|
let isFullscreen = false;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Source } from "@/components/player/hooks/usePlayer";
|
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
|
||||||
import { Listener } from "@/utils/events";
|
import { Listener } from "@/utils/events";
|
||||||
|
|
||||||
export type DisplayInterfaceEvents = {
|
export type DisplayInterfaceEvents = {
|
||||||
@ -10,12 +10,14 @@ export type DisplayInterfaceEvents = {
|
|||||||
duration: number;
|
duration: number;
|
||||||
buffered: number;
|
buffered: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
qualities: SourceQuality[];
|
||||||
|
changedquality: SourceQuality | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
|
||||||
play(): void;
|
play(): void;
|
||||||
pause(): void;
|
pause(): void;
|
||||||
load(source: Source): void;
|
load(source: LoadableSource): void;
|
||||||
processVideoElement(video: HTMLVideoElement): void;
|
processVideoElement(video: HTMLVideoElement): void;
|
||||||
processContainerElement(container: HTMLElement): void;
|
processContainerElement(container: HTMLElement): void;
|
||||||
toggleFullscreen(): void;
|
toggleFullscreen(): void;
|
||||||
|
@ -2,6 +2,7 @@ import { MWStreamType } from "@/backend/helpers/streams";
|
|||||||
import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer";
|
import { useInitializePlayer } from "@/components/player/hooks/useInitializePlayer";
|
||||||
import { PlayerMeta, playerStatus } 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";
|
||||||
|
import { SourceSliceSource } from "@/stores/player/utils/qualities";
|
||||||
|
|
||||||
export interface Source {
|
export interface Source {
|
||||||
url: string;
|
url: string;
|
||||||
@ -11,8 +12,8 @@ export interface Source {
|
|||||||
export function usePlayer() {
|
export function usePlayer() {
|
||||||
const setStatus = usePlayerStore((s) => s.setStatus);
|
const setStatus = usePlayerStore((s) => s.setStatus);
|
||||||
const setMeta = usePlayerStore((s) => s.setMeta);
|
const setMeta = usePlayerStore((s) => s.setMeta);
|
||||||
|
const setSource = usePlayerStore((s) => s.setSource);
|
||||||
const status = usePlayerStore((s) => s.status);
|
const status = usePlayerStore((s) => s.status);
|
||||||
const display = usePlayerStore((s) => s.display);
|
|
||||||
const reset = usePlayerStore((s) => s.reset);
|
const reset = usePlayerStore((s) => s.reset);
|
||||||
const { init } = useInitializePlayer();
|
const { init } = useInitializePlayer();
|
||||||
|
|
||||||
@ -22,8 +23,8 @@ export function usePlayer() {
|
|||||||
setMeta(meta: PlayerMeta) {
|
setMeta(meta: PlayerMeta) {
|
||||||
setMeta(meta);
|
setMeta(meta);
|
||||||
},
|
},
|
||||||
playMedia(source: Source) {
|
playMedia(source: SourceSliceSource) {
|
||||||
display?.load(source);
|
setSource(source);
|
||||||
setStatus(playerStatus.PLAYING);
|
setStatus(playerStatus.PLAYING);
|
||||||
init();
|
init();
|
||||||
},
|
},
|
||||||
|
@ -1,26 +1,50 @@
|
|||||||
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
function Card(props: { children: React.ReactNode }) {
|
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 }) {
|
function Title(props: { children: React.ReactNode }) {
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</h3>
|
</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 }) {
|
function Section(props: { children: React.ReactNode }) {
|
||||||
return <div className="my-5">{props.children}</div>;
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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)" }}
|
style={{ width: "calc(100% + 1.5rem)" }}
|
||||||
onClick={props.onClick}
|
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 (
|
return (
|
||||||
<span className="text-video-context-type-main font-medium">
|
<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">
|
||||||
{props.children}
|
<div className="flex items-center space-x-3">
|
||||||
</span>
|
<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 = {
|
export const Context = {
|
||||||
Card,
|
Card,
|
||||||
Title,
|
Title,
|
||||||
|
BackLink,
|
||||||
Section,
|
Section,
|
||||||
Link,
|
Link,
|
||||||
LinkTitle,
|
LinkTitle,
|
||||||
LinkChevron,
|
LinkChevron,
|
||||||
|
IconButton,
|
||||||
|
Divider,
|
||||||
|
SmallText,
|
||||||
|
Anchor,
|
||||||
};
|
};
|
||||||
|
52
src/components/player/utils/convertRunoutputToSource.ts
Normal file
52
src/components/player/utils/convertRunoutputToSource.ts
Normal 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");
|
||||||
|
}
|
@ -5,6 +5,7 @@ import { useParams } from "react-router-dom";
|
|||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
import { MWStreamType } from "@/backend/helpers/streams";
|
||||||
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
import { usePlayer } from "@/components/player/hooks/usePlayer";
|
||||||
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
|
||||||
|
import { convertRunoutputToSource } from "@/components/player/utils/convertRunoutputToSource";
|
||||||
import { MetaPart } from "@/pages/parts/player/MetaPart";
|
import { MetaPart } from "@/pages/parts/player/MetaPart";
|
||||||
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
|
import { PlayerPart } from "@/pages/parts/player/PlayerPart";
|
||||||
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
|
import { ScrapingPart } from "@/pages/parts/player/ScrapingPart";
|
||||||
@ -26,25 +27,8 @@ export function PlayerView() {
|
|||||||
|
|
||||||
const playAfterScrape = useCallback(
|
const playAfterScrape = useCallback(
|
||||||
(out: RunOutput | null) => {
|
(out: RunOutput | null) => {
|
||||||
if (out?.stream.type !== "file") return;
|
if (!out) return;
|
||||||
const qualities = Object.keys(out.stream.qualities).sort(
|
playMedia(convertRunoutputToSource(out));
|
||||||
(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,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
[playMedia]
|
[playMedia]
|
||||||
);
|
);
|
||||||
|
@ -65,6 +65,16 @@ export const createDisplaySlice: MakeSlice<DisplaySlice> = (set, get) => ({
|
|||||||
s.mediaPlaying.isLoading = isLoading;
|
s.mediaPlaying.isLoading = isLoading;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
newDisplay.on("qualities", (qualities) => {
|
||||||
|
set((s) => {
|
||||||
|
s.qualities = qualities;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
newDisplay.on("changedquality", (quality) => {
|
||||||
|
set((s) => {
|
||||||
|
s.currentQuality = quality;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
set((s) => {
|
set((s) => {
|
||||||
s.display = newDisplay;
|
s.display = newDisplay;
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { ScrapeMedia } from "@movie-web/providers";
|
import { ScrapeMedia } from "@movie-web/providers";
|
||||||
|
|
||||||
import { MWStreamType } from "@/backend/helpers/streams";
|
|
||||||
import { MakeSlice } from "@/stores/player/slices/types";
|
import { MakeSlice } from "@/stores/player/slices/types";
|
||||||
|
import {
|
||||||
|
LoadableSource,
|
||||||
|
SourceQuality,
|
||||||
|
SourceSliceSource,
|
||||||
|
selectQuality,
|
||||||
|
} from "@/stores/player/utils/qualities";
|
||||||
import { ValuesOf } from "@/utils/typeguard";
|
import { ValuesOf } from "@/utils/typeguard";
|
||||||
|
|
||||||
export const playerStatus = {
|
export const playerStatus = {
|
||||||
@ -12,11 +17,6 @@ export const playerStatus = {
|
|||||||
|
|
||||||
export type PlayerStatus = ValuesOf<typeof playerStatus>;
|
export type PlayerStatus = ValuesOf<typeof playerStatus>;
|
||||||
|
|
||||||
export interface SourceSliceSource {
|
|
||||||
url: string;
|
|
||||||
type: MWStreamType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayerMeta {
|
export interface PlayerMeta {
|
||||||
type: "movie" | "show";
|
type: "movie" | "show";
|
||||||
title: string;
|
title: string;
|
||||||
@ -38,9 +38,12 @@ export interface PlayerMeta {
|
|||||||
export interface SourceSlice {
|
export interface SourceSlice {
|
||||||
status: PlayerStatus;
|
status: PlayerStatus;
|
||||||
source: SourceSliceSource | null;
|
source: SourceSliceSource | null;
|
||||||
|
qualities: SourceQuality[];
|
||||||
|
currentQuality: SourceQuality | null;
|
||||||
meta: PlayerMeta | null;
|
meta: PlayerMeta | null;
|
||||||
setStatus(status: PlayerStatus): void;
|
setStatus(status: PlayerStatus): void;
|
||||||
setSource(url: string, type: MWStreamType): void;
|
setSource(stream: SourceSliceSource): void;
|
||||||
|
switchQuality(quality: SourceQuality): void;
|
||||||
setMeta(meta: PlayerMeta): 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,
|
source: null,
|
||||||
|
qualities: [],
|
||||||
|
currentQuality: null,
|
||||||
status: playerStatus.IDLE,
|
status: playerStatus.IDLE,
|
||||||
meta: null,
|
meta: null,
|
||||||
setStatus(status: PlayerStatus) {
|
setStatus(status: PlayerStatus) {
|
||||||
@ -81,12 +86,30 @@ export const createSourceSlice: MakeSlice<SourceSlice> = (set) => ({
|
|||||||
s.meta = meta;
|
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) => {
|
set((s) => {
|
||||||
s.source = {
|
s.source = stream;
|
||||||
type,
|
s.qualities = qualities as SourceQuality[];
|
||||||
url,
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
58
src/stores/player/utils/qualities.ts
Normal file
58
src/stores/player/utils/qualities.ts
Normal 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];
|
||||||
|
}
|
@ -132,7 +132,8 @@ module.exports = {
|
|||||||
|
|
||||||
type: {
|
type: {
|
||||||
main: "#617A8A",
|
main: "#617A8A",
|
||||||
secondary: "#374A56"
|
secondary: "#374A56",
|
||||||
|
accent: "#A570FA"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user