series implemented (with jank) + readme update

This commit is contained in:
mrjvs 2022-03-06 18:31:22 +01:00
parent f66637a185
commit 52e8132cce
15 changed files with 7286 additions and 6818 deletions

View File

@ -1,20 +1,30 @@
# movie-web
<h1>movie-web&nbsp;&nbsp;<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>

View File

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

View File

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

View 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}
</>
);
}

View File

@ -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={{

View File

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

View File

@ -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: [],
};
},
};

View File

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

View File

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

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

View File

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

View File

@ -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[]> {

View File

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

View File

@ -35,5 +35,5 @@ module.exports = {
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
},
},
plugins: [],
plugins: [require("tailwind-scrollbar")],
};

13635
yarn.lock

File diff suppressed because it is too large Load Diff