progress, bookmarking, homepage, resuming where left of, actual media view, navigation improvements for searching

Co-authored-by: James Hawkins <jhawki2005@gmail.com>
Co-authored-by: William Oldham <wegg7250@gmail.com>
This commit is contained in:
mrjvs 2022-02-28 00:08:20 +01:00
parent cfb907924e
commit 60e6b4d851
26 changed files with 511 additions and 119 deletions

View File

@ -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
} }

View File

@ -39,17 +39,18 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
- [x] Add results list end - [x] Add results list end
- [x] Store watched percentage - [x] Store watched percentage
- [x] Add Brand tag top left - [x] Add Brand tag top left
- [X] Add github and discord top right - [x] Add github and discord top right
- [x] Link Github and Discord in error boundary - [x] Link Github and Discord in error boundary
- [ ] Implement movie + series view - [ ] Implement movie + series view
- [ ] Global state for media objects - [x] Global state for media objects
- [ ] Styling for pages - [x] Styling for pages
- [ ] loading video player view + error
- [ ] Series episodes+seasons - [ ] Series episodes+seasons
- [ ] On back button, persist the search query and results - [x] On back button, persist the search query and results
- [ ] Bookmarking - [x] Bookmarking
- [ ] Resume from where you left of - [x] Resume from where you left of
- [ ] Less spaghett video player view - [ ] Less spaghett video player view (implement source that are not mp4)
- [ ] Homepage continue watching + bookmarks - [x] Homepage continue watching + bookmarks
- [x] Add provider stream method - [x] Add provider stream method
- [x] Better looking error boundary - [x] Better looking error boundary
- [x] sort search results so they aren't sorted by provider - [x] sort search results so they aren't sorted by provider
@ -57,10 +58,13 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
- [ ] Migrate old video progress - [ ] Migrate old video progress
- [ ] Get rid of react warnings - [ ] Get rid of react warnings
- [ ] Implement more scrapers - [ ] Implement more scrapers
- [ ] Add 404 page for media (media not found, provider disabled, provider not found) & general (page not found) - [ ] Add 404 page for media (media not found, provider disabled, provider not found) & general (page not found) <---
- [x] Change text of "thats all we have"
## Todo's after rewrite ## Todo's after rewrite
- [ ] Less spaghetti versioned storage (typesafe and works functionally) - [ ] Less spaghetti versioned storage (typesafe and works functionally)
- [ ] Add a way to remove from continue watching
- [ ] i18n
- [ ] better mobile search type selector - [ ] better mobile search type selector
- [ ] Custom video player - [ ] Custom video player

View File

@ -1,22 +1,24 @@
import { MWMediaType } from "providers"; import { MWMediaType } from "providers";
import { Redirect, Route, Switch } from "react-router-dom"; import { Redirect, Route, Switch } from "react-router-dom";
import { WatchedContextProvider } from "state/watched/context"; import { BookmarkContextProvider } from "state/bookmark";
import { WatchedContextProvider } from "state/watched";
import "./index.css"; import "./index.css";
import { MovieView } from "./views/MovieView"; import { MediaView } from "./views/MediaView";
import { SearchView } from "./views/SearchView"; import { SearchView } from "./views/SearchView";
import { SeriesView } from "./views/SeriesView";
function App() { function App() {
return ( return (
<WatchedContextProvider> <WatchedContextProvider>
<Switch> <BookmarkContextProvider>
<Route exact path="/"> <Switch>
<Redirect to={`/${MWMediaType.MOVIE}`} /> <Route exact path="/">
</Route> <Redirect to={`/${MWMediaType.MOVIE}`} />
<Route exact path="/media/movie/:media" component={MovieView} /> </Route>
<Route exact path="/media/series/:media" component={SeriesView} /> <Route exact path="/media/movie/:media" component={MediaView} />
<Route exact path="/:type/:query?" component={SearchView} /> <Route exact path="/media/series/:media" component={MediaView} />
</Switch> <Route exact path="/:type/:query?" component={SearchView} />
</Switch>
</BookmarkContextProvider>
</WatchedContextProvider> </WatchedContextProvider>
); );
} }

View File

@ -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]">&#9679;</span>
) : null}
{item}
</span>
))}
</p>
);
}

