From 8f8bbf28c12cb191edb01722f92f9cc65732695f Mon Sep 17 00:00:00 2001 From: mrjvs Date: Sat, 14 Oct 2023 16:06:25 +0200 Subject: [PATCH] quality selection, context menu style fully implemented Co-authored-by: Jip Frijlink --- src/components/Icon.tsx | 2 + src/components/player/atoms/Settings.tsx | 133 +++++++++++++++--- src/components/player/display/base.ts | 4 +- .../player/display/displayInterface.ts | 6 +- src/components/player/hooks/usePlayer.ts | 7 +- .../player/internals/ContextUtils.tsx | 89 ++++++++++-- .../player/utils/convertRunoutputToSource.ts | 52 +++++++ src/pages/PlayerView.tsx | 22 +-- src/stores/player/slices/display.ts | 10 ++ src/stores/player/slices/source.ts | 49 +++++-- src/stores/player/utils/qualities.ts | 58 ++++++++ tailwind.config.js | 3 +- 12 files changed, 371 insertions(+), 64 deletions(-) create mode 100644 src/components/player/utils/convertRunoutputToSource.ts create mode 100644 src/stores/player/utils/qualities.ts diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 21f6ac24..2bdb6261 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { checkmark: ``, tachometer: ``, mail: ``, + circle_check: ``, }; function ChromeCastButton() { diff --git a/src/components/player/atoms/Settings.tsx b/src/components/player/atoms/Settings.tsx index 1556a2de..183d36aa 100644 --- a/src/components/player/atoms/Settings.tsx +++ b/src/components/player/atoms/Settings.tsx @@ -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 ( + + + {props.children} + + {props.selected ? ( + + ) : null} + + ); +} + +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 ( + <> + router.navigate("/")}> + Quality + + + {availableQualities.map((v) => ( + change(v)} + > + {qualityToString(v)} + + ))} + + router.navigate("/")}> + Automatic quality + Toggle + + + You can try{" "} + router.navigate("/source")}> + switching source + {" "} + to get different quality options. + + + + ); +} function SettingsOverlay({ id }: { id: string }) { - const router = useOverlayRouter("settings"); + const router = useOverlayRouter(id); + const currentQuality = usePlayerStore((s) => s.currentQuality); return ( - Ba ba ba ba my title + Video settings - Hi - router.navigate("/other")}> - Go to page 2 - + router.navigate("/quality")}> + Quality + + {currentQuality ? qualityToString(currentQuality) : ""} + - + router.navigate("/source")}> Video source SuperStream + + Download + + + + + Viewing Experience + + router.navigate("/quality")}> + Enable Captions + + + + Caption settings + English + + + Playback settings + + - + - Some other bit - - - + + + + + + router.navigate("/")}> + It's a minion! + + diff --git a/src/components/player/display/base.ts b/src/components/player/display/base.ts index d86a0436..98648ef1 100644 --- a/src/components/player/display/base.ts +++ b/src/components/player/display/base.ts @@ -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(); - let source: Source | null = null; + let source: LoadableSource | null = null; let videoElement: HTMLVideoElement | null = null; let containerElement: HTMLElement | null = null; let isFullscreen = false; diff --git a/src/components/player/display/displayInterface.ts b/src/components/player/display/displayInterface.ts index 6b037846..2cb6f5df 100644 --- a/src/components/player/display/displayInterface.ts +++ b/src/components/player/display/displayInterface.ts @@ -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 { play(): void; pause(): void; - load(source: Source): void; + load(source: LoadableSource): void; processVideoElement(video: HTMLVideoElement): void; processContainerElement(container: HTMLElement): void; toggleFullscreen(): void; diff --git a/src/components/player/hooks/usePlayer.ts b/src/components/player/hooks/usePlayer.ts index 02e3801f..ff9fd41f 100644 --- a/src/components/player/hooks/usePlayer.ts +++ b/src/components/player/hooks/usePlayer.ts @@ -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(); }, diff --git a/src/components/player/internals/ContextUtils.tsx b/src/components/player/internals/ContextUtils.tsx index 1aa460b1..4f253377 100644 --- a/src/components/player/internals/ContextUtils.tsx +++ b/src/components/player/internals/ContextUtils.tsx @@ -1,26 +1,50 @@ +import classNames from "classnames"; + import { Icon, Icons } from "@/components/Icon"; function Card(props: { children: React.ReactNode }) { - return
{props.children}
; + return
{props.children}
; } function Title(props: { children: React.ReactNode }) { return ( -

+

{props.children}

); } +function LinkTitle(props: { children: React.ReactNode; textClass?: string }) { + return ( + +
{props.children}
+
+ ); +} + function Section(props: { children: React.ReactNode }) { return
{props.children}
; } -function Link(props: { onClick?: () => void; children: React.ReactNode }) { +function Link(props: { + onClick?: () => void; + children: React.ReactNode; + noHover?: boolean; +}) { return ( + {props.children} + +
{props.rightSide}
+ ); } @@ -46,11 +84,46 @@ function LinkChevron(props: { children?: React.ReactNode }) { ); } +function IconButton(props: { icon: Icons; onClick?: () => void }) { + return ( + + ); +} + +function Divider() { + return ( +
+ ); +} + +function SmallText(props: { children: React.ReactNode }) { + return

{props.children}

; +} + +function Anchor(props: { children: React.ReactNode; onClick: () => void }) { + return ( + + {props.children} + + ); +} + export const Context = { Card, Title, + BackLink, Section, Link, LinkTitle, LinkChevron, + IconButton, + Divider, + SmallText, + Anchor, }; diff --git a/src/components/player/utils/convertRunoutputToSource.ts b/src/components/player/utils/convertRunoutputToSource.ts new file mode 100644 index 00000000..773496a8 --- /dev/null +++ b/src/components/player/utils/convertRunoutputToSource.ts @@ -0,0 +1,52 @@ +import { RunOutput } from "@movie-web/providers"; + +import { + SourceFileStream, + SourceQuality, + SourceSliceSource, +} from "@/stores/player/utils/qualities"; + +const allowedQualitiesMap: Record = { + "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> = {}; + 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"); +} diff --git a/src/pages/PlayerView.tsx b/src/pages/PlayerView.tsx index 2d6f39b5..5d0d12f2 100644 --- a/src/pages/PlayerView.tsx +++ b/src/pages/PlayerView.tsx @@ -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] ); diff --git a/src/stores/player/slices/display.ts b/src/stores/player/slices/display.ts index e2ced531..5fec4c94 100644 --- a/src/stores/player/slices/display.ts +++ b/src/stores/player/slices/display.ts @@ -65,6 +65,16 @@ export const createDisplaySlice: MakeSlice = (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; diff --git a/src/stores/player/slices/source.ts b/src/stores/player/slices/source.ts index 36404952..a8f38aa4 100644 --- a/src/stores/player/slices/source.ts +++ b/src/stores/player/slices/source.ts @@ -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; -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 = (set) => ({ +export const createSourceSlice: MakeSlice = (set, get) => ({ source: null, + qualities: [], + currentQuality: null, status: playerStatus.IDLE, meta: null, setStatus(status: PlayerStatus) { @@ -81,12 +86,30 @@ export const createSourceSlice: MakeSlice = (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); + } }, }); diff --git a/src/stores/player/utils/qualities.ts b/src/stores/player/utils/qualities.ts new file mode 100644 index 00000000..8a6191ce --- /dev/null +++ b/src/stores/player/utils/qualities.ts @@ -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>; + } + | { + 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 = { + "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]; +} diff --git a/tailwind.config.js b/tailwind.config.js index d2eb0a07..b3f15e19 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -132,7 +132,8 @@ module.exports = { type: { main: "#617A8A", - secondary: "#374A56" + secondary: "#374A56", + accent: "#A570FA" } } }