Browse Source
Co-authored-by: James Hawkins <jhawki2005@gmail.com> Co-authored-by: William Oldham <wegg7250@gmail.com>pull/60/head
26 changed files with 511 additions and 119 deletions
@ -1,6 +1,6 @@ |
|||||||
{ |
{ |
||||||
"files.eol": "\n", |
"files.eol": "\n", |
||||||
"editor.detectIndentation": false, |
"editor.detectIndentation": false, |
||||||
"editor.formatOnSave": false, |
"editor.formatOnSave": true, |
||||||
"editor.tabSize": 2 |
"editor.tabSize": 2 |
||||||
} |
} |
@ -0,0 +1,19 @@ |
|||||||
|
export interface DotListProps { |
||||||
|
content: string[]; |
||||||
|
className?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function DotList(props: DotListProps) { |
||||||
|
return ( |
||||||
|
<p className={`text-denim-700 font-semibold ${props.className || ""}`}> |
||||||
|
{props.content.map((item, index) => ( |
||||||
|
<span key={item}> |
||||||
|
{index !== 0 ? ( |
||||||
|
<span className="mx-[0.6em] text-[1em]">●</span> |
||||||
|
) : null} |
||||||
|
{item} |
||||||
|
</span> |
||||||
|
))} |
||||||
|
</p> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
import { ReactNode } from "react"; |
||||||
|
|
||||||
|
export interface PaperProps { |
||||||
|
children?: ReactNode, |
||||||
|
className?: string, |
||||||
|
} |
||||||
|
|
||||||
|
export function Paper(props: PaperProps) { |
||||||
|
return ( |
||||||
|
<div className={`bg-denim-200 rounded-xl p-12 ${props.className}`}> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
export interface EpisodeProps { |
||||||
|
progress?: number; |
||||||
|
episodeNumber: number; |
||||||
|
} |
||||||
|
|
||||||
|
export function Episode(props: EpisodeProps) { |
||||||
|
return ( |
||||||
|
<div className="bg-denim-500 hover:bg-denim-400 transition-[background-color, transform] relative mr-3 mb-3 inline-flex h-10 w-10 cursor-pointer select-none items-center justify-center overflow-hidden rounded font-bold text-white active:scale-110"> |
||||||
|
<div |
||||||
|
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50" |
||||||
|
style={{ |
||||||
|
width: `${props.progress || 0}%`, |
||||||
|
}} |
||||||
|
/> |
||||||
|
<span className="relative">{props.episodeNumber}</span> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
import { SimpleCache } from "utils/cache"; |
||||||
|
import { MWPortableMedia, MWMedia } from "providers"; |
||||||
|
|
||||||
|
// cache
|
||||||
|
const contentCache = new SimpleCache<MWPortableMedia, MWMedia>(); |
||||||
|
contentCache.setCompare((a,b) => a.mediaId === b.mediaId && a.providerId === b.providerId); |
||||||
|
contentCache.initialize(); |
||||||
|
|
||||||
|
export default contentCache; |
@ -0,0 +1,100 @@ |
|||||||
|
import { MWMediaMeta } from "providers"; |
||||||
|
import { createContext, ReactNode, useContext, useState } from "react"; |
||||||
|
import { BookmarkStore } from "./store"; |
||||||
|
|
||||||
|
interface BookmarkStoreData { |
||||||
|
bookmarks: MWMediaMeta[]; |
||||||
|
} |
||||||
|
|
||||||
|
interface BookmarkStoreDataWrapper { |
||||||
|
setItemBookmark(media: MWMediaMeta, bookedmarked: boolean): void; |
||||||
|
bookmarkStore: BookmarkStoreData; |
||||||
|
} |
||||||
|
|
||||||
|
const BookmarkedContext = createContext<BookmarkStoreDataWrapper>({ |
||||||
|
setItemBookmark: () => {}, |
||||||
|
bookmarkStore: { |
||||||
|
bookmarks: [], |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
export function BookmarkContextProvider(props: { children: ReactNode }) { |
||||||
|
const bookmarkLocalstorage = BookmarkStore.get(); |
||||||
|
const [bookmarkStorage, setBookmarkStore] = useState<BookmarkStoreData>( |
||||||
|
bookmarkLocalstorage as BookmarkStoreData |
||||||
|
); |
||||||
|
|
||||||
|
function setBookmarked(data: any) { |
||||||
|
setBookmarkStore((old) => { |
||||||
|
let old2 = JSON.parse(JSON.stringify(old)); |
||||||
|
let newData = data; |
||||||
|
if (data.constructor === Function) { |
||||||
|
newData = data(old2); |
||||||
|
} |
||||||
|
bookmarkLocalstorage.save(newData); |
||||||
|
return newData; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const contextValue = { |
||||||
|
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) { |
||||||
|
setBookmarked((data: BookmarkStoreData) => { |
||||||
|
if (bookmarked) { |
||||||
|
const itemIndex = getBookmarkIndexFromPortable(data, media); |
||||||
|
if (itemIndex === -1) { |
||||||
|
const item = { |
||||||
|
mediaId: media.mediaId, |
||||||
|
mediaType: media.mediaType, |
||||||
|
providerId: media.providerId, |
||||||
|
title: media.title, |
||||||
|
year: media.year, |
||||||
|
episode: media.episode, |
||||||
|
season: media.season, |
||||||
|
}; |
||||||
|
data.bookmarks.push(item); |
||||||
|
} |
||||||
|
} else { |
||||||
|
const itemIndex = getBookmarkIndexFromPortable(data, media); |
||||||
|
if (itemIndex !== -1) { |
||||||
|
data.bookmarks.splice(itemIndex); |
||||||
|
} |
||||||
|
} |
||||||
|
return data; |
||||||
|
}); |
||||||
|
}, |
||||||
|
bookmarkStore: bookmarkStorage, |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<BookmarkedContext.Provider value={contextValue}> |
||||||
|
{props.children} |
||||||
|
</BookmarkedContext.Provider> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function useBookmarkContext() { |
||||||
|
return useContext(BookmarkedContext); |
||||||
|
} |
||||||
|
|
||||||
|
function getBookmarkIndexFromPortable( |
||||||
|
store: BookmarkStoreData, |
||||||
|
media: MWMediaMeta |
||||||
|
): number { |
||||||
|
const a = store.bookmarks.findIndex((v) => { |
||||||
|
return ( |
||||||
|
v.mediaId === media.mediaId && |
||||||
|
v.providerId === media.providerId && |
||||||
|
v.episode === media.episode && |
||||||
|
v.season === media.season |
||||||
|
); |
||||||
|
}); |
||||||
|
return a; |
||||||
|
} |
||||||
|
|
||||||
|
export function getIfBookmarkedFromPortable( |
||||||
|
store: BookmarkStoreData, |
||||||
|
media: MWMediaMeta |
||||||
|
): boolean { |
||||||
|
const bookmarked = getBookmarkIndexFromPortable(store, media); |
||||||
|
return bookmarked !== -1; |
||||||
|
} |
@ -0,0 +1,45 @@ |
|||||||
|
import { versionedStoreBuilder } from 'utils/storage'; |
||||||
|
|
||||||
|
/* |
||||||
|
version 0 |
||||||
|
{ |
||||||
|
[{scraperid}]: { |
||||||
|
movie: { |
||||||
|
[{movie-id}]: { |
||||||
|
full: { |
||||||
|
currentlyAt: number, |
||||||
|
totalDuration: number, |
||||||
|
updatedAt: number, // unix timestamp in ms
|
||||||
|
meta: FullMetaObject, // no idea whats in here
|
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
show: { |
||||||
|
[{show-id}]: { |
||||||
|
[{season}-{episode}]: { |
||||||
|
currentlyAt: number, |
||||||
|
totalDuration: number, |
||||||
|
updatedAt: number, // unix timestamp in ms
|
||||||
|
show: { |
||||||
|
episode: string, |
||||||
|
season: string, |
||||||
|
}, |
||||||
|
meta: FullMetaObject, // no idea whats in here
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
*/ |
||||||
|
|
||||||
|
export const BookmarkStore = versionedStoreBuilder() |
||||||
|
.setKey('mw-bookmarks') |
||||||
|
.addVersion({ |
||||||
|
version: 0, |
||||||
|
create() { |
||||||
|
return { |
||||||
|
bookmarks: [] |
||||||
|
} |
||||||
|
} |
||||||
|
}) |
||||||
|
.build() |
@ -0,0 +1,153 @@ |
|||||||
|
import { IconPatch } from "components/buttons/IconPatch"; |
||||||
|
import { Icons } from "components/Icon"; |
||||||
|
import { Navigation } from "components/layout/Navigation"; |
||||||
|
import { Paper } from "components/layout/Paper"; |
||||||
|
import { SkeletonVideoPlayer, VideoPlayer } from "components/media/VideoPlayer"; |
||||||
|
import { ArrowLink } from "components/text/ArrowLink"; |
||||||
|
import { DotList } from "components/text/DotList"; |
||||||
|
import { Title } from "components/text/Title"; |
||||||
|
import { useLoading } from "hooks/useLoading"; |
||||||
|
import { usePortableMedia } from "hooks/usePortableMedia"; |
||||||
|
import { |
||||||
|
MWPortableMedia, |
||||||
|
getStream, |
||||||
|
MWMediaStream, |
||||||
|
MWMedia, |
||||||
|
convertPortableToMedia, |
||||||
|
getProviderFromId, |
||||||
|
MWMediaProvider, |
||||||
|
} from "providers"; |
||||||
|
import { ReactNode, useEffect, useState } from "react"; |
||||||
|
import { |
||||||
|
getIfBookmarkedFromPortable, |
||||||
|
useBookmarkContext, |
||||||
|
} from "state/bookmark"; |
||||||
|
import { getWatchedFromPortable, useWatchedContext } from "state/watched"; |
||||||
|
|
||||||
|
interface StyledMediaViewProps { |
||||||
|
media: MWMedia; |
||||||
|
stream: MWMediaStream; |
||||||
|
provider: MWMediaProvider; |
||||||
|
} |
||||||
|
|
||||||
|
function StyledMediaView(props: StyledMediaViewProps) { |
||||||
|
const store = useWatchedContext(); |
||||||
|
const startAtTime: number | undefined = getWatchedFromPortable( |
||||||
|
store.watched, |
||||||
|
props.media |
||||||
|
)?.progress; |
||||||
|
const { setItemBookmark, bookmarkStore } = useBookmarkContext(); |
||||||
|
const isBookmarked = getIfBookmarkedFromPortable(bookmarkStore, props.media); |
||||||
|
|
||||||
|
function updateProgress(e: Event) { |
||||||
|
if (!props.media) return; |
||||||
|
const el: HTMLVideoElement = e.currentTarget as HTMLVideoElement; |
||||||
|
if (el.currentTime <= 30) { |
||||||
|
return; // Don't update stored progress if less than 30s into the video
|
||||||
|
} |
||||||
|
store.updateProgress(props.media, el.currentTime, el.duration); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<VideoPlayer |
||||||
|
source={props.stream} |
||||||
|
onProgress={updateProgress} |
||||||
|
startAt={startAtTime} |
||||||
|
/> |
||||||
|
<Paper className="mt-5"> |
||||||
|
<div className="flex"> |
||||||
|
<div className="flex-1"> |
||||||
|
<Title>{props.media.title}</Title> |
||||||
|
<DotList |
||||||
|
className="mt-3 text-sm" |
||||||
|
content={[ |
||||||
|
props.provider.displayName, |
||||||
|
props.media.mediaType, |
||||||
|
props.media.year, |
||||||
|
]} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<IconPatch |
||||||
|
icon={Icons.BOOKMARK} |
||||||
|
active={isBookmarked} |
||||||
|
onClick={() => setItemBookmark(props.media, !isBookmarked)} |
||||||
|
clickable |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Paper> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function LoadingMediaView(props: { error?: boolean }) { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<SkeletonVideoPlayer error={props.error} /> |
||||||
|
<Paper className="mt-5"> |
||||||
|
<div className="flex"> |
||||||
|
<div className="flex-1"> |
||||||
|
<div className="bg-denim-500 mb-2 h-4 w-48 rounded-full" /> |
||||||
|
<div> |
||||||
|
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" /> |
||||||
|
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Paper> |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function MediaView() { |
||||||
|
const mediaPortable: MWPortableMedia | undefined = usePortableMedia(); |
||||||
|
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>(); |
||||||
|
const [media, setMedia] = useState<MWMedia | undefined>(); |
||||||
|
const [fetchAllData, loading, error] = useLoading((mediaPortable) => { |
||||||
|
const streamPromise = getStream(mediaPortable); |
||||||
|
const mediaPromise = convertPortableToMedia(mediaPortable); |
||||||
|
return Promise.all([streamPromise, mediaPromise]); |
||||||
|
}); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
(async () => { |
||||||
|
if (mediaPortable) { |
||||||
|
const resultData = await fetchAllData(mediaPortable); |
||||||
|
if (!resultData) return; |
||||||
|
setStreamUrl(resultData[0]); |
||||||
|
setMedia(resultData[1]); |
||||||
|
} |
||||||
|
})(); |
||||||
|
}, [mediaPortable, setStreamUrl]); |
||||||
|
|
||||||
|
let content: ReactNode; |
||||||
|
if (loading) content = <LoadingMediaView />; |
||||||
|
else if (error) content = <LoadingMediaView error />; |
||||||
|
else if (mediaPortable && media && streamUrl) |
||||||
|
content = ( |
||||||
|
<StyledMediaView |
||||||
|
provider={ |
||||||
|
getProviderFromId(mediaPortable.providerId) as MWMediaProvider |
||||||
|
} |
||||||
|
media={media} |
||||||
|
stream={streamUrl} |
||||||
|
/> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="w-full"> |
||||||
|
<Navigation> |
||||||
|
<ArrowLink |
||||||
|
onClick={() => window.history.back()} |
||||||
|
direction="left" |
||||||
|
linkText="Go back" |
||||||
|
/> |
||||||
|
</Navigation> |
||||||
|
<div className="container mx-auto mt-40 mb-16 max-w-[1100px]"> |
||||||
|
{content} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -1,34 +0,0 @@ |
|||||||
import { VideoPlayer } from "components/media/VideoPlayer"; |
|
||||||
import { usePortableMedia } from "hooks/usePortableMedia"; |
|
||||||
import { MWPortableMedia, getStream, MWMediaStream } from "providers"; |
|
||||||
import { useEffect, useState } from "react"; |
|
||||||
import { useWatchedContext } from "state/watched"; |
|
||||||
|
|
||||||
export function MovieView() { |
|
||||||
const mediaPortable: MWPortableMedia | undefined = usePortableMedia(); |
|
||||||
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>(); |
|
||||||
const store = useWatchedContext(); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
(async () => { |
|
||||||
setStreamUrl(mediaPortable && (await getStream(mediaPortable))); |
|
||||||
})(); |
|
||||||
}, [mediaPortable, setStreamUrl]); |
|
||||||
|
|
||||||
function updateProgress(e: Event) { |
|
||||||
if (!mediaPortable) return; |
|
||||||
const el: HTMLVideoElement = e.currentTarget as HTMLVideoElement; |
|
||||||
store.updateProgress(mediaPortable, el.currentTime, el.duration); |
|
||||||
} |
|
||||||
|
|
||||||
return ( |
|
||||||
<div> |
|
||||||
<p>Movie view here</p> |
|
||||||
<p>{JSON.stringify(mediaPortable, null, 2)}</p> |
|
||||||
<p></p> |
|
||||||
{streamUrl ? ( |
|
||||||
<VideoPlayer source={streamUrl} onProgress={updateProgress} /> |
|
||||||
) : null} |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
Loading…
Reference in new issue