mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-14 07:35:06 +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)
|
Features include:
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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:
|
To run this project locally for contributing or testing, run the following commands:
|
||||||
|
|
||||||
```
|
```bash
|
||||||
git clone https://github.com/JamesHawkinss/movie-web
|
git clone https://github.com/JamesHawkinss/movie-web
|
||||||
cd movie-web
|
cd movie-web
|
||||||
yarn install
|
yarn install
|
||||||
@ -23,57 +33,33 @@ yarn start
|
|||||||
|
|
||||||
To build production files, simply run `yarn build`.
|
To build production files, simply run `yarn build`.
|
||||||
|
|
||||||
## Environment
|
<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>
|
||||||
- `REACT_APP_CORS_PROXY_URL` - The Cloudflare CORS Proxy, will be something like `https://PROXY.workers.dev?destination=`
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Check out [this project's issues](https://github.com/JamesHawkinss/movie-web/issues) for inspiration for contribution. Pull requests are always welcome.
|
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)
|
This project would not be possible without our amazing contributors and the community.
|
||||||
- [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
|
|
||||||
|
|
||||||
## 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)
|
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||||
- [ ] Make cool announcement with cool gif animation
|
<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)
|
<div style="display:flex;align-items:center;grid-gap:10px">
|
||||||
- [ ] Add a way to remove from continue watching
|
<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>
|
||||||
- [ ] i18n
|
</div>
|
||||||
- [ ] better mobile search type selector
|
|
||||||
- [ ] Custom video player
|
<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",
|
"postcss": "^8.4.6",
|
||||||
"prettier": "^2.5.1",
|
"prettier": "^2.5.1",
|
||||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||||
|
"tailwind-scrollbar": "^1.3.1",
|
||||||
"tailwindcss": "^3.0.20",
|
"tailwindcss": "^3.0.20",
|
||||||
"typescript": "^4.6.2"
|
"typescript": "^4.6.2"
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,8 @@ export class ErrorBoundary extends Component<
|
|||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
ErrorBoundaryState
|
ErrorBoundaryState
|
||||||
> {
|
> {
|
||||||
constructor() {
|
constructor(props: { children: any }) {
|
||||||
super({});
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
hasError: false,
|
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 {
|
export interface EpisodeProps {
|
||||||
progress?: number;
|
progress?: number;
|
||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
|
onClick?: () => void;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Episode(props: EpisodeProps) {
|
export function Episode(props: EpisodeProps) {
|
||||||
return (
|
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
|
<div
|
||||||
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
className="bg-bink-500 absolute bottom-0 top-0 left-0 bg-opacity-50"
|
||||||
style={{
|
style={{
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { getProviderFromId } from "./methods/helpers";
|
import { getProviderFromId } from "./methods/helpers";
|
||||||
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
|
import { MWMedia, MWPortableMedia, MWMediaStream } from "./types";
|
||||||
import contentCache from "./methods/contentCache";
|
|
||||||
|
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./methods/helpers";
|
export * from "./methods/helpers";
|
||||||
@ -26,10 +25,6 @@ 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
|
|
||||||
const output = contentCache.get(portable);
|
|
||||||
if (output) return output;
|
|
||||||
|
|
||||||
const provider = getProviderFromId(portable.providerId);
|
const provider = getProviderFromId(portable.providerId);
|
||||||
return provider?.getMediaFromPortable(portable);
|
return provider?.getMediaFromPortable(portable);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
MWMediaProvider,
|
MWMediaProvider,
|
||||||
|
MWMediaSeasons,
|
||||||
MWMediaType,
|
MWMediaType,
|
||||||
MWPortableMedia,
|
MWPortableMedia,
|
||||||
MWQuery,
|
MWQuery,
|
||||||
@ -33,4 +34,10 @@ export const tempScraper: MWMediaProvider = {
|
|||||||
type: "mp4",
|
type: "mp4",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSeasonDataFromMedia(media): Promise<MWMediaSeasons> {
|
||||||
|
return {
|
||||||
|
seasons: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
MWPortableMedia,
|
MWPortableMedia,
|
||||||
MWMediaStream,
|
MWMediaStream,
|
||||||
MWQuery,
|
MWQuery,
|
||||||
|
MWMediaSeasons,
|
||||||
} from "providers/types";
|
} from "providers/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -70,4 +71,29 @@ export const theFlixScraper: MWMediaProvider = {
|
|||||||
const data = JSON.parse(prop.textContent);
|
const data = JSON.parse(prop.textContent);
|
||||||
return { url: data.props.pageProps.videoUrl, type: "mp4" };
|
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()}`,
|
.toLowerCase()}`,
|
||||||
title: data.name,
|
title: data.name,
|
||||||
year: new Date(data.releaseDate).getFullYear().toString(),
|
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 {
|
export interface MWMediaMeta extends MWPortableMedia {
|
||||||
title: string;
|
title: string;
|
||||||
year: 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">;
|
export type MWProviderMediaResult = Omit<MWMedia, "mediaType" | "providerId">;
|
||||||
|
|
||||||
@ -41,6 +55,7 @@ export interface MWMediaProvider {
|
|||||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
|
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
|
||||||
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
|
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
|
||||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
||||||
|
getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MWMediaProviderMetadata {
|
export interface MWMediaProviderMetadata {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import contentCache from "./methods/contentCache";
|
||||||
import {
|
import {
|
||||||
MWMedia,
|
MWMedia,
|
||||||
MWMediaProvider,
|
MWMediaProvider,
|
||||||
@ -19,11 +20,21 @@ export function WrapProvider(
|
|||||||
...provider,
|
...provider,
|
||||||
|
|
||||||
async getMediaFromPortable(media: MWPortableMedia): Promise<MWMedia> {
|
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)),
|
...(await provider.getMediaFromPortable(media)),
|
||||||
providerId: provider.id,
|
providerId: provider.id,
|
||||||
mediaType: media.mediaType,
|
mediaType: media.mediaType,
|
||||||
};
|
};
|
||||||
|
contentCache.set(media, mediaObject, 60 * 60);
|
||||||
|
return mediaObject;
|
||||||
},
|
},
|
||||||
|
|
||||||
async searchForMedia(query: MWQuery): Promise<MWMedia[]> {
|
async searchForMedia(query: MWQuery): Promise<MWMedia[]> {
|
||||||
|
@ -2,6 +2,7 @@ import { IconPatch } from "components/buttons/IconPatch";
|
|||||||
import { Icons } from "components/Icon";
|
import { Icons } from "components/Icon";
|
||||||
import { Navigation } from "components/layout/Navigation";
|
import { Navigation } from "components/layout/Navigation";
|
||||||
import { Paper } from "components/layout/Paper";
|
import { Paper } from "components/layout/Paper";
|
||||||
|
import { Seasons } from "components/layout/Seasons";
|
||||||
import { SkeletonVideoPlayer, VideoPlayer } from "components/media/VideoPlayer";
|
import { SkeletonVideoPlayer, VideoPlayer } from "components/media/VideoPlayer";
|
||||||
import { ArrowLink } from "components/text/ArrowLink";
|
import { ArrowLink } from "components/text/ArrowLink";
|
||||||
import { DotList } from "components/text/DotList";
|
import { DotList } from "components/text/DotList";
|
||||||
@ -29,7 +30,6 @@ import { NotFoundChecks } from "./notfound/NotFoundChecks";
|
|||||||
interface StyledMediaViewProps {
|
interface StyledMediaViewProps {
|
||||||
media: MWMedia;
|
media: MWMedia;
|
||||||
stream: MWMediaStream;
|
stream: MWMediaStream;
|
||||||
provider: MWMediaProvider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StyledMediaView(props: StyledMediaViewProps) {
|
function StyledMediaView(props: StyledMediaViewProps) {
|
||||||
@ -38,11 +38,6 @@ function StyledMediaView(props: StyledMediaViewProps) {
|
|||||||
watchedStore.watched.items,
|
watchedStore.watched.items,
|
||||||
props.media
|
props.media
|
||||||
)?.progress;
|
)?.progress;
|
||||||
const { setItemBookmark, getFilteredBookmarks } = useBookmarkContext();
|
|
||||||
const isBookmarked = getIfBookmarkedFromPortable(
|
|
||||||
getFilteredBookmarks(),
|
|
||||||
props.media
|
|
||||||
);
|
|
||||||
|
|
||||||
function updateProgress(e: Event) {
|
function updateProgress(e: Event) {
|
||||||
if (!props.media) return;
|
if (!props.media) return;
|
||||||
@ -54,55 +49,68 @@ function StyledMediaView(props: StyledMediaViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<VideoPlayer
|
||||||
<VideoPlayer
|
source={props.stream}
|
||||||
source={props.stream}
|
onProgress={(e) => updateProgress(e)}
|
||||||
onProgress={(e) => updateProgress(e)}
|
startAt={startAtTime}
|
||||||
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 }) {
|
interface StyledMediaFooterProps {
|
||||||
|
media: MWMedia;
|
||||||
|
provider: MWMediaProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyledMediaFooter(props: StyledMediaFooterProps) {
|
||||||
|
const { setItemBookmark, getFilteredBookmarks } = useBookmarkContext();
|
||||||
|
const isBookmarked = getIfBookmarkedFromPortable(
|
||||||
|
getFilteredBookmarks(),
|
||||||
|
props.media
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Paper className="mt-5">
|
||||||
<SkeletonVideoPlayer error={props.error} />
|
<div className="flex">
|
||||||
<Paper className="mt-5">
|
<div className="flex-1">
|
||||||
<div className="flex">
|
<Title>{props.media.title}</Title>
|
||||||
<div className="flex-1">
|
<DotList
|
||||||
<div className="bg-denim-500 mb-2 h-4 w-48 rounded-full" />
|
className="mt-3 text-sm"
|
||||||
<div>
|
content={[
|
||||||
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" />
|
props.provider.displayName,
|
||||||
<span className="bg-denim-400 mr-4 inline-block h-2 w-12 rounded-full" />
|
props.media.mediaType,
|
||||||
</div>
|
props.media.year,
|
||||||
</div>
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</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 mediaPortable = props.portable;
|
||||||
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>();
|
const [streamUrl, setStreamUrl] = useState<MWMediaStream | undefined>();
|
||||||
const [media, setMedia] = useState<MWMedia | undefined>();
|
const [media, setMedia] = useState<MWMedia | undefined>();
|
||||||
const [fetchAllData, loading, error] = useLoading(
|
const [fetchMedia, loadingPortable, errorPortable] = useLoading(
|
||||||
(portable: MWPortableMedia) => {
|
(portable: MWPortableMedia) => convertPortableToMedia(portable)
|
||||||
const streamPromise = getStream(portable);
|
);
|
||||||
const mediaPromise = convertPortableToMedia(portable);
|
const [fetchStream, loadingStream, errorStream] = useLoading(
|
||||||
return Promise.all([streamPromise, mediaPromise]);
|
(portable: MWPortableMedia) => getStream(portable)
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (mediaPortable) {
|
if (mediaPortable) {
|
||||||
const resultData = await fetchAllData(mediaPortable);
|
setMedia(await fetchMedia(mediaPortable));
|
||||||
if (!resultData) return;
|
|
||||||
setStreamUrl(resultData[0]);
|
|
||||||
setMedia(resultData[1]);
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [mediaPortable, setStreamUrl, fetchAllData]);
|
}, [mediaPortable, setMedia, fetchMedia]);
|
||||||
|
|
||||||
let content: ReactElement | null = null;
|
useEffect(() => {
|
||||||
if (loading) content = <LoadingMediaView />;
|
(async () => {
|
||||||
else if (error) content = <LoadingMediaView error />;
|
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)
|
else if (mediaPortable && media && streamUrl)
|
||||||
content = (
|
footerContent = (
|
||||||
<StyledMediaView
|
<StyledMediaFooter
|
||||||
provider={
|
provider={
|
||||||
getProviderFromId(mediaPortable.providerId) as MWMediaProvider
|
getProviderFromId(mediaPortable.providerId) as MWMediaProvider
|
||||||
}
|
}
|
||||||
media={media}
|
media={media}
|
||||||
stream={streamUrl}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return content;
|
return (
|
||||||
|
<>
|
||||||
|
{playerContent}
|
||||||
|
{footerContent}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaView() {
|
export function MediaView() {
|
||||||
|
@ -35,5 +35,5 @@ module.exports = {
|
|||||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [require("tailwind-scrollbar")],
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user