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 @@
@@ -1,6 +1,6 @@
|
||||
{ |
||||
"files.eol": "\n", |
||||
"editor.detectIndentation": false, |
||||
"editor.formatOnSave": false, |
||||
"editor.formatOnSave": true, |
||||
"editor.tabSize": 2 |
||||
} |
@ -0,0 +1,19 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1 @@
|
||||
export * from "./context"; |
@ -0,0 +1,45 @@
@@ -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 @@
@@ -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 @@
@@ -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