20 changed files with 492 additions and 168 deletions
@ -0,0 +1,15 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,47 +1,66 @@
|
||||
import { MWStreamQuality, MWStreamType } from "@/backend/helpers/streams"; |
||||
import { MWMediaMeta } from "@/backend/metadata/types"; |
||||
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 = { |
||||
isPlaying: boolean; |
||||
isPaused: boolean; |
||||
isSeeking: boolean; |
||||
isLoading: boolean; |
||||
isFirstLoading: boolean; |
||||
isFullscreen: boolean; |
||||
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 }[]; |
||||
}[]; |
||||
// state related to the user interface
|
||||
interface: { |
||||
isFullscreen: boolean; |
||||
popout: string | null; // id of current popout (eg source select, episode select)
|
||||
isFocused: boolean; // is the video player the users focus? (shortcuts only works when its focused)
|
||||
leftControlHovering: boolean; // is the cursor hovered over the left side of player controls
|
||||
}; |
||||
|
||||
error: null | { |
||||
name: string; |
||||
description: string; |
||||
// state related to the playing state of the media
|
||||
mediaPlaying: { |
||||
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 | { |
||||
quality: MWStreamQuality; |
||||
url: string; |
||||
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; |
||||
}; |
||||
|
Loading…
Reference in new issue