mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 04:31:51 +01:00
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:
parent
cfb907924e
commit
60e6b4d851
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@ -1,6 +1,6 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"editor.detectIndentation": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.tabSize": 2
|
||||
}
|
22
README.md
22
README.md
@ -39,17 +39,18 @@ Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/iss
|
||||
- [x] Add results list end
|
||||
- [x] Store watched percentage
|
||||
- [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
|
||||
- [ ] Implement movie + series view
|
||||
- [ ] Global state for media objects
|
||||
- [ ] Styling for pages
|
||||
- [x] Global state for media objects
|
||||
- [x] Styling for pages
|
||||
- [ ] loading video player view + error
|
||||
- [ ] Series episodes+seasons
|
||||
- [ ] On back button, persist the search query and results
|
||||
- [ ] Bookmarking
|
||||
- [ ] Resume from where you left of
|
||||
- [ ] Less spaghett video player view
|
||||
- [ ] Homepage continue watching + bookmarks
|
||||
- [x] On back button, persist the search query and results
|
||||
- [x] Bookmarking
|
||||
- [x] Resume from where you left of
|
||||
- [ ] Less spaghett video player view (implement source that are not mp4)
|
||||
- [x] Homepage continue watching + bookmarks
|
||||
- [x] Add provider stream method
|
||||
- [x] Better looking error boundary
|
||||
- [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
|
||||
- [ ] Get rid of react warnings
|
||||
- [ ] 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
|
||||
|
||||
- [ ] Less spaghetti versioned storage (typesafe and works functionally)
|
||||
- [ ] Add a way to remove from continue watching
|
||||
- [ ] i18n
|
||||
- [ ] better mobile search type selector
|
||||
- [ ] Custom video player
|
||||
|
24
src/App.tsx
24
src/App.tsx
@ -1,22 +1,24 @@
|
||||
import { MWMediaType } from "providers";
|
||||
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 { MovieView } from "./views/MovieView";
|
||||
import { MediaView } from "./views/MediaView";
|
||||
import { SearchView } from "./views/SearchView";
|
||||
import { SeriesView } from "./views/SeriesView";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<WatchedContextProvider>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
<Route exact path="/media/movie/:media" component={MovieView} />
|
||||
<Route exact path="/media/series/:media" component={SeriesView} />
|
||||
<Route exact path="/:type/:query?" component={SearchView} />
|
||||
</Switch>
|
||||
<BookmarkContextProvider>
|
||||
<Switch>
|
||||
<Route exact path="/">
|
||||
<Redirect to={`/${MWMediaType.MOVIE}`} />
|
||||
</Route>
|
||||
<Route exact path="/media/movie/:media" component={MediaView} />
|
||||
<Route exact path="/media/series/:media" component={MediaView} />
|
||||
<Route exact path="/:type/:query?" component={SearchView} />
|
||||
</Switch>
|
||||
</BookmarkContextProvider>
|
||||
</WatchedContextProvider>
|
||||
);
|
||||
}
|
||||
|
19
src/components/Text/DotList.tsx
Normal file
19
src/components/Text/DotList.tsx
Normal 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]">●</span>
|
||||
) : null}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
@ -5,7 +5,7 @@ export function BrandPill() {
|
||||
return (
|
||||
<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} />
|
||||
<span className="font-semibold text-white">Movie Web</span>
|
||||
<span className="font-semibold text-white">movie-web</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ export interface LoadingProps {
|
||||
export function Loading(props: LoadingProps) {
|
||||
return (
|
||||
<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="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>
|
||||
|
@ -1,13 +1,24 @@
|
||||
import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Icons } from "components/Icon";
|
||||
import { DISCORD_LINK, GITHUB_LINK } from "mw_constants";
|
||||
import { ReactNode } from "react";
|
||||
import { Link } from "react-router-dom"
|
||||
import { BrandPill } from "./BrandPill";
|
||||
|
||||
export function Navigation() {
|
||||
export interface NavigationProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
return (
|
||||
<div className="flex justify-between items-center absolute left-0 right-0 top-0 py-5 px-7">
|
||||
<div>
|
||||
<BrandPill />
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="mr-6">
|
||||
<Link to="/">
|
||||
<BrandPill/>
|
||||
</Link>
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
<div className="flex">
|
||||
<a href={DISCORD_LINK} target="_blank" rel="noreferrer" className="text-2xl text-white"><IconPatch icon={Icons.DISCORD} clickable/></a>
|
||||
|
14
src/components/layout/Paper.tsx
Normal file
14
src/components/layout/Paper.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -8,11 +8,12 @@ interface SectionHeadingProps {
|
||||
children?: ReactNode;
|
||||
linkText?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading(props: SectionHeadingProps) {
|
||||
return (
|
||||
<div className="mt-12">
|
||||
<div className={`mt-12 ${props.className}`}>
|
||||
<div className="mb-4 flex items-end">
|
||||
<p className="text-denim-700 flex flex-1 items-center font-bold uppercase">
|
||||
{props.icon ? (
|
||||
|
18
src/components/media/EpisodeButton.tsx
Normal file
18
src/components/media/EpisodeButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,38 +1,20 @@
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
getProviderFromId,
|
||||
MWMedia,
|
||||
MWMediaMeta,
|
||||
MWMediaType,
|
||||
} from "providers";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Icon, Icons } from "components/Icon";
|
||||
import { serializePortableMedia } from "hooks/usePortableMedia";
|
||||
import { DotList } from "components/text/DotList";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMedia;
|
||||
media: MWMediaMeta;
|
||||
watchedPercentage: Number;
|
||||
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]">●</span>
|
||||
) : null}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
@ -68,7 +50,8 @@ function MediaCardContent({
|
||||
{/* card content */}
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-1 font-bold text-white">{media.title}</h1>
|
||||
<MediaMeta
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
/>
|
||||
</div>
|
||||
|
@ -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 { useRef } from "react";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: MWMediaStream;
|
||||
startAt?: number;
|
||||
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) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const mustUseHls = props.source.type === "m3u8";
|
||||
|
||||
return (
|
||||
<video
|
||||
className="videoElement"
|
||||
className="bg-denim-500 w-full rounded-xl"
|
||||
ref={videoRef}
|
||||
onProgress={(e) =>
|
||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
|
||||
}
|
||||
onLoadedData={(e) => {
|
||||
if (props.startAt)
|
||||
(e.target as HTMLVideoElement).currentTime = props.startAt;
|
||||
}}
|
||||
controls
|
||||
autoPlay
|
||||
>
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { MWMedia } from "providers";
|
||||
import { MWMediaMeta } from "providers";
|
||||
import { useWatchedContext, getWatchedFromPortable } from "state/watched";
|
||||
import { MediaCard } from "./MediaCard";
|
||||
|
||||
export interface WatchedMediaCardProps {
|
||||
media: MWMedia;
|
||||
media: MWMediaMeta;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
|
@ -13,7 +13,7 @@ export function useSearchQuery(): [MWQuery, (inp: Partial<MWQuery>) => void] {
|
||||
const updateParams = (inp: Partial<MWQuery>) => {
|
||||
const copySearch: MWQuery = {...search};
|
||||
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(() => {
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
} from "./types";
|
||||
import contentCache from "./methods/contentCache";
|
||||
export * from "./types";
|
||||
export * from "./methods/helpers";
|
||||
export * from "./methods/providers";
|
||||
@ -28,6 +29,10 @@ export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
|
||||
export async function convertPortableToMedia(
|
||||
portable: MWPortableMedia
|
||||
): Promise<MWMedia | undefined> {
|
||||
// consult cache first
|
||||
let output = contentCache.get(portable);
|
||||
if (output) return output;
|
||||
|
||||
const provider = getProviderFromId(portable.providerId);
|
||||
return await provider?.getMediaFromPortable(portable);
|
||||
}
|
||||
|
9
src/providers/methods/contentCache.ts
Normal file
9
src/providers/methods/contentCache.ts
Normal 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;
|
@ -1,7 +1,8 @@
|
||||
import Fuse from "fuse.js";
|
||||
import { MWMassProviderOutput, MWMedia, MWQuery } from "providers";
|
||||
import { MWMassProviderOutput, MWMedia, MWQuery, convertMediaToPortable } from "providers";
|
||||
import { SimpleCache } from "utils/cache";
|
||||
import { GetProvidersForType } from "./helpers";
|
||||
import contentCache from "./contentCache";
|
||||
|
||||
// cache
|
||||
const resultCache = new SimpleCache<MWQuery, MWMassProviderOutput>();
|
||||
@ -52,6 +53,10 @@ async function callProviders(
|
||||
resultCache.set(query, output, 60 * 60); // cache for an hour
|
||||
}
|
||||
|
||||
output.results.forEach((result: MWMedia) => {
|
||||
contentCache.set(convertMediaToPortable(result), result, 60 * 60);
|
||||
})
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
|
@ -18,11 +18,13 @@ export interface MWMediaStream {
|
||||
type: MWMediaStreamType;
|
||||
}
|
||||
|
||||
export interface MWMedia extends MWPortableMedia {
|
||||
export interface MWMediaMeta extends MWPortableMedia {
|
||||
title: string;
|
||||
year: string;
|
||||
}
|
||||
|
||||
export interface MWMedia extends MWMediaMeta {}
|
||||
|
||||
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
|
||||
|
||||
export interface MWQuery {
|
||||
|
100
src/state/bookmark/context.tsx
Normal file
100
src/state/bookmark/context.tsx
Normal 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;
|
||||
}
|
1
src/state/bookmark/index.ts
Normal file
1
src/state/bookmark/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./context";
|
45
src/state/bookmark/store.ts
Normal file
45
src/state/bookmark/store.ts
Normal 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()
|
@ -1,8 +1,8 @@
|
||||
import { MWPortableMedia } from "providers";
|
||||
import { MWMediaMeta } from "providers";
|
||||
import React, { createContext, ReactNode, useContext, useState } from "react";
|
||||
import { VideoProgressStore } from "./store";
|
||||
|
||||
interface WatchedStoreItem extends MWPortableMedia {
|
||||
interface WatchedStoreItem extends MWMediaMeta {
|
||||
progress: number;
|
||||
percentage: number;
|
||||
}
|
||||
@ -12,13 +12,11 @@ interface WatchedStoreData {
|
||||
}
|
||||
|
||||
interface WatchedStoreDataWrapper {
|
||||
setWatched: React.Dispatch<React.SetStateAction<WatchedStoreData>>;
|
||||
updateProgress(media: MWPortableMedia, progress: number, total: number): void;
|
||||
updateProgress(media: MWMediaMeta, progress: number, total: number): void;
|
||||
watched: WatchedStoreData;
|
||||
}
|
||||
|
||||
const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
||||
setWatched: () => {},
|
||||
updateProgress: () => {},
|
||||
watched: {
|
||||
items: [],
|
||||
@ -44,11 +42,8 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const contextValue = {
|
||||
setWatched(data: any) {
|
||||
return setWatched(data);
|
||||
},
|
||||
updateProgress(
|
||||
media: MWPortableMedia,
|
||||
media: MWMediaMeta,
|
||||
progress: number,
|
||||
total: number
|
||||
): void {
|
||||
@ -59,6 +54,8 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
||||
mediaId: media.mediaId,
|
||||
mediaType: media.mediaType,
|
||||
providerId: media.providerId,
|
||||
title: media.title,
|
||||
year: media.year,
|
||||
percentage: 0,
|
||||
progress: 0,
|
||||
episode: media.episode,
|
||||
@ -90,7 +87,7 @@ export function useWatchedContext() {
|
||||
|
||||
export function getWatchedFromPortable(
|
||||
store: WatchedStoreData,
|
||||
media: MWPortableMedia
|
||||
media: MWMediaMeta
|
||||
): WatchedStoreItem | undefined {
|
||||
return store.items.find((v) => {
|
||||
return (
|
||||
|
153
src/views/MediaView.tsx
Normal file
153
src/views/MediaView.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -1,11 +1,6 @@
|
||||
import { WatchedMediaCard } from "components/media/WatchedMediaCard";
|
||||
import { SearchBarInput } from "components/SearchBar";
|
||||
import {
|
||||
MWMassProviderOutput,
|
||||
MWMediaType,
|
||||
MWQuery,
|
||||
SearchProviders,
|
||||
} from "providers";
|
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "providers";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThinContainer } from "components/layout/ThinContainer";
|
||||
import { SectionHeading } from "components/layout/SectionHeading";
|
||||
@ -18,9 +13,14 @@ import { useLoading } from "hooks/useLoading";
|
||||
import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Navigation } from "components/layout/Navigation";
|
||||
import { useSearchQuery } from "hooks/useSearchQuery";
|
||||
import { useWatchedContext } from "state/watched/context";
|
||||
import {
|
||||
getIfBookmarkedFromPortable,
|
||||
useBookmarkContext,
|
||||
} from "state/bookmark/context";
|
||||
|
||||
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: {
|
||||
@ -43,13 +43,13 @@ function SearchSuffix(props: {
|
||||
<div>
|
||||
{props.fails > 0 ? (
|
||||
<p className="text-red-400">
|
||||
{props.fails}/{props.total} providers failed
|
||||
{props.fails}/{props.total} providers failed!
|
||||
</p>
|
||||
) : null}
|
||||
{props.resultsSize > 0 ? (
|
||||
<p>That's all we have — sorry</p>
|
||||
<p>That's all we have!</p>
|
||||
) : (
|
||||
<p>We couldn't find anything — sorry</p>
|
||||
<p>We couldn't find anything!</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@ -57,7 +57,7 @@ function SearchSuffix(props: {
|
||||
{/* Error result */}
|
||||
{allFailed ? (
|
||||
<div>
|
||||
<p>All providers failed — whoops</p>
|
||||
<p>All providers have failed!</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@ -151,7 +151,7 @@ export function SearchView() {
|
||||
onChange={setSearch}
|
||||
value={search}
|
||||
placeholder="What movie do you want to watch?"
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* results view */}
|
||||
@ -162,8 +162,46 @@ export function SearchView() {
|
||||
searchQuery={debouncedSearch}
|
||||
clear={() => setSearch({ searchQuery: "" })}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<ExtraItems />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
export function SeriesView() {
|
||||
return (
|
||||
<div>
|
||||
<p>Series view here</p>
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user