mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-24 18:51:53 +01:00
series implemented (with jank) + readme update
This commit is contained in:
parent
f66637a185
commit
52e8132cce
96
README.md
96
README.md
@ -1,20 +1,30 @@
|
||||
# movie-web
|
||||
<h1>movie-web <span><span>
|
||||
</h1>
|
||||
|
||||
Small web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
<p align="center">
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/workflow/status/JamesHawkinss/movie-web/Build%20&%20deploy?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/blob/master/LICENSE.md"><img alt="GitHub license" src="https://img.shields.io/github/license/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/network"><img alt="GitHub forks" src="https://img.shields.io/github/forks/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://discord.gg/vXsRvye8BS"><img src="https://discordapp.com/api/guilds/871713465100816424/widget.png?style=banner2" alt="Discord Server"></a>
|
||||
</p>
|
||||
|
||||
**[Join the Discord community](https://discord.gg/vXsRvye8BS)**
|
||||
movie-web is a web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**.
|
||||
|
||||
## Credits
|
||||
This service works by displaying video files from third-party providers inside an intuitive and aesthic user interface.
|
||||
|
||||
- Thanks to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli)
|
||||
- Thanks to [@mrjvs](https://github.com/mrjvs) for help porting to React, and for the beautiful design
|
||||
- Thanks to [@JoshHeng](https://github.com/JoshHeng/) for the Cloudflare CORS Proxy and URL routing
|
||||
Features include:
|
||||
|
||||
## Installation
|
||||
- 🕑 Saving of your progress so you can come back to a video at any time!
|
||||
- 🔖 Bookmarks to keep track of videos you would like to watch.
|
||||
- 🎞️ Easy switching between seasons and episodes for a TV series; binge away!
|
||||
- ✖️ Supports multiple types of content including movies, TV shows and Anime (coming soon™️)
|
||||
|
||||
## Self-hosting
|
||||
|
||||
To run this project locally for contributing or testing, run the following commands:
|
||||
|
||||
```
|
||||
```bash
|
||||
git clone https://github.com/JamesHawkinss/movie-web
|
||||
cd movie-web
|
||||
yarn install
|
||||
@ -23,57 +33,33 @@ yarn start
|
||||
|
||||
To build production files, simply run `yarn build`.
|
||||
|
||||
## Environment
|
||||
|
||||
- `REACT_APP_CORS_PROXY_URL` - The Cloudflare CORS Proxy, will be something like `https://PROXY.workers.dev?destination=`
|
||||
|
||||
## Contributing
|
||||
<h2>Contributing - <a href="https://github.com/JamesHawkinss/movie-web/issues"><img alt="GitHub issues" src="https://img.shields.io/github/issues/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/pulls"><img alt="GitHub pull requests" src="https://img.shields.io/github/issues-pr/JamesHawkinss/movie-web?style=flat-square"></a></h2>
|
||||
|
||||
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
||||
|
||||
## Rewrite TODO's
|
||||
## Credits
|
||||
|
||||
- [x] Better provider errors (only fail if all failed, show individual fails somewhere)
|
||||
- [x] Better search suffix view
|
||||
- [x] Add back link of results view
|
||||
- [x] Add results list end
|
||||
- [x] Store watched percentage
|
||||
- [x] Add Brand tag top left
|
||||
- [x] Add github and discord top right
|
||||
- [x] Link Github and Discord in error boundary
|
||||
- [x] On back button, persist the search query and results
|
||||
- [x] Bookmarking
|
||||
- [x] Resume from where you left of
|
||||
- [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
|
||||
- [x] Change text of "thats all we have"
|
||||
- [x] Brand tag hover state and cursor
|
||||
- [ ] Implement movie + series view
|
||||
- [x] Global state for media objects
|
||||
- [x] Styling for pages
|
||||
- [x] loading stream player view + error
|
||||
- [x] video load error, video loading (from actual video player)
|
||||
- [ ] Series episodes+seasons
|
||||
- [ ] Get rid of react warnings
|
||||
- [x] Add 404 page for media (media not found, provider disabled, provider not found) & general (page not found)
|
||||
- [x] Handle disabled providers (continue watching, bookmarks & router)
|
||||
- [ ] Subtitles
|
||||
- [ ] Implement all scrapers
|
||||
- [ ] implement sources that are not mp4
|
||||
- [x] Bug: go back doesn't work if used directly from link
|
||||
- [ ] Migrate old video progress
|
||||
This project would not be possible without our amazing contributors and the community.
|
||||
|
||||
## After all rewrite code has been written
|
||||
<a href="https://github.com/JamesHawkinss/movie-web/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/JamesHawkinss/movie-web?style=flat-square"></a>
|
||||
|
||||
- [ ] Make better readme (with binary in credits)
|
||||
- [ ] Make cool announcement with cool gif animation
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JipFr.png?size=20" width="20"><span><a href="https://github.com/JipFr">@JipFr</a> for initial work on <a href="https://github.com/JipFr/movie-cli">movie-cli</a>.</span>
|
||||
</div>
|
||||
|
||||
## Todo's after rewrite
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/mrjvs.png?size=20" width="20"><span><a href="https://github.com/mrjvs">@mrjvs</a> for leading the port to React, and for the beautiful design.</span>
|
||||
</div>
|
||||
|
||||
- [ ] 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
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/JoshHeng.png?size=20" width="20"><span><a href="https://github.com/JoshHeng">@JoshHeng</a> for the Cloudflare CORS Proxy and URL routing.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/binaryoverload.png?size=20" width="20"><span><a href="https://github.com/binaryoverload">@binaryoverload</a> for help rewriting the application into React.</span>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||
<img src="https://github.com/lem6ns.png?size=20" width="20"><span><a href="https://github.com/lem6ns">@lem6ns</a> for helpfully implementing extra scrapers.</span>
|
||||
</div>
|
||||
|
@ -58,6 +58,7 @@
|
||||
"postcss": "^8.4.6",
|
||||
"prettier": "^2.5.1",
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^1.3.1",
|
||||
"tailwindcss": "^3.0.20",
|
||||
"typescript": "^4.6.2"
|
||||
}
|
||||
|
@ -18,8 +18,8 @@ export class ErrorBoundary extends Component<
|
||||
Record<string, unknown>,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor() {
|
||||
super({});
|
||||
constructor(props: { children: any }) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
};
|
||||
|
85
src/components/layout/Seasons.tsx
Normal file
85
src/components/layout/Seasons.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { IconButton } from "components/buttons/IconButton";
|
||||
import { Dropdown } from "components/Dropdown";
|
||||
import { Icons } from "components/Icon";
|
||||
import { Episode } from "components/media/EpisodeButton";
|
||||
import { useLoading } from "hooks/useLoading";
|
||||
import { serializePortableMedia } from "hooks/usePortableMedia";
|
||||
import {
|
||||
convertMediaToPortable,
|
||||
MWMedia,
|
||||
MWMediaSeasons,
|
||||
MWPortableMedia,
|
||||
} from "providers";
|
||||
import { getSeasonDataFromMedia } from "providers/methods/seasons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
export interface SeasonsProps {
|
||||
media: MWMedia;
|
||||
}
|
||||
|
||||
export function Seasons(props: SeasonsProps) {
|
||||
const [searchSeasons, loading, error, success] = useLoading(
|
||||
(portableMedia: MWPortableMedia) => getSeasonDataFromMedia(portableMedia)
|
||||
);
|
||||
const history = useHistory();
|
||||
const [seasons, setSeasons] = useState<MWMediaSeasons>({ seasons: [] });
|
||||
const seasonSelected = props.media.season as number;
|
||||
const episodeSelected = props.media.episode as number;
|
||||
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const seasonData = await searchSeasons(props.media);
|
||||
setSeasons(seasonData);
|
||||
})();
|
||||
}, [searchSeasons, props.media]);
|
||||
|
||||
function navigateToSeasonAndEpisode(season: number, episode: number) {
|
||||
const newMedia: MWMedia = { ...props.media };
|
||||
newMedia.episode = episode;
|
||||
newMedia.season = season;
|
||||
history.replace(
|
||||
`/media/${newMedia.mediaType}/${serializePortableMedia(
|
||||
convertMediaToPortable(newMedia)
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{loading ? <p>Loading...</p> : null}
|
||||
{error ? <p>error!</p> : null}
|
||||
{success && seasons.seasons.length ? (
|
||||
<>
|
||||
<Dropdown
|
||||
open={dropdownOpen}
|
||||
setOpen={setDropdownOpen}
|
||||
selectedItem={`${seasonSelected}`}
|
||||
options={seasons.seasons.map((season) => ({
|
||||
id: `${season.seasonNumber}`,
|
||||
name: `Season ${season.seasonNumber}`,
|
||||
}))}
|
||||
setSelectedItem={(id) =>
|
||||
navigateToSeasonAndEpisode(
|
||||
+id,
|
||||
seasons.seasons[+id]?.episodes[0].episodeNumber
|
||||
)
|
||||
}
|
||||
/>
|
||||
{seasons.seasons[seasonSelected]?.episodes.map((v) => (
|
||||
<Episode
|
||||
key={v.episodeNumber}
|
||||
episodeNumber={v.episodeNumber}
|
||||
active={v.episodeNumber === episodeSelected}
|
||||
onClick={() =>
|
||||
navigateToSeasonAndEpisode(seasonSelected, v.episodeNumber)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,11 +1,18 @@
|
||||
export interface EpisodeProps {
|
||||
progress?: number;
|
||||
episodeNumber: number;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
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
|
||||
onClick={props.onClick}
|
||||
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 border-2 border-transparent font-bold text-white active:scale-110 ${
|
||||
props.active ? "border-bink-500 bg-bink-200" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
||||
style={{
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { getProviderFromId } from "./methods/helpers";
|
||||
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
|
||||
import contentCache from "./methods/contentCache";
|
||||
|
||||
export * from "./types";
|
||||
export * from "./methods/helpers";
|
||||
@ -26,10 +25,6 @@ export function convertMediaToPortable(media: MWMedia): MWPortableMedia {
|
||||
export async function convertPortableToMedia(
|
||||
portable: MWPortableMedia
|
||||
): Promise<MWMedia | undefined> {
|
||||
// consult cache first
|
||||
const output = contentCache.get(portable);
|
||||
if (output) return output;
|
||||
|
||||
const provider = getProviderFromId(portable.providerId);
|
||||
return provider?.getMediaFromPortable(portable);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
MWMediaProvider,
|
||||
MWMediaSeasons,
|
||||
MWMediaType,
|
||||
MWPortableMedia,
|
||||
MWQuery,
|
||||
@ -33,4 +34,10 @@ export const tempScraper: MWMediaProvider = {
|
||||
type: "mp4",
|
||||
};
|
||||
},
|
||||
|
||||
async getSeasonDataFromMedia(media): Promise<MWMediaSeasons> {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWMediaSeasons,
|
||||
} from "providers/types";
|
||||
|
||||
import {
|
||||
@ -70,4 +71,29 @@ export const theFlixScraper: MWMediaProvider = {
|
||||
const data = JSON.parse(prop.textContent);
|
||||
return { url: data.props.pageProps.videoUrl, type: "mp4" };
|
||||
},
|
||||
|
||||
async getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const url = `${CORS_PROXY_URL}https://theflix.to/tv-show/${media.mediaId}/season-${media.season}/episode-${media.episode}`;
|
||||
const res = await fetch(url).then((d) => d.text());
|
||||
|
||||
const node: Element = Array.from(
|
||||
new DOMParser()
|
||||
.parseFromString(res, "text/html")
|
||||
.querySelectorAll(`script[id="__NEXT_DATA__"]`)
|
||||
)[0];
|
||||
|
||||
const data = JSON.parse(node.innerHTML).props.pageProps.selectedTv.seasons;
|
||||
return {
|
||||
seasons: data.map((d: any) => ({
|
||||
seasonNumber: d.seasonNumber === 0 ? 999 : d.seasonNumber,
|
||||
type: d.seasonNumber === 0 ? "special" : "season",
|
||||
episodes: d.episodes.map((e: any) => ({
|
||||
title: e.name,
|
||||
episodeNumber: e.episodeNumber,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
@ -37,5 +37,12 @@ export function turnDataIntoMedia(data: any): MWProviderMediaResult {
|
||||
.toLowerCase()}`,
|
||||
title: data.name,
|
||||
year: new Date(data.releaseDate).getFullYear().toString(),
|
||||
seasonCount: data.numberOfSeasons,
|
||||
episode: data.lastReleasedEpisode
|
||||
? data.lastReleasedEpisode.episodeNumber
|
||||
: null,
|
||||
season: data.lastReleasedEpisode
|
||||
? data.lastReleasedEpisode.seasonNumber
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
39
src/providers/methods/seasons.ts
Normal file
39
src/providers/methods/seasons.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { SimpleCache } from "utils/cache";
|
||||
import { MWPortableMedia } from "providers";
|
||||
import { MWMediaSeasons } from "providers/types";
|
||||
import { getProviderFromId } from "./helpers";
|
||||
|
||||
// cache
|
||||
const seasonCache = new SimpleCache<MWPortableMedia, MWMediaSeasons>();
|
||||
seasonCache.setCompare(
|
||||
(a, b) => a.mediaId === b.mediaId && a.providerId === b.providerId
|
||||
);
|
||||
seasonCache.initialize();
|
||||
|
||||
/*
|
||||
** get season data from a (portable) media object, seasons and episodes will be sorted
|
||||
*/
|
||||
export async function getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (seasonCache.has(media)) {
|
||||
return seasonCache.get(media) as MWMediaSeasons;
|
||||
}
|
||||
|
||||
const seasonData = await provider.getSeasonDataFromMedia(media);
|
||||
seasonData.seasons.sort((a, b) => a.seasonNumber - b.seasonNumber);
|
||||
seasonData.seasons.forEach((s) =>
|
||||
s.episodes.sort((a, b) => a.episodeNumber - b.episodeNumber)
|
||||
);
|
||||
|
||||
// cache it
|
||||
seasonCache.set(media, seasonData, 60 * 60); // cache it for an hour
|
||||
return seasonData;
|
||||
}
|
@ -21,9 +21,23 @@ export interface MWMediaStream {
|
||||
export interface MWMediaMeta extends MWPortableMedia {
|
||||
title: string;
|
||||
year: string;
|
||||
seasonCount?: number;
|
||||
}
|
||||
|
||||
export type MWMedia = MWMediaMeta;
|
||||
export interface MWMediaSeasons {
|
||||
seasons: {
|
||||
seasonNumber: number;
|
||||
type: "season" | "special";
|
||||
episodes: {
|
||||
title: string;
|
||||
episodeNumber: number;
|
||||
}[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface MWMedia extends MWMediaMeta {
|
||||
seriesData?: MWMediaSeasons;
|
||||
}
|
||||
|
||||
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
|
||||
|
||||
@ -41,6 +55,7 @@ export interface MWMediaProvider {
|
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
|
||||
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
|
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
||||
getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons>;
|
||||
}
|
||||
|
||||
export interface MWMediaProviderMetadata {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import contentCache from "./methods/contentCache";
|
||||
import {
|
||||
MWMedia,
|
||||
MWMediaProvider,
|
||||
@ -19,11 +20,21 @@ export function WrapProvider(
|
||||
...provider,
|
||||
|
||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia> {
|
||||
return {
|
||||
// consult cache first
|
||||
const output = contentCache.get(media);
|
||||
if (output) {
|
||||
output.season = media.season;
|
||||
output.episode = media.episode;
|
||||
return output;
|
||||
}
|
||||
|
||||
const mediaObject = {
|
||||
...(await provider.getMediaFromPortable(media)),
|
||||
providerId: provider.id,
|
||||
mediaType: media.mediaType,
|
||||
};
|
||||
contentCache.set(media, mediaObject, 60 * 60);
|
||||
return mediaObject;
|
||||
},
|
||||
|
||||
async searchForMedia(query: MWQuery): Promise<MWMedia[]> {
|
||||
|
@ -2,6 +2,7 @@ import { IconPatch } from "components/buttons/IconPatch";
|
||||
import { Icons } from "components/Icon";
|
||||
import { Navigation } from "components/layout/Navigation";
|
||||
import { Paper } from "components/layout/Paper";
|
||||
import { Seasons } from "components/layout/Seasons";
|
||||
import { SkeletonVideoPlayer, VideoPlayer } from "components/media/VideoPlayer";
|
||||
import { ArrowLink } from "components/text/ArrowLink";
|
||||
import { DotList } from "components/text/DotList";
|
||||
@ -29,7 +30,6 @@ import { NotFoundChecks } from "./notfound/NotFoundChecks";
|
||||
interface StyledMediaViewProps {
|
||||
media: MWMedia;
|
||||
stream: MWMediaStream;
|
||||
provider: MWMediaProvider;
|
||||
}
|
||||
|
||||
function StyledMediaView(props: StyledMediaViewProps) {
|
||||
@ -38,11 +38,6 @@ function StyledMediaView(props: StyledMediaViewProps) {
|
||||
watchedStore.watched.items,
|
||||
props.media
|
||||
)?.progress;
|
||||
const { setItemBookmark, getFilteredBookmarks } = useBookmarkContext();
|
||||
const isBookmarked = getIfBookmarkedFromPortable(
|
||||
getFilteredBookmarks(),
|
||||
props.media
|
||||
);
|
||||
|
||||
function updateProgress(e: Event) {
|
||||
if (!props.media) return;
|
||||
@ -54,55 +49,68 @@ function StyledMediaView(props: StyledMediaViewProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoPlayer
|
||||
source={props.stream}
|
||||
onProgress={(e) => updateProgress(e)}
|
||||
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>
|
||||
</>
|
||||
<VideoPlayer
|
||||
source={props.stream}
|
||||
onProgress={(e) => updateProgress(e)}
|
||||
startAt={startAtTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingMediaView(props: { error?: boolean }) {
|
||||
interface StyledMediaFooterProps {
|
||||
media: MWMedia;
|
||||
provider: MWMediaProvider;
|
||||
}
|
||||
|
||||
function StyledMediaFooter(props: StyledMediaFooterProps) {
|
||||
const { setItemBookmark, getFilteredBookmarks } = useBookmarkContext();
|
||||
const isBookmarked = getIfBookmarkedFromPortable(
|
||||
getFilteredBookmarks(),
|
||||
props.media
|
||||
);
|
||||
|
||||
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>
|
||||
<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>
|
||||
</Paper>
|
||||
</>
|
||||
<div>
|
||||
<IconPatch
|
||||
icon={Icons.BOOKMARK}
|
||||
active={isBookmarked}
|
||||
onClick={() => setItemBookmark(props.media, !isBookmarked)}
|
||||
clickable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Seasons media={props.media} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingMediaFooter(props: { error?: boolean }) {
|
||||
return (
|
||||
<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>
|
||||
{props.error ? "error!" : null}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -110,40 +118,54 @@ function MediaViewContent(props: { portable: MWPortableMedia }) {
|
||||
const mediaPortable = props.portable;
|
||||
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>();
|
||||
const [media, setMedia] = useState<MWMedia | undefined>();
|
||||
const [fetchAllData, loading, error] = useLoading(
|
||||
(portable: MWPortableMedia) => {
|
||||
const streamPromise = getStream(portable);
|
||||
const mediaPromise = convertPortableToMedia(portable);
|
||||
return Promise.all([streamPromise, mediaPromise]);
|
||||
}
|
||||
const [fetchMedia, loadingPortable, errorPortable] = useLoading(
|
||||
(portable: MWPortableMedia) => convertPortableToMedia(portable)
|
||||
);
|
||||
const [fetchStream, loadingStream, errorStream] = useLoading(
|
||||
(portable: MWPortableMedia) => getStream(portable)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (mediaPortable) {
|
||||
const resultData = await fetchAllData(mediaPortable);
|
||||
if (!resultData) return;
|
||||
setStreamUrl(resultData[0]);
|
||||
setMedia(resultData[1]);
|
||||
setMedia(await fetchMedia(mediaPortable));
|
||||
}
|
||||
})();
|
||||
}, [mediaPortable, setStreamUrl, fetchAllData]);
|
||||
}, [mediaPortable, setMedia, fetchMedia]);
|
||||
|
||||
let content: ReactElement | null = null;
|
||||
if (loading) content = <LoadingMediaView />;
|
||||
else if (error) content = <LoadingMediaView error />;
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (mediaPortable) {
|
||||
setStreamUrl(await fetchStream(mediaPortable));
|
||||
}
|
||||
})();
|
||||
}, [mediaPortable, setStreamUrl, fetchStream]);
|
||||
|
||||
let playerContent: ReactElement | null = null;
|
||||
if (loadingStream) playerContent = <SkeletonVideoPlayer />;
|
||||
else if (errorStream) playerContent = <SkeletonVideoPlayer error />;
|
||||
else if (media && streamUrl)
|
||||
playerContent = <StyledMediaView media={media} stream={streamUrl} />;
|
||||
|
||||
let footerContent: ReactElement | null = null;
|
||||
if (loadingPortable) footerContent = <LoadingMediaFooter />;
|
||||
else if (errorPortable) footerContent = <LoadingMediaFooter error />;
|
||||
else if (mediaPortable && media && streamUrl)
|
||||
content = (
|
||||
<StyledMediaView
|
||||
footerContent = (
|
||||
<StyledMediaFooter
|
||||
provider={
|
||||
getProviderFromId(mediaPortable.providerId) as MWMediaProvider
|
||||
}
|
||||
media={media}
|
||||
stream={streamUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
return content;
|
||||
return (
|
||||
<>
|
||||
{playerContent}
|
||||
{footerContent}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaView() {
|
||||
|
@ -35,5 +35,5 @@ module.exports = {
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
plugins: [require("tailwind-scrollbar")],
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user