20 changed files with 492 additions and 168 deletions
@ -0,0 +1,15 @@ |
|||||||
|
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useMeta } from "@/video/state/logic/meta"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
onClick?: () => void; |
||||||
|
showControls?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function HeaderAction(props: Props) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const meta = useMeta(descriptor); |
||||||
|
|
||||||
|
return <VideoPlayerHeader media={meta?.meta} {...props} />; |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { Helmet } from "react-helmet"; |
||||||
|
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
||||||
|
|
||||||
|
export function PageTitleAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const { isSeries, humanizedEpisodeId, meta } = |
||||||
|
useCurrentSeriesEpisodeInfo(descriptor); |
||||||
|
|
||||||
|
if (!meta) return null; |
||||||
|
|
||||||
|
const title = isSeries ? `${meta.title} - ${humanizedEpisodeId}` : meta.title; |
||||||
|
|
||||||
|
return ( |
||||||
|
<Helmet> |
||||||
|
<title>{title}</title> |
||||||
|
</Helmet> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useSource } from "@/video/state/logic/source"; |
||||||
|
|
||||||
|
export function QualityDisplayAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const source = useSource(descriptor); |
||||||
|
|
||||||
|
if (!source.source) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="rounded-md bg-denim-300 py-1 px-2 transition-colors"> |
||||||
|
<p className="text-center text-xs font-bold text-slate-300 transition-colors"> |
||||||
|
{source.source.quality} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useCurrentSeriesEpisodeInfo } from "../hooks/useCurrentSeriesEpisodeInfo"; |
||||||
|
|
||||||
|
export function ShowTitleAction() { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const { isSeries, currentEpisodeInfo, humanizedEpisodeId } = |
||||||
|
useCurrentSeriesEpisodeInfo(descriptor); |
||||||
|
|
||||||
|
if (!isSeries) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<p className="ml-8 select-none space-x-2 text-white"> |
||||||
|
<span>{humanizedEpisodeId}</span> |
||||||
|
<span className="opacity-50">{currentEpisodeInfo?.title}</span> |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useControls } from "@/video/state/logic/controls"; |
||||||
|
import { useEffect } from "react"; |
||||||
|
|
||||||
|
interface MetaControllerProps { |
||||||
|
meta?: MWMediaMeta; |
||||||
|
} |
||||||
|
|
||||||
|
export function MetaController(props: MetaControllerProps) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const controls = useControls(descriptor); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
controls.setMeta(props.meta); |
||||||
|
}, [props, controls]); |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import { MWMediaType } from "@/backend/metadata/types"; |
||||||
|
import { useMeta } from "@/video/state/logic/meta"; |
||||||
|
import { useMemo } from "react"; |
||||||
|
|
||||||
|
export function useCurrentSeriesEpisodeInfo(descriptor: string) { |
||||||
|
const meta = useMeta(descriptor); |
||||||
|
|
||||||
|
const currentSeasonInfo = useMemo(() => { |
||||||
|
return meta?.seasons?.find( |
||||||
|
(season) => season.id === meta?.episode?.seasonId |
||||||
|
); |
||||||
|
}, [meta]); |
||||||
|
|
||||||
|
const currentEpisodeInfo = useMemo(() => { |
||||||
|
return currentSeasonInfo?.episodes?.find( |
||||||
|
(episode) => episode.id === meta?.episode?.episodeId |
||||||
|
); |
||||||
|
}, [currentSeasonInfo, meta]); |
||||||
|
|
||||||
|
const isSeries = Boolean( |
||||||
|
meta?.meta?.type === MWMediaType.SERIES && meta?.episode |
||||||
|
); |
||||||
|
|
||||||
|
if (!isSeries) return { isSeries: false }; |
||||||
|
|
||||||
|
const humanizedEpisodeId = `S${currentSeasonInfo?.number} E${currentEpisodeInfo?.number}`; |
||||||
|
|
||||||
|
return { |
||||||
|
isSeries: true, |
||||||
|
humanizedEpisodeId, |
||||||
|
currentSeasonInfo, |
||||||
|
currentEpisodeInfo, |
||||||
|
meta: meta?.meta, |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,37 @@ |
|||||||
|
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||||
|
import { Icons } from "@/components/Icon"; |
||||||
|
import { Title } from "@/components/text/Title"; |
||||||
|
import { useVideoPlayerDescriptor } from "@/video/state/hooks"; |
||||||
|
import { useMeta } from "@/video/state/logic/meta"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
import { VideoPlayerHeader } from "./VideoPlayerHeader"; |
||||||
|
|
||||||
|
interface VideoPlayerErrorProps { |
||||||
|
onGoBack?: () => void; |
||||||
|
children?: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export function VideoPlayerError(props: VideoPlayerErrorProps) { |
||||||
|
const descriptor = useVideoPlayerDescriptor(); |
||||||
|
const meta = useMeta(descriptor); |
||||||
|
// TODO add error state
|
||||||
|
|
||||||
|
const err = null as any; |
||||||
|
|
||||||
|
if (!err) return props.children as any; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-denim-100"> |
||||||
|
<IconPatch icon={Icons.WARNING} className="mb-6 text-red-400" /> |
||||||
|
<Title>Failed to load media</Title> |
||||||
|
<p className="my-6 max-w-lg"> |
||||||
|
{err?.name}: {err?.description} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"> |
||||||
|
<VideoPlayerHeader media={meta?.meta} onClick={props.onGoBack} /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||||
|
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { BrandPill } from "@/components/layout/BrandPill"; |
||||||
|
import { |
||||||
|
getIfBookmarkedFromPortable, |
||||||
|
useBookmarkContext, |
||||||
|
} from "@/state/bookmark"; |
||||||
|
|
||||||
|
interface VideoPlayerHeaderProps { |
||||||
|
media?: MWMediaMeta; |
||||||
|
onClick?: () => void; |
||||||
|
showControls?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) { |
||||||
|
const { bookmarkStore, setItemBookmark } = useBookmarkContext(); |
||||||
|
const isBookmarked = props.media |
||||||
|
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media) |
||||||
|
: false; |
||||||
|
const showDivider = props.media && props.onClick; |
||||||
|
return ( |
||||||
|
<div className="flex items-center"> |
||||||
|
<div className="flex flex-1 items-center"> |
||||||
|
<p className="flex items-center"> |
||||||
|
{props.onClick ? ( |
||||||
|
<span |
||||||
|
onClick={props.onClick} |
||||||
|
className="flex cursor-pointer items-center py-1 text-white opacity-50 transition-opacity hover:opacity-100" |
||||||
|
> |
||||||
|
<Icon className="mr-2" icon={Icons.ARROW_LEFT} /> |
||||||
|
<span>Back to home</span> |
||||||
|
</span> |
||||||
|
) : null} |
||||||
|
{showDivider ? ( |
||||||
|
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" /> |
||||||
|
) : null} |
||||||
|
{props.media ? ( |
||||||
|
<span className="flex items-center text-white"> |
||||||
|
<span>{props.media.title}</span> |
||||||
|
</span> |
||||||
|
) : null} |
||||||
|
</p> |
||||||
|
{props.media && ( |
||||||
|
<IconPatch |
||||||
|
clickable |
||||||
|
transparent |
||||||
|
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE} |
||||||
|
className="ml-2 text-white" |
||||||
|
onClick={() => |
||||||
|
props.media && setItemBookmark(props.media, !isBookmarked) |
||||||
|
} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{props.showControls ? null : ( |
||||||
|
// <>
|
||||||
|
// <AirplayControl />
|
||||||
|
// <ChromeCastControl />
|
||||||
|
// </>
|
||||||
|
<BrandPill /> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,35 @@ |
|||||||
|
import { useEffect, useState } from "react"; |
||||||
|
import { getPlayerState } from "../cache"; |
||||||
|
import { listenEvent, sendEvent, unlistenEvent } from "../events"; |
||||||
|
import { VideoPlayerMeta, VideoPlayerState } from "../types"; |
||||||
|
|
||||||
|
export type VideoMetaEvent = VideoPlayerMeta | null; |
||||||
|
|
||||||
|
function getMetaFromState(state: VideoPlayerState): VideoMetaEvent { |
||||||
|
return state.meta |
||||||
|
? { |
||||||
|
...state.meta, |
||||||
|
} |
||||||
|
: null; |
||||||
|
} |
||||||
|
|
||||||
|
export function updateMeta(descriptor: string, state: VideoPlayerState) { |
||||||
|
sendEvent<VideoMetaEvent>(descriptor, "meta", getMetaFromState(state)); |
||||||
|
} |
||||||
|
|
||||||
|
export function useMeta(descriptor: string): VideoMetaEvent { |
||||||
|
const state = getPlayerState(descriptor); |
||||||
|
const [data, setData] = useState<VideoMetaEvent>(getMetaFromState(state)); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function update(payload: CustomEvent<VideoMetaEvent>) { |
||||||
|
setData(payload.detail); |
||||||
|
} |
||||||
|
listenEvent(descriptor, "meta", update); |
||||||
|
return () => { |
||||||
|
unlistenEvent(descriptor, "meta", update); |
||||||
|
}; |
||||||
|
}, [descriptor]); |
||||||
|
|
||||||
|
return data; |
||||||
|
} |
||||||
@ -1,47 +1,66 @@ |
|||||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||||
|
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||||
import { VideoPlayerStateProvider } from "./providers/providerTypes"; |
import { VideoPlayerStateProvider } from "./providers/providerTypes"; |
||||||
|
|
||||||
|
export type VideoPlayerMeta = { |
||||||
|
meta: MWMediaMeta; |
||||||
|
episode?: { |
||||||
|
episodeId: string; |
||||||
|
seasonId: string; |
||||||
|
}; |
||||||
|
seasons?: { |
||||||
|
id: string; |
||||||
|
number: number; |
||||||
|
title: string; |
||||||
|
episodes?: { id: string; number: number; title: string }[]; |
||||||
|
}[]; |
||||||
|
}; |
||||||
|
|
||||||
export type VideoPlayerState = { |
export type VideoPlayerState = { |
||||||
isPlaying: boolean; |
// state related to the user interface
|
||||||
isPaused: boolean; |
interface: { |
||||||
isSeeking: boolean; |
isFullscreen: boolean; |
||||||
isLoading: boolean; |
popout: string | null; // id of current popout (eg source select, episode select)
|
||||||
isFirstLoading: boolean; |
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
||||||
isFullscreen: boolean; |
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||||
time: number; |
|
||||||
duration: number; |
|
||||||
volume: number; |
|
||||||
buffered: number; |
|
||||||
pausedWhenSeeking: boolean; |
|
||||||
hasInitialized: boolean; |
|
||||||
leftControlHovering: boolean; |
|
||||||
hasPlayedOnce: boolean; |
|
||||||
popout: string | null; |
|
||||||
isFocused: boolean; |
|
||||||
seasonData: { |
|
||||||
isSeries: boolean; |
|
||||||
current?: { |
|
||||||
episodeId: string; |
|
||||||
seasonId: string; |
|
||||||
}; |
|
||||||
seasons?: { |
|
||||||
id: string; |
|
||||||
number: number; |
|
||||||
title: string; |
|
||||||
episodes?: { id: string; number: number; title: string }[]; |
|
||||||
}[]; |
|
||||||
}; |
}; |
||||||
|
|
||||||
error: null | { |
// state related to the playing state of the media
|
||||||
name: string; |
mediaPlaying: { |
||||||
description: string; |
isPlaying: boolean; |
||||||
|
isPaused: boolean; |
||||||
|
isSeeking: boolean; // seeking with progress bar
|
||||||
|
isLoading: boolean; // buffering or not
|
||||||
|
isFirstLoading: boolean; // first buffering of the video, used to show
|
||||||
|
hasPlayedOnce: boolean; // has the video played at all?
|
||||||
}; |
}; |
||||||
canAirplay: boolean; |
|
||||||
stateProvider: VideoPlayerStateProvider | null; |
// state related to video progress
|
||||||
|
progress: { |
||||||
|
time: number; |
||||||
|
duration: number; |
||||||
|
buffered: number; |
||||||
|
}; |
||||||
|
|
||||||
|
// meta data of video
|
||||||
|
meta: null | VideoPlayerMeta; |
||||||
source: null | { |
source: null | { |
||||||
quality: MWStreamQuality; |
quality: MWStreamQuality; |
||||||
url: string; |
url: string; |
||||||
type: MWStreamType; |
type: MWStreamType; |
||||||
}; |
}; |
||||||
|
error: null | { |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
}; |
||||||
|
|
||||||
|
// misc
|
||||||
|
volume: number; |
||||||
|
pausedWhenSeeking: boolean; |
||||||
|
hasInitialized: boolean; |
||||||
|
canAirplay: boolean; |
||||||
|
|
||||||
|
// backing fields
|
||||||
|
stateProvider: VideoPlayerStateProvider | null; |
||||||
wrapperElement: HTMLDivElement | null; |
wrapperElement: HTMLDivElement | null; |
||||||
}; |
}; |
||||||
|
|||||||
Loading…
Reference in new issue