View File

@ -5,7 +5,7 @@ export function BrandPill() {
return ( return (
<div className="bg-bink-100 bg-opacity-50 text-bink-600 rounded-full flex items-center space-x-2 px-4 py-2"> <div className="bg-bink-100 bg-opacity-50 text-bink-600 rounded-full flex items-center space-x-2 px-4 py-2">
<Icon className="text-xl" icon={Icons.MOVIE_WEB} /> <Icon className="text-xl" icon={Icons.MOVIE_WEB} />
<span className="font-semibold text-white">Movie Web</span> <span className="font-semibold text-white">movie-web</span>
</div> </div>
) )
} }

View File

@ -6,7 +6,7 @@ export interface LoadingProps {
export function Loading(props: LoadingProps) { export function Loading(props: LoadingProps) {
return ( return (
<div className={props.className}> <div className={props.className}>
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center">
<div className="flex h-12 items-center justify-center"> <div className="flex h-12 items-center justify-center">
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full"></div> <div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full"></div>
<div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]"></div> <div className="animate-loading-pin bg-denim-300 mx-1 h-2 w-2 rounded-full [animation-delay:150ms]"></div>

View File

@ -1,13 +1,24 @@
import { IconPatch } from "components/buttons/IconPatch"; import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon"; import { Icons } from "components/Icon";
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants"; import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
import { ReactNode } from "react";
import { Link } from "react-router-dom"
import { BrandPill } from "./BrandPill"; import { BrandPill } from "./BrandPill";
export function Navigation() { export interface NavigationProps {
children?: ReactNode;
}
export function Navigation(props: NavigationProps) {
return ( return (
<div className="flex justify-between items-center absolute left-0 right-0 top-0 py-5 px-7"> <div className="flex justify-between items-center absolute left-0 right-0 top-0 py-5 px-7">
<div> <div className="flex justify-center items-center">
<BrandPill /> <div className="mr-6">
<Link to="/">
<BrandPill/>
</Link>
</div>
{props.children}
</div> </div>
<div className="flex"> <div className="flex">
<a href={DISCORD_LINK} target="_blank" rel="noreferrer" className="text-2xl text-white"><IconPatch icon={Icons.DISCORD} clickable/></a> <a href={DISCORD_LINK} target="_blank" rel="noreferrer" className="text-2xl text-white"><IconPatch icon={Icons.DISCORD} clickable/></a>

View File

@ -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>
)
}

View File

@ -8,11 +8,12 @@ interface SectionHeadingProps {
children?: ReactNode; children?: ReactNode;
linkText?: string; linkText?: string;
onClick?: () => void; onClick?: () => void;
className?: string;
} }
export function SectionHeading(props: SectionHeadingProps) { export function SectionHeading(props: SectionHeadingProps) {
return ( return (
<div className="mt-12"> <div className={`mt-12 ${props.className}`}>
<div className="mb-4 flex items-end"> <div className="mb-4 flex items-end">
<p className="text-denim-700 flex flex-1 items-center font-bold uppercase"> <p className="text-denim-700 flex flex-1 items-center font-bold uppercase">
{props.icon ? ( {props.icon ? (

View File

@ -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>
);
}

View File

@ -1,38 +1,20 @@
import { import {
convertMediaToPortable, convertMediaToPortable,
getProviderFromId, getProviderFromId,
MWMedia, MWMediaMeta,
MWMediaType, MWMediaType,
} from "providers"; } from "providers";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Icon, Icons } from "components/Icon"; import { Icon, Icons } from "components/Icon";
import { serializePortableMedia } from "hooks/usePortableMedia"; import { serializePortableMedia } from "hooks/usePortableMedia";
import { DotList } from "components/text/DotList";
export interface MediaCardProps { export interface MediaCardProps {
media: MWMedia; media: MWMediaMeta;
watchedPercentage: Number; watchedPercentage: Number;
linkable?: boolean; linkable?: boolean;
} }
export interface MediaMetaProps {
content: string[];
}
function MediaMeta(props: MediaMetaProps) {
return (
<p className="text-denim-700 text-xs font-semibold">
{props.content.map((item, index) => (
<span key={item}>
{index !== 0 ? (
<span className="mx-[0.6em] text-[1em]">&#9679;</span>
) : null}
{item}
</span>
))}
</p>
);
}
function MediaCardContent({ function MediaCardContent({
media, media,
linkable, linkable,
@ -68,7 +50,8 @@ function MediaCardContent({
{/* card content */} {/* card content */}
<div className="flex-1"> <div className="flex-1">
<h1 className="mb-1 font-bold text-white">{media.title}</h1> <h1 className="mb-1 font-bold text-white">{media.title}</h1>
<MediaMeta <DotList
className="text-xs"
content={[provider.displayName, media.mediaType, media.year]} content={[provider.displayName, media.mediaType, media.year]}
/> />
</div> </div>

View File

@ -1,22 +1,48 @@
import { IconPatch } from "components/buttons/IconPatch";
import { Icons } from "components/Icon";
import { Loading } from "components/layout/Loading";
import { MWMediaStream } from "providers"; import { MWMediaStream } from "providers";
import { useRef } from "react"; import { useRef } from "react";
export interface VideoPlayerProps { export interface VideoPlayerProps {
source: MWMediaStream; source: MWMediaStream;
startAt?: number;
onProgress?: (event: ProgressEvent) => void; onProgress?: (event: ProgressEvent) => void;
} }
export function SkeletonVideoPlayer(props: { error?: boolean }) {
return (
<div className="bg-denim-200 flex aspect-video w-full items-center justify-center rounded-xl">
{props.error ? (
<div className="flex flex-col items-center">
<IconPatch icon={Icons.WARNING} className="text-red-400" />
<p className="mt-5 text-white">Couldn't get your stream</p>
</div>
) : (
<div className="flex flex-col items-center">
<Loading />
<p className="mt-3 text-white">Getting your stream...</p>
</div>
)}
</div>
);
}
export function VideoPlayer(props: VideoPlayerProps) { export function VideoPlayer(props: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const mustUseHls = props.source.type === "m3u8"; const mustUseHls = props.source.type === "m3u8";
return ( return (
<video <video
className="videoElement" className="bg-denim-500 w-full rounded-xl"
ref={videoRef} ref={videoRef}
onProgress={(e) => onProgress={(e) =>
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent) props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
} }
onLoadedData={(e) => {
if (props.startAt)
(e.target as HTMLVideoElement).currentTime = props.startAt;
}}
controls controls
autoPlay autoPlay
> >

View File

@ -1,9 +1,9 @@
import { MWMedia } from "providers"; import { MWMediaMeta } from "providers";
import { useWatchedContext, getWatchedFromPortable } from "state/watched"; import { useWatchedContext, getWatchedFromPortable } from "state/watched";
import { MediaCard } from "./MediaCard"; import { MediaCard } from "./MediaCard";
export interface WatchedMediaCardProps { export interface WatchedMediaCardProps {
media: MWMedia; media: MWMediaMeta;
} }
export function WatchedMediaCard(props: WatchedMediaCardProps) { export function WatchedMediaCard(props: WatchedMediaCardProps) {

View File

@ -13,7 +13,7 @@ export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
const updateParams = (inp: Partial<MWQuery>) => { const updateParams = (inp: Partial<MWQuery>) => {
const copySearch: MWQuery = {...search}; const copySearch: MWQuery = {...search};
Object.assign(copySearch, inp); Object.assign(copySearch, inp);
history.push(generatePath(path, { query: copySearch.searchQuery.length == 0 ? undefined : inp.searchQuery, type: copySearch.type })) history.push(generatePath(path, { query: copySearch.searchQuery.length === 0 ? undefined : inp.searchQuery, type: copySearch.type }))
} }
React.useEffect(() => { React.useEffect(() => {

View File

@ -4,6 +4,7 @@ import {
MWPortableMedia, MWPortableMedia,
MWMediaStream, MWMediaStream,
} from "./types"; } from "./types";
import contentCache from "./methods/contentCache";
export * from "./types"; export * from "./types";
export * from "./methods/helpers"; export * from "./methods/helpers";
export * from "./methods/providers"; export * from "./methods/providers";
@ -28,6 +29,10 @@ export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
export async function convertPortableToMedia( export async function convertPortableToMedia(
portable: MWPortableMedia portable: MWPortableMedia
): Promise<MWMedia | undefined> { ): Promise<MWMedia | undefined> {
// consult cache first
let output = contentCache.get(portable);
if (output) return output;
const provider = getProviderFromId(portable.providerId); const provider = getProviderFromId(portable.providerId);
return await provider?.getMediaFromPortable(portable); return await provider?.getMediaFromPortable(portable);
} }

View File

@ -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;

View File

@ -1,7 +1,8 @@
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { MWMassProviderOutput, MWMedia, MWQuery } from "providers"; import { MWMassProviderOutput, MWMedia, MWQuery, convertMediaToPortable } from "providers";
import { SimpleCache } from "utils/cache"; import { SimpleCache } from "utils/cache";
import { GetProvidersForType } from "./helpers"; import { GetProvidersForType } from "./helpers";
import contentCache from "./contentCache";
// cache // cache
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>(); const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>();
@ -52,6 +53,10 @@ async function callProviders(
resultCache.set(query, output, 60 * 60); // cache for an hour resultCache.set(query, output, 60 * 60); // cache for an hour
} }
output.results.forEach((result: MWMedia) => {
contentCache.set(convertMediaToPortable(result), result, 60 * 60);
})
return output; return output;
} }

View File

@ -18,11 +18,13 @@ export interface MWMediaStream {
type: MWMediaStreamType; type: MWMediaStreamType;
} }
export interface MWMedia extends MWPortableMedia { export interface MWMediaMeta extends MWPortableMedia {
title: string; title: string;
year: string; year: string;
} }
export interface MWMedia extends MWMediaMeta {}
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">; export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
export interface MWQuery { export interface MWQuery {

View File

@ -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;
}

View File

@ -0,0 +1 @@
export * from "./context";

View File

@ -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()

View File

@ -1,8 +1,8 @@
import { MWPortableMedia } from "providers"; import { MWMediaMeta } from "providers";
import React, { createContext, ReactNode, useContext, useState } from "react"; import React, { createContext, ReactNode, useContext, useState } from "react";
import { VideoProgressStore } from "./store"; import { VideoProgressStore } from "./store";
interface WatchedStoreItem extends MWPortableMedia { interface WatchedStoreItem extends MWMediaMeta {
progress: number; progress: number;
percentage: number; percentage: number;
} }
@ -12,13 +12,11 @@ interface WatchedStoreData {
} }
interface WatchedStoreDataWrapper { interface WatchedStoreDataWrapper {
setWatched: React.Dispatch<React.SetStateAction<WatchedStoreData>>; updateProgress(media: MWMediaMeta, progress: number, total: number): void;
updateProgress(media: MWPortableMedia, progress: number, total: number): void;
watched: WatchedStoreData; watched: WatchedStoreData;
} }
const WatchedContext = createContext<WatchedStoreDataWrapper>({ const WatchedContext = createContext<WatchedStoreDataWrapper>({
setWatched: () => {},
updateProgress: () => {}, updateProgress: () => {},
watched: { watched: {
items: [], items: [],
@ -44,11 +42,8 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
} }
const contextValue = { const contextValue = {
setWatched(data: any) {
return setWatched(data);
},
updateProgress( updateProgress(
media: MWPortableMedia, media: MWMediaMeta,
progress: number, progress: number,
total: number total: number
): void { ): void {
@ -59,6 +54,8 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
mediaId: media.mediaId, mediaId: media.mediaId,
mediaType: media.mediaType, mediaType: media.mediaType,
providerId: media.providerId, providerId: media.providerId,
title: media.title,
year: media.year,
percentage: 0, percentage: 0,
progress: 0, progress: 0,
episode: media.episode, episode: media.episode,
@ -90,7 +87,7 @@ export function useWatchedContext() {
export function getWatchedFromPortable( export function getWatchedFromPortable(
store: WatchedStoreData, store: WatchedStoreData,
media: MWPortableMedia media: MWMediaMeta
): WatchedStoreItem | undefined { ): WatchedStoreItem | undefined {
return store.items.find((v) => { return store.items.find((v) => {
return ( return (

153
src/views/MediaView.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -1,11 +1,6 @@
import { WatchedMediaCard } from "components/media/WatchedMediaCard"; import { WatchedMediaCard } from "components/media/WatchedMediaCard";
import { SearchBarInput } from "components/SearchBar"; import { SearchBarInput } from "components/SearchBar";
import { import { MWMassProviderOutput, MWQuery, SearchProviders } from "providers";
MWMassProviderOutput,
MWMediaType,
MWQuery,
SearchProviders,
} from "providers";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ThinContainer } from "components/layout/ThinContainer"; import { ThinContainer } from "components/layout/ThinContainer";
import { SectionHeading } from "components/layout/SectionHeading"; import { SectionHeading } from "components/layout/SectionHeading";
@ -18,9 +13,14 @@ import { useLoading } from "hooks/useLoading";
import { IconPatch } from "components/buttons/IconPatch"; import { IconPatch } from "components/buttons/IconPatch";
import { Navigation } from "components/layout/Navigation"; import { Navigation } from "components/layout/Navigation";
import { useSearchQuery } from "hooks/useSearchQuery"; import { useSearchQuery } from "hooks/useSearchQuery";
import { useWatchedContext } from "state/watched/context";
import {
getIfBookmarkedFromPortable,
useBookmarkContext,
} from "state/bookmark/context";
function SearchLoading() { function SearchLoading() {
return <Loading className="my-12" text="Fetching your favourite shows..." />; return <Loading className="my-24" text="Fetching your favourite shows..." />;
} }
function SearchSuffix(props: { function SearchSuffix(props: {
@ -43,13 +43,13 @@ function SearchSuffix(props: {
<div> <div>
{props.fails > 0 ? ( {props.fails > 0 ? (
<p className="text-red-400"> <p className="text-red-400">
{props.fails}/{props.total} providers failed {props.fails}/{props.total} providers failed!
</p> </p>
) : null} ) : null}
{props.resultsSize > 0 ? ( {props.resultsSize > 0 ? (
<p>That's all we have &mdash; sorry</p> <p>That's all we have!</p>
) : ( ) : (
<p>We couldn't find anything &mdash; sorry</p> <p>We couldn't find anything!</p>
)} )}
</div> </div>
) : null} ) : null}
@ -57,7 +57,7 @@ function SearchSuffix(props: {
{/* Error result */} {/* Error result */}
{allFailed ? ( {allFailed ? (
<div> <div>
<p>All providers failed &mdash; whoops</p> <p>All providers have failed!</p>
</div> </div>
) : null} ) : null}
</div> </div>
@ -151,7 +151,7 @@ export function SearchView() {
onChange={setSearch} onChange={setSearch}
value={search} value={search}
placeholder="What movie do you want to watch?" placeholder="What movie do you want to watch?"
/> />
</div> </div>
{/* results view */} {/* results view */}
@ -162,8 +162,46 @@ export function SearchView() {
searchQuery={debouncedSearch} searchQuery={debouncedSearch}
clear={() => setSearch({ searchQuery: "" })} clear={() => setSearch({ searchQuery: "" })}
/> />
) : null} ) : (
<ExtraItems />
)}
</ThinContainer> </ThinContainer>
</> </>
); );
} }
function ExtraItems() {
const { bookmarkStore } = useBookmarkContext();
const { watched } = useWatchedContext();
const watchedItems = watched.items.filter(
(v) => !getIfBookmarkedFromPortable(bookmarkStore, v)
);
if (watchedItems.length === 0 && bookmarkStore.bookmarks.length === 0)
return null;
return (
<div className="mb-16 mt-32">
{bookmarkStore.bookmarks.length > 0 ? (
<SectionHeading title="Bookmarks" icon={Icons.BOOKMARK}>
{bookmarkStore.bookmarks.map((v) => (
<WatchedMediaCard
key={[v.mediaId, v.providerId].join("|")}
media={v}
/>
))}
</SectionHeading>
) : null}
{watchedItems.length > 0 ? (
<SectionHeading title="Continue Watching" icon={Icons.CLOCK}>
{watchedItems.map((v) => (
<WatchedMediaCard
key={[v.mediaId, v.providerId].join("|")}
media={v}
/>
))}
</SectionHeading>
) : null}
</div>
);
}

View File

@ -1,7 +0,0 @@
export function SeriesView() {
return (
<div>
<p>Series view here</p>
</div>
)
}