mirror of
https://github.com/movie-web/movie-web.git
synced 2024-11-10 23:55:05 +01:00
commit
39d2d79f67
1
.env
1
.env
@ -1 +0,0 @@
|
||||
REACT_APP_CORS_PROXY_URL=https://proxy-1.movie-web.workers.dev/?destination=
|
@ -63,6 +63,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||
"tailwind-scrollbar": "^1.3.1",
|
||||
"tailwindcss": "^3.0.20",
|
||||
"typescript": "^4.6.2"
|
||||
"typescript": "^4.6.4"
|
||||
}
|
||||
}
|
||||
|
@ -52,11 +52,11 @@ export function SearchBarInput(props: SearchBarProps) {
|
||||
name: "Series",
|
||||
icon: Icons.CLAPPER_BOARD,
|
||||
},
|
||||
{
|
||||
id: MWMediaType.ANIME,
|
||||
name: "Anime",
|
||||
icon: Icons.DRAGON,
|
||||
},
|
||||
// {
|
||||
// id: MWMediaType.ANIME,
|
||||
// name: "Anime",
|
||||
// icon: Icons.DRAGON,
|
||||
// },
|
||||
]}
|
||||
onClick={() => setDropdownOpen((old) => !old)}
|
||||
>
|
||||
|
@ -3,6 +3,7 @@ import { Icons } from "components/Icon";
|
||||
import { Loading } from "components/layout/Loading";
|
||||
import { MWMediaCaption, MWMediaStream } from "providers";
|
||||
import { ReactElement, useEffect, useRef, useState } from "react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
source: MWMediaStream;
|
||||
@ -40,7 +41,34 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setErrored(false);
|
||||
}, [props.source.url]);
|
||||
|
||||
// hls support
|
||||
if (mustUseHls) {
|
||||
if (!videoRef.current)
|
||||
return;
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
setLoading(false);
|
||||
setErrored(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
if (videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoRef.current.src = props.source.url;
|
||||
return;
|
||||
}
|
||||
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.loadSource(props.source.url);
|
||||
|
||||
hls.on(Hls.Events.ERROR, (event, data) => {
|
||||
setErrored(true);
|
||||
console.error(data);
|
||||
});
|
||||
}
|
||||
}, [props.source.url, videoRef, mustUseHls]);
|
||||
|
||||
let skeletonUi: null | ReactElement = null;
|
||||
if (hasErrored) {
|
||||
@ -53,9 +81,8 @@ export function VideoPlayer(props: VideoPlayerProps) {
|
||||
<>
|
||||
{skeletonUi}
|
||||
<video
|
||||
className={`bg-denim-500 w-full rounded-xl ${
|
||||
!showVideo ? "hidden" : ""
|
||||
}`}
|
||||
className={`bg-denim-500 w-full rounded-xl ${!showVideo ? "hidden" : ""
|
||||
}`}
|
||||
ref={videoRef}
|
||||
onProgress={(e) =>
|
||||
props.onProgress && props.onProgress(e.nativeEvent as ProgressEvent)
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWMediaSeasons,
|
||||
MWProviderMediaResult
|
||||
} from "providers/types";
|
||||
|
||||
@ -57,14 +56,13 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
async searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]> {
|
||||
const searchRes = await fetch(`${CORS_PROXY_URL}https://api.gdriveplayer.us/v1/movie/search?title=${query.searchQuery}`).then((d) => d.json());
|
||||
|
||||
const results: MWProviderMediaResult[] = searchRes.map((item: any) => ({
|
||||
const results: MWProviderMediaResult[] = (searchRes || []).map((item: any) => ({
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
mediaId: item.imdb,
|
||||
}));
|
||||
|
||||
return results;
|
||||
|
||||
},
|
||||
|
||||
async getStream(media: MWPortableMedia): Promise<MWMediaStream> {
|
||||
@ -89,8 +87,4 @@ export const gDrivePlayerScraper: MWMediaProvider = {
|
||||
|
||||
return { url: `https:${source.file}`, type: source.type, captions: [] };
|
||||
},
|
||||
|
||||
async getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons> {
|
||||
return {} as MWMediaSeasons;
|
||||
}
|
||||
};
|
||||
|
@ -4,7 +4,6 @@ import {
|
||||
MWPortableMedia,
|
||||
MWMediaStream,
|
||||
MWQuery,
|
||||
MWMediaSeasons,
|
||||
MWProviderMediaResult
|
||||
} from "providers/types";
|
||||
|
||||
@ -47,7 +46,7 @@ export const gomostreamScraper: MWMediaProvider = {
|
||||
`${CORS_PROXY_URL}http://www.omdbapi.com/?${encodeURIComponent(params.toString())}`,
|
||||
).then(d => d.json())
|
||||
|
||||
const results: MWProviderMediaResult[] = searchRes.Search.map((d: any) => ({
|
||||
const results: MWProviderMediaResult[] = (searchRes.Search || []).map((d: any) => ({
|
||||
title: d.Title,
|
||||
year: d.Year,
|
||||
mediaId: d.imdbID
|
||||
@ -92,9 +91,5 @@ export const gomostreamScraper: MWMediaProvider = {
|
||||
if (streamType !== "mp4" && streamType !== "m3u8") throw new Error("Unsupported stream type");
|
||||
|
||||
return { url: streamUrl, type: streamType, captions: [] };
|
||||
},
|
||||
|
||||
async getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons> {
|
||||
return {} as MWMediaSeasons;
|
||||
}
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { SimpleCache } from "utils/cache";
|
||||
import { MWPortableMedia } from "providers";
|
||||
import { MWMediaSeasons } from "providers/types";
|
||||
import { MWMediaSeasons, MWMediaType, MWMediaProviderSeries } from "providers/types";
|
||||
import { getProviderFromId } from "./helpers";
|
||||
|
||||
// cache
|
||||
@ -16,13 +16,19 @@ seasonCache.initialize();
|
||||
export async function getSeasonDataFromMedia(
|
||||
media: MWPortableMedia
|
||||
): Promise<MWMediaSeasons> {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
const provider = getProviderFromId(media.providerId) as MWMediaProviderSeries;
|
||||
if (!provider) {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!provider.type.includes(MWMediaType.SERIES) && !provider.type.includes(MWMediaType.ANIME)) {
|
||||
return {
|
||||
seasons: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (seasonCache.has(media)) {
|
||||
return seasonCache.get(media) as MWMediaSeasons;
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export interface MWQuery {
|
||||
type: MWMediaType;
|
||||
}
|
||||
|
||||
export interface MWMediaProvider {
|
||||
export interface MWMediaProviderBase {
|
||||
id: string; // id of provider, must be unique
|
||||
enabled: boolean;
|
||||
type: MWMediaType[];
|
||||
@ -66,9 +66,15 @@ export interface MWMediaProvider {
|
||||
getMediaFromPortable(media: MWPortableMedia): Promise<MWProviderMediaResult>;
|
||||
searchForMedia(query: MWQuery): Promise<MWProviderMediaResult[]>;
|
||||
getStream(media: MWPortableMedia): Promise<MWMediaStream>;
|
||||
getSeasonDataFromMedia(media: MWPortableMedia): Promise<MWMediaSeasons>;
|
||||
getSeasonDataFromMedia?: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
|
||||
}
|
||||
|
||||
export type MWMediaProviderSeries = MWMediaProviderBase & {
|
||||
getSeasonDataFromMedia: (media: MWPortableMedia) => Promise<MWMediaSeasons>;
|
||||
};
|
||||
|
||||
export type MWMediaProvider = MWMediaProviderBase;
|
||||
|
||||
export interface MWMediaProviderMetadata {
|
||||
exists: boolean;
|
||||
id?: string;
|
||||
|
19
src2/App.js
19
src2/App.js
@ -1,19 +0,0 @@
|
||||
import { SearchView } from './views/Search';
|
||||
import { MovieView } from './views/Movie';
|
||||
import { useMovie, MovieProvider } from './hooks/useMovie';
|
||||
import './index.css';
|
||||
|
||||
function Router() {
|
||||
const { streamData } = useMovie();
|
||||
return streamData ? <MovieView /> : <SearchView />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<MovieProvider>
|
||||
<Router />
|
||||
</MovieProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-chevron-down"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
Before Width: | Height: | Size: 261 B |
@ -1,7 +0,0 @@
|
||||
.feather.left {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Arrow.css'
|
||||
|
||||
// left?: boolean
|
||||
export function Arrow(props) {
|
||||
return (
|
||||
<span className="arrow" dangerouslySetInnerHTML={{ __html: `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather ${props.left?'left':''}"}>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
`}}>
|
||||
</span>
|
||||
)
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
.card {
|
||||
background-color: var(--card);
|
||||
padding: 3rem 4rem;
|
||||
margin: 0 3rem;
|
||||
margin-bottom: 6rem;
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
transition: height 500ms ease-in-out;
|
||||
}
|
||||
|
||||
.card-wrapper.full {
|
||||
width: 81rem;
|
||||
}
|
||||
|
||||
.card-wrapper {
|
||||
transition: height 500ms ease-in-out;
|
||||
width: 45rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-wrapper.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.card {
|
||||
margin: 0;
|
||||
margin-bottom: 6rem;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Card.css'
|
||||
|
||||
// fullWidth: boolean
|
||||
// show: boolean
|
||||
// doTransition: boolean
|
||||
export function Card(props) {
|
||||
|
||||
const [showing, setShowing] = React.useState(false);
|
||||
const measureRef = React.useRef(null)
|
||||
const [height, setHeight] = React.useState(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!measureRef?.current) return;
|
||||
setShowing(props.show);
|
||||
setHeight(measureRef.current.clientHeight)
|
||||
}, [props.show, measureRef])
|
||||
|
||||
return (
|
||||
<div className={`card-wrapper ${ props.fullWidth ? 'full' : '' } ${ props.doTransition ? 'overflow-hidden' : '' }`} style={{
|
||||
height: props.doTransition ? (showing ? height : 0) : "initial",
|
||||
}}>
|
||||
<div className={`card ${ showing ? 'show' : '' } ${ props.doTransition ? 'doTransition' : '' }`} ref={measureRef}>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
.episodeSelector {
|
||||
margin-top: 20px;
|
||||
}
|
@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import { TypeSelector } from './TypeSelector';
|
||||
import { NumberSelector } from './NumberSelector';
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
import { SelectBox } from '../components/SelectBox';
|
||||
import './EpisodeSelector.css'
|
||||
import { useWindowSize } from '../hooks/useWindowSize';
|
||||
|
||||
export function EpisodeSelector({ setSelectedSeason, selectedSeason, setEpisode, seasons, episodes, currentSeason, currentEpisode, streamData }) {
|
||||
const choices = episodes ? episodes.map(v => {
|
||||
const progressData = VideoProgressStore.get();
|
||||
|
||||
let currentlyAt = 0;
|
||||
let totalDuration = 0;
|
||||
|
||||
const progress = progressData?.[streamData.source]?.[streamData.type]?.[streamData.slug]?.[`${selectedSeason}-${v}`]
|
||||
|
||||
if (progress) {
|
||||
currentlyAt = progress.currentlyAt
|
||||
totalDuration = progress.totalDuration
|
||||
}
|
||||
|
||||
const percentage = Math.round((currentlyAt / totalDuration) * 100)
|
||||
|
||||
return {
|
||||
value: v.toString(),
|
||||
label: v,
|
||||
percentage
|
||||
}
|
||||
}) : [];
|
||||
|
||||
const windowSize = useWindowSize()
|
||||
|
||||
return (
|
||||
<div className="episodeSelector">
|
||||
{
|
||||
(seasons.length > 0 && (windowSize.width <= 768 || seasons.length > 4)) ?
|
||||
(
|
||||
<SelectBox setSelectedItem={(index) => setSelectedSeason(seasons[index])} selectedItem={seasons.findIndex(s => s === selectedSeason)} options={seasons.map(season => { return {id: season, name: `Season ${season}` }})}/>
|
||||
)
|
||||
:
|
||||
(
|
||||
<TypeSelector setType={setSelectedSeason} selected={selectedSeason} choices={seasons.map(v=>({ value: v.toString(), label: `Season ${v}`}))} />
|
||||
)
|
||||
}
|
||||
<br></br>
|
||||
<NumberSelector setType={(e) => setEpisode({episode: e, season: selectedSeason})} choices={choices} selected={(selectedSeason.toString() === currentSeason) ? currentEpisode : null} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
.errorBanner {
|
||||
margin-top: 0.5rem;
|
||||
border-inline-start: none;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
letter-spacing: -.01em;
|
||||
padding: .5rem 1rem .5rem .75rem;
|
||||
border-radius: .25rem;
|
||||
background-color: var(--button);
|
||||
color: var(--button-text);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
import React from 'react';
|
||||
import './ErrorBanner.css';
|
||||
|
||||
export function ErrorBanner({children}) {
|
||||
return (
|
||||
<div className="errorBanner">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,95 +0,0 @@
|
||||
.inputBar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.inputBar > *:first-child{
|
||||
border-radius: 0 !important;
|
||||
border-top-left-radius: 10px !important;
|
||||
border-bottom-left-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputBar > *:last-child {
|
||||
border-radius: 0 !important;
|
||||
border-top-right-radius: 10px !important;
|
||||
border-bottom-right-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputTextBox {
|
||||
border-width: 0;
|
||||
outline: none;
|
||||
background-color: var(--content);
|
||||
color: var(--text);
|
||||
padding: .7rem 1.5rem;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.inputSearchButton {
|
||||
background-color: var(--button);
|
||||
border-width: 0;
|
||||
color: var(--button-text, var(--text));
|
||||
padding: .5rem 2.1rem;
|
||||
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover {
|
||||
background-color: var(--button-hover);
|
||||
}
|
||||
|
||||
.inputTextBox:hover {
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.inputSearchButton .text > .arrow {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
|
||||
position: absolute;
|
||||
right: -0.8rem;
|
||||
bottom: -0.2rem;
|
||||
}
|
||||
|
||||
.inputSearchButton .text {
|
||||
display: flex;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover .text > .arrow {
|
||||
transform: translateX(8px);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.inputSearchButton:hover .text {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.inputSearchButton:active {
|
||||
background-color: var(--button-active);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.inputBar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.inputBar > *:nth-child(n) {
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.inputSearchButton {
|
||||
margin-top: .5rem;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.inputTextBox {
|
||||
margin-top: .5rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Arrow } from './Arrow';
|
||||
import './InputBox.css'
|
||||
|
||||
// props = { onSubmit: (str) => {}, placeholder: string}
|
||||
export function InputBox({ onSubmit, placeholder }) {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
|
||||
return (
|
||||
<form className="inputBar" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(searchTerm)
|
||||
return false;
|
||||
}}>
|
||||
<input
|
||||
type='text'
|
||||
className="inputTextBox"
|
||||
id="inputTextBox"
|
||||
placeholder={placeholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="inputSearchButton">
|
||||
<span className="text">Search<span className="arrow"><Arrow /></span></span>
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
.movieRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border-radius: 5px;
|
||||
background-color: var(--content);
|
||||
color: var(--text);
|
||||
padding: .8rem 1.5rem;
|
||||
margin-top: .5rem;
|
||||
cursor: pointer;
|
||||
transition: transform 50ms ease-in-out;
|
||||
user-select: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.movieRow p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.movieRow .left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: flex-start;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.movieRow .left .titleWrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.movieRow .left .seasonEpisodeSubtitle,
|
||||
.movieRow .left .year {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.movieRow .watch {
|
||||
color: var(--theme-color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.movieRow .watch .arrow {
|
||||
margin-left: .5rem;
|
||||
transition: transform 50ms ease-in-out;
|
||||
transform: translateY(.1rem);
|
||||
}
|
||||
|
||||
.movieRow:active {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.movieRow:hover {
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.movieRow:hover .watch .arrow {
|
||||
transform: translateX(.3rem) translateY(.1rem);
|
||||
}
|
||||
|
||||
.movieRow:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
background-color: var(--content-hover);
|
||||
}
|
||||
|
||||
.movieRow:focus-visible .watch .arrow {
|
||||
transform: translateX(.3rem) translateY(.1rem);
|
||||
}
|
||||
|
||||
.attribute {
|
||||
color: var(--text);
|
||||
background-color: var(--theme-color);
|
||||
font-size: .75rem;
|
||||
padding: .25rem;
|
||||
border-radius: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.subtitleIcon {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.movieRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.movieRow .watch {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
@ -1,59 +0,0 @@
|
||||
import React from 'react'
|
||||
import { Arrow } from './Arrow'
|
||||
import { PercentageOverlay } from './PercentageOverlay'
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
import './MovieRow.css'
|
||||
|
||||
// title: string
|
||||
// onClick: () => void
|
||||
export function MovieRow(props) {
|
||||
const progressData = VideoProgressStore.get();
|
||||
let progress;
|
||||
let percentage = null;
|
||||
|
||||
if (props.type === "movie") {
|
||||
progress = progressData?.[props.source]?.movie?.[props.slug]?.full
|
||||
|
||||
if (progress) {
|
||||
percentage = Math.floor((progress.currentlyAt / progress.totalDuration) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event){
|
||||
if ((event.code === 'Enter' || event.code === 'Space') && props.onClick){
|
||||
props.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="movieRow" tabIndex={0} onKeyPress={handleKeyPress} onClick={() => props.onClick && props.onClick()}>
|
||||
|
||||
{ (props.source === "lookmovie" || props.source === "xemovie") && (
|
||||
<div className="subtitleIcon">
|
||||
<svg id="subtitleIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4C2.897 4 2 4.897 2 6V18C2 19.103 2.897 20 4 20H20C21.103 20 22 19.103 22 18V6C22 4.897 21.103 4 20 4ZM11 10H8V14H11V16H8C6.897 16 6 15.103 6 14V10C6 8.897 6.897 8 8 8H11V10ZM18 10H15V14H18V16H15C13.897 16 13 15.103 13 14V10C13 8.897 13.897 8 15 8H18V10Z" fill="#EEEEEE"/>
|
||||
</svg>
|
||||
</div>
|
||||
) }
|
||||
|
||||
<div className="left">
|
||||
{/* <Cross /> */}
|
||||
<div className="titleWrapper">
|
||||
<div className="titleText">
|
||||
{props.title}
|
||||
|
||||
<span className="year">({props.year})</span>
|
||||
<span className="seasonEpisodeSubtitle">{props.place ? ` - S${props.place.season}:E${props.place.episode}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="watch">
|
||||
<p>Watch {props.type}</p>
|
||||
<Arrow/>
|
||||
</div>
|
||||
|
||||
<PercentageOverlay percentage={props.percentage || percentage} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,55 +0,0 @@
|
||||
.numberSelector {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(2.5rem, 1fr));
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper {
|
||||
position: relative;
|
||||
border-radius: 10%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
|
||||
.numberSelector .choice {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--choice);
|
||||
margin-right: 5px;
|
||||
padding: .2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.numberSelector .choice:hover,
|
||||
.numberSelector .choiceWrapper:focus-visible .choice {
|
||||
background-color: var(--choice-hover);
|
||||
}
|
||||
|
||||
.numberSelector .choiceWrapper:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.numberSelector .choice.selected {
|
||||
color: var(--choice-active-text, var(--text));
|
||||
background-color: var(--choice-active);
|
||||
}
|
||||
|
@ -1,27 +0,0 @@
|
||||
import React from 'react';
|
||||
// import { Arrow } from './Arrow';
|
||||
import './NumberSelector.css'
|
||||
import { PercentageOverlay } from './PercentageOverlay';
|
||||
|
||||
// setType: (txt: string) => void
|
||||
// choices: { label: string, value: string }[]
|
||||
// selected: string
|
||||
export function NumberSelector({ setType, choices, selected }) {
|
||||
const handleKeyPress = choice => event => {
|
||||
if (event.code === 'Space' || event.code === 'Enter'){
|
||||
setType(choice);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="numberSelector">
|
||||
{choices.map(v=>(
|
||||
<div key={v.value} className="choiceWrapper" tabIndex={0} onKeyPress={handleKeyPress(v.value)}>
|
||||
<div className={`choice ${selected&&selected===v.value?'selected':''}`} onClick={() => setType(v.value)}>
|
||||
{v.label}
|
||||
<PercentageOverlay percentage={v.percentage} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
.progressBar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.progressBarInner {
|
||||
background: var(--theme-color);
|
||||
height: 100%;
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import React from 'react'
|
||||
import './PercentageOverlay.css'
|
||||
|
||||
export function PercentageOverlay({ percentage }) {
|
||||
|
||||
if(percentage && percentage > 3) percentage = Math.max(20, percentage < 90 ? percentage : 100)
|
||||
|
||||
return percentage > 0 ? (
|
||||
<div className="progressBar">
|
||||
<div className="progressBarInner" style={{width: `${percentage}%`}}></div>
|
||||
</div>
|
||||
) : <React.Fragment></React.Fragment>
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
.progress {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
height: 5rem;
|
||||
margin-top: 1rem;
|
||||
transition: height 800ms ease-in-out, opacity 800ms ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progress.hide {
|
||||
opacity: 0;
|
||||
height: 0rem;
|
||||
}
|
||||
|
||||
.progress p {
|
||||
margin: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress .bar {
|
||||
width: 13rem;
|
||||
max-width: 100%;
|
||||
background-color: var(--content);
|
||||
border-radius: 10px;
|
||||
height: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.progress .bar .bar-inner {
|
||||
transition: width 400ms ease-in-out, background-color 100ms ease-in-out;
|
||||
background-color: var(--theme-color);
|
||||
border-radius: 10px;
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.progress.failed .bar .bar-inner {
|
||||
background-color: var(--failed);
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import React from 'react'
|
||||
import './Progress.css'
|
||||
|
||||
// show: boolean
|
||||
// progress: number
|
||||
// steps: number
|
||||
// text: string
|
||||
// failed: boolean
|
||||
export function Progress(props) {
|
||||
return (
|
||||
<div className={`progress ${props.show ? '' : 'hide'} ${props.failed ? 'failed' : ''}`}>
|
||||
{ props.text && props.text.length > 0 ? (
|
||||
<p>{props.text}</p>) : null}
|
||||
<div className="bar">
|
||||
<div className="bar-inner" style={{
|
||||
width: (props.progress / props.steps * 100).toFixed(0) + "%"
|
||||
}}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i,800,800i&display=swap');
|
||||
|
||||
/* select box styling */
|
||||
.select-box {
|
||||
display: flex;
|
||||
width: 200px;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.select-box:focus-visible .selected {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.select-box > * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.select-box .options-container {
|
||||
max-height: 0;
|
||||
width: calc( 100% - 12px);
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-in-out;
|
||||
overflow: hidden;
|
||||
border-radius: 5px;
|
||||
background-color: var(--choice);
|
||||
order: 1;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50px;
|
||||
}
|
||||
|
||||
.select-box .selected {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
width: 188px;
|
||||
height: 45px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--choice);
|
||||
color: white;
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.select-box .selected::after {
|
||||
content: "";
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
background: url(../assets/down-arrow.svg);
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
top: 50%;
|
||||
transition: transform 150ms;
|
||||
transform: translateY(-50%);
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
|
||||
.select-box .option .item {
|
||||
color: var(--text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select-box .options-container.active {
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.select-box .options-container.active + .selected::after {
|
||||
transform: translateY(-50%) rotateX(180deg);
|
||||
}
|
||||
|
||||
.select-box .options-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #0d141f;
|
||||
background: #81878f;
|
||||
background: #f1f2f3;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.select-box .options-container::-webkit-scrollbar-thumb {
|
||||
background: #525861;
|
||||
background: #81878f;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
.select-box .option {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
.select-box .option,
|
||||
.selected {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-box .options-container .option:hover {
|
||||
background: var(--choice-hover);
|
||||
}
|
||||
.select-box .options-container .option:hover .item {
|
||||
color: var(--choice-active-text, var(--text));
|
||||
}
|
||||
|
||||
.select-box label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-box .option .radio {
|
||||
display: none;
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import "./SelectBox.css";
|
||||
|
||||
function Option({ option, ...props }) {
|
||||
return (
|
||||
<div className="option" {...props}>
|
||||
<input type="radio" className="radio" id={option.id} />
|
||||
<label htmlFor={option.id}>
|
||||
<div className="item">{option.name}</div>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectBox({ options, selectedItem, setSelectedItem }) {
|
||||
if (!Array.isArray(options)) {
|
||||
throw new Error("Items must be an array!");
|
||||
}
|
||||
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const containerRef = useRef();
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (containerRef.current.contains(e.target)) {
|
||||
// inside click
|
||||
return;
|
||||
}
|
||||
// outside click
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// add when mounted
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
// return function to be called when unmounted
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onOptionClick = (e, option, i) => {
|
||||
e.stopPropagation();
|
||||
setSelectedItem(i);
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const handleSelectedKeyPress = (event) => {
|
||||
if (event.code === "Enter" || event.code === "Space") {
|
||||
setActive((a) => !a);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOptionKeyPress = (option, i) => (event) => {
|
||||
if (event.code === "Enter" || event.code === "Space") {
|
||||
onOptionClick(event, option, i);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="select-box"
|
||||
ref={containerRef}
|
||||
onClick={() => setActive((a) => !a)}
|
||||
>
|
||||
<div
|
||||
className="selected"
|
||||
tabIndex={0}
|
||||
onKeyPress={handleSelectedKeyPress}
|
||||
>
|
||||
{options ? <Option option={options[selectedItem]} /> : null}
|
||||
</div>
|
||||
<div className={"options-container" + (active ? " active" : "")}>
|
||||
{options.map((opt, i) => (
|
||||
<Option
|
||||
option={opt}
|
||||
key={i}
|
||||
onClick={(e) => onOptionClick(e, opt, i)}
|
||||
tabIndex={active ? 0 : undefined}
|
||||
onKeyPress={active ? handleOptionKeyPress(opt, i) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,52 +0,0 @@
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
color: var(--text);
|
||||
/* max-width: 20rem; */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.title-size-medium {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.title-size-small {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.title-accent {
|
||||
color: var(--theme-color);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.title-accent.title-accent-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title.accent.title-accent-link:focus-visible {
|
||||
border: 1px solid #ffffff;
|
||||
}
|
||||
|
||||
.title.accent.title-accent-link:focus-visible .arrow {
|
||||
transform: translateY(.1rem) translateX(-.5rem);
|
||||
}
|
||||
|
||||
|
||||
.title-accent.title-accent-link .arrow {
|
||||
transition: transform 100ms ease-in-out;
|
||||
transform: translateY(.1rem);
|
||||
margin-right: .2rem;
|
||||
}
|
||||
|
||||
.title-accent.title-accent-link:hover .arrow {
|
||||
transform: translateY(.1rem) translateX(-.5rem);
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,41 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useMovie } from '../hooks/useMovie'
|
||||
import { Arrow } from '../components/Arrow'
|
||||
import './Title.css'
|
||||
|
||||
// size: "big" | "medium" | "small" | null
|
||||
// accent: string | null
|
||||
// accentLink: string | null
|
||||
export function Title(props) {
|
||||
const { streamData, resetStreamData } = useMovie();
|
||||
const history = useHistory();
|
||||
const size = props.size || "big";
|
||||
|
||||
const accentLink = props.accentLink || "";
|
||||
const accent = props.accent || "";
|
||||
|
||||
function handleAccentClick(){
|
||||
if (accentLink.length > 0) {
|
||||
history.push(`/${streamData.type}`);
|
||||
resetStreamData();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyPress(event){
|
||||
if (event.code === 'Enter' || event.code === 'Space'){
|
||||
handleAccentClick();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{accent.length > 0 ? (
|
||||
<p onClick={handleAccentClick} className={`title-accent ${accentLink.length > 0 ? 'title-accent-link' : ''}`} tabIndex={accentLink.length > 0 ? 0 : undefined} onKeyPress={handleKeyPress}>
|
||||
{accentLink.length > 0 ? (<Arrow left/>) : null}{accent}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className={"title " + ( size ? `title-size-${size}` : '' )}>{props.children}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,65 +0,0 @@
|
||||
|
||||
/* TODO better responsiveness, use dropdown if more than 5 options */
|
||||
.typeSelector {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.typeSelector:not(.nowrap) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.typeSelector::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
background-color: var(--content);
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.typeSelector .choice {
|
||||
width: 7rem;
|
||||
height: 3rem;
|
||||
padding: .3rem .2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.typeSelector .choice:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typeSelector .choice:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.typeSelector .choice.selected {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.typeSelector .selectedBar {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
width: 7rem;
|
||||
background-color: var(--theme-color);
|
||||
border-radius: 2px;
|
||||
bottom: 0;
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
.typeSelector:not(.nowrap) {
|
||||
display: block;
|
||||
}
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import './TypeSelector.css';
|
||||
|
||||
// setType: (txt: string) => void
|
||||
// choices: { label: string, value: string }[]
|
||||
// selected: string
|
||||
export function TypeSelector({ setType, choices, selected, noWrap = false }) {
|
||||
const selectedIndex = choices.findIndex(v => v.value === selected);
|
||||
const transformStyles = {
|
||||
opacity: selectedIndex !== -1 ? 1 : 0,
|
||||
transform: `translateX(${selectedIndex !== -1 ? selectedIndex * 7 : 0}rem)`,
|
||||
};
|
||||
|
||||
const handleKeyPress = choice => event => {
|
||||
if (event.code === 'Enter' || event.code === 'Space') {
|
||||
setType(choice);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`typeSelector ${noWrap ? 'nowrap' : ''}`}>
|
||||
{choices.map(v => (
|
||||
<div
|
||||
key={v.value}
|
||||
className={`choice ${selected === v.value ? 'selected' : ''}`}
|
||||
onClick={() => setType(v.value)}
|
||||
onKeyPress={handleKeyPress(v.value)}
|
||||
tabIndex={0}
|
||||
>
|
||||
{v.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="selectedBar" style={transformStyles} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
.videoElement {
|
||||
width: 100%;
|
||||
background-color: black;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.videoElementText {
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
@ -1,63 +0,0 @@
|
||||
import React from 'react'
|
||||
import Hls from 'hls.js'
|
||||
import { VideoPlaceholder } from './VideoPlaceholder'
|
||||
|
||||
import './VideoElement.css'
|
||||
|
||||
// streamUrl: string
|
||||
// loading: boolean
|
||||
// setProgress: (event: NativeEvent) => void
|
||||
// videoRef: useRef
|
||||
// startTime: number
|
||||
export function VideoElement({ streamUrl, loading, setProgress, videoRef, startTime, streamData }) {
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
function onLoad() {
|
||||
if (startTime)
|
||||
videoRef.current.currentTime = startTime;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!streamUrl.includes('.mp4')) {
|
||||
setError(false)
|
||||
if (!videoRef || !videoRef.current || !streamUrl || streamUrl.length === 0 || loading) return;
|
||||
|
||||
const hls = new Hls();
|
||||
|
||||
if (!Hls.isSupported() && videoRef.current.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
videoRef.current.src = streamUrl;
|
||||
return;
|
||||
} else if (!Hls.isSupported()) {
|
||||
setError(true)
|
||||
return;
|
||||
}
|
||||
|
||||
hls.attachMedia(videoRef.current);
|
||||
hls.loadSource(streamUrl);
|
||||
}
|
||||
}, [videoRef, streamUrl, loading]);
|
||||
|
||||
if (error)
|
||||
return (<VideoPlaceholder>Your browser is not supported</VideoPlaceholder>)
|
||||
|
||||
if (loading)
|
||||
return <VideoPlaceholder>Loading episode...</VideoPlaceholder>
|
||||
|
||||
if (!streamUrl || streamUrl.length === 0)
|
||||
return <VideoPlaceholder>No video selected</VideoPlaceholder>
|
||||
|
||||
if (!streamUrl.includes('.mp4')) {
|
||||
return (
|
||||
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
|
||||
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
|
||||
</video>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<video className="videoElement" ref={videoRef} controls autoPlay onProgress={setProgress} onLoadedData={onLoad}>
|
||||
{ streamData.subtitles && streamData.subtitles.map((sub, index) => <track key={index} kind="captions" label={sub.language} src={sub.file} />) }
|
||||
<source src={streamUrl} type="video/mp4" />
|
||||
</video>
|
||||
)
|
||||
}
|
||||
}
|
@ -1,23 +0,0 @@
|
||||
.videoPlaceholder {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.videoPlaceholder::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%;
|
||||
}
|
||||
.videoPlaceholderBox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: absolute;
|
||||
background: var(--choice);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import React from 'react'
|
||||
import './VideoPlaceholder.css'
|
||||
|
||||
export function VideoPlaceholder(props) {
|
||||
return (
|
||||
<div className="videoPlaceholder">
|
||||
<div className="videoPlaceholderBox">
|
||||
<p>{props.children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,30 +0,0 @@
|
||||
import React from 'react'
|
||||
const MovieContext = React.createContext(null)
|
||||
|
||||
export function MovieProvider(props) {
|
||||
const [page, setPage] = React.useState("search");
|
||||
const [stream, setStream] = React.useState("");
|
||||
const [streamData, setStreamData] = React.useState(null); //{ title: "", slug: "", type: "", episodes: [], seasons: [] })
|
||||
|
||||
return (
|
||||
<MovieContext.Provider value={{
|
||||
navigate(str) {
|
||||
setPage(str)
|
||||
},
|
||||
page,
|
||||
setStreamUrl: setStream,
|
||||
streamUrl: stream,
|
||||
streamData,
|
||||
setStreamData(d) {
|
||||
setStreamData(p => ({...p,...d}))
|
||||
},
|
||||
resetStreamData() { setStreamData(null) }
|
||||
}}>
|
||||
{props.children}
|
||||
</MovieContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMovie(props) {
|
||||
return React.useContext(MovieContext);
|
||||
}
|
@ -1,28 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// https://usehooks.com/useWindowSize/
|
||||
export function useWindowSize() {
|
||||
// Initialize state with undefined width/height so server and client renders match
|
||||
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
});
|
||||
useEffect(() => {
|
||||
// Handler to call on window resize
|
||||
function handleResize() {
|
||||
// Set window width/height to state
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}
|
||||
// Add event listener
|
||||
window.addEventListener("resize", handleResize);
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
handleResize();
|
||||
// Remove event listener on cleanup
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []); // Empty array ensures that effect is only run on mount
|
||||
return windowSize;
|
||||
}
|
@ -1,70 +0,0 @@
|
||||
:root {
|
||||
--theme-color: #E880C5;
|
||||
--theme-color-text: var(--theme-color);
|
||||
|
||||
--failed: #d85b66;
|
||||
|
||||
--body: #16171D;
|
||||
--card: #22232A;
|
||||
|
||||
--text: white;
|
||||
--text-secondary: #BCBECB;
|
||||
--text-tertiary: #585A67;
|
||||
|
||||
--content: #36363e;
|
||||
--content-hover: #3C3D44;
|
||||
|
||||
--button: #A73B83;
|
||||
--button-hover: #9C3179;
|
||||
--button-active: #8b286a;
|
||||
--button-text: var(--text);
|
||||
|
||||
--choice: #2E2F37;
|
||||
--choice-hover: #45464D;
|
||||
--choice-active: #45464D;
|
||||
|
||||
--source-headings: #5b5c63;
|
||||
}
|
||||
/* @media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--theme-color: #457461;
|
||||
|
||||
--body: white;
|
||||
--card: #f8f9fa;
|
||||
|
||||
--content: #eee;
|
||||
--content-hover: #e7e7e7;
|
||||
|
||||
--text: #333;
|
||||
--text-secondary: #616161;
|
||||
--text-tertiary: #aaa;
|
||||
|
||||
--button: #457461;
|
||||
--button-hover: #4e836e;
|
||||
--button-active: #437a64;
|
||||
--button-text: white;
|
||||
|
||||
--choice: var(--content);
|
||||
--choice-hover: var(--content-hover);
|
||||
--choice-active: var(--content-hover);
|
||||
}
|
||||
} */
|
||||
|
||||
body, html {
|
||||
margin: 0;
|
||||
background-color: var(--body);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body, html, input, button {
|
||||
font-family: 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
outline: none;
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
@ -1,55 +0,0 @@
|
||||
import lookmovie from './scraper/lookmovie';
|
||||
import xemovie from './scraper/xemovie';
|
||||
import theflix from './scraper/theflix';
|
||||
import vidzstore from './scraper/vidzstore';
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
const results = { options: []};
|
||||
const content = await Promise.all([
|
||||
// lookmovie.findContent(searchTerm, type),
|
||||
xemovie.findContent(searchTerm, type),
|
||||
theflix.findContent(searchTerm, type),
|
||||
vidzstore.findContent(searchTerm, type)
|
||||
]);
|
||||
|
||||
content.forEach((o) => {
|
||||
if (!o || !o.options) return;
|
||||
|
||||
o.options.forEach((i) => {
|
||||
if (!i) return;
|
||||
results.options.push(i)
|
||||
})
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, source, season, episode) {
|
||||
switch (source) {
|
||||
case 'lookmovie':
|
||||
return await lookmovie.getStreamUrl(slug, type, season, episode);
|
||||
case 'theflix':
|
||||
return await theflix.getStreamUrl(slug, type, season, episode);
|
||||
case 'vidzstore':
|
||||
return await vidzstore.getStreamUrl(slug);
|
||||
case 'xemovie':
|
||||
return await xemovie.getStreamUrl(slug, type, season, episode);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function getEpisodes(slug, source) {
|
||||
switch (source) {
|
||||
case 'lookmovie':
|
||||
return await lookmovie.getEpisodes(slug);
|
||||
case 'theflix':
|
||||
return await theflix.getEpisodes(slug);
|
||||
case 'xemovie':
|
||||
return await xemovie.getEpisodes(slug);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export { findContent, getStreamUrl, getEpisodes }
|
@ -1,111 +0,0 @@
|
||||
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://database.gdriveplayer.us`;
|
||||
const MOVIE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://database.gdriveplayer.us/player.php`;
|
||||
const SHOW_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://series.databasegdriveplayer.co/player.php`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
const term = searchTerm.toLowerCase()
|
||||
const tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/search?query=${term}`).then(d => d.text());
|
||||
|
||||
const doc = new DOMParser().parseFromString(tmdbRes, 'text/html');
|
||||
const nodes = Array.from(doc.querySelectorAll('div.results > div > div.wrapper'));
|
||||
const results = nodes.slice(0, 10).map((node) => {
|
||||
let type = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('data-media-type');
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
type = 'movie';
|
||||
break;
|
||||
case 'tv':
|
||||
type = 'show';
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
case 'collection':
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
type: type,
|
||||
title: node.querySelector('div.details > div.wrapper > div.title > div > a').textContent,
|
||||
year: node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2],
|
||||
slug: node.querySelector('div.details > div.wrapper > div.title > div > a').href.split('/')[4],
|
||||
source: 'gdriveplayer'
|
||||
}
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
// const tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/search?query=${term}`).then(d => d.text());
|
||||
|
||||
console.log(`${MOVIE_URL}?tmdb=${slug}`)
|
||||
const res = await fetch(`${MOVIE_URL}?tmdb=${slug}`).then(d => d.text());
|
||||
|
||||
const embed = Array.from(new DOMParser().parseFromString(res, 'text/html').querySelectorAll('.list-server-items a'))
|
||||
.find((e) => e.textContent.includes("Mirror"))
|
||||
|
||||
if (embed && embed.getAttribute('href')) {
|
||||
let href = embed.getAttribute('href');
|
||||
if (href.startsWith('//')) href = `https:${href}`;
|
||||
|
||||
const res1 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${href}`.replace('streaming.php', 'download')).then(d => d.text());
|
||||
const sb = Array.from(new DOMParser().parseFromString(res1, 'text/html').querySelectorAll('a'))
|
||||
.find((a) => a.textContent.includes("StreamSB"));
|
||||
|
||||
console.log(sb);
|
||||
|
||||
if (sb && sb.getAttribute('href')) {
|
||||
console.log(sb.getAttribute('href'))
|
||||
const src = await sbPlayGetLink(sb.getAttribute('href'));
|
||||
if (src) return { url: src };
|
||||
}
|
||||
}
|
||||
|
||||
return { url: '' }
|
||||
}
|
||||
|
||||
async function sbPlayGetLink(href) {
|
||||
if (href.startsWith("//")) href = `https:${href}`;
|
||||
|
||||
const res = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${href}`).then(d => d.text());
|
||||
const a = new DOMParser().parseFromString(res, 'text/html').querySelector('table tbody tr a');
|
||||
|
||||
if (a && a.getAttribute('onclick')) {
|
||||
let match = a.getAttribute("onclick").match(/'([^']+)'/gm);
|
||||
console.log(a.getAttribute("onclick"));
|
||||
|
||||
if (match) {
|
||||
let [code, mode, hash] = match;
|
||||
|
||||
const url = `https://sbplay2.com/dl?op=download_orig&id=${code.replace(/'/gm, "")}&mode=${mode.replace(/'/gm, "")}&hash=${hash.replace(/'/gm, "")}`;
|
||||
|
||||
// https://sbplay2.com/dl?op=download_orig&id=glr78kyk21kd&mode=n&hash=1890245-0-0-1640889479-95e144cdfdbe0e9104a67b8e3eee0c2d
|
||||
// https://sbplay2.com/dl?op=download_orig&id=0hh6mxf5qqn0&mode=h&hash=2473604-78-149-1640889782-797bc207a16b2934c21ea6fdb1e97352
|
||||
// https://proxy-1.movie-web.workers.dev/?destination=https://sbplay2.com/dl?op=download_orig&id=glr78kyk21kd&mode=n&hash=1890245-0-0-1640889479-95e144cdfdbe0e9104a67b8e3eee0c2d
|
||||
|
||||
const text = await fetch(url).then((e) => e.text());
|
||||
const a = new DOMParser().parseFromString(text, 'text/html').querySelector(".contentbox span a");
|
||||
if (a && a.getAttribute("href")) return a.getAttribute("href");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gdriveplayer = { findContent, getStreamUrl }
|
||||
export default gdriveplayer;
|
@ -1,91 +0,0 @@
|
||||
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
|
||||
|
||||
import { unpack } from '../util/unpacker';
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://gomo.to`;
|
||||
const MOVIE_URL = `${BASE_URL}/movie`
|
||||
const DECODING_URL = `${BASE_URL}/decoding_v3.php`
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
const term = searchTerm.toLowerCase()
|
||||
const imdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://v2.sg.media-imdb.com/suggestion/${term.slice(0, 1)}/${term}.json`).then(d => d.json())
|
||||
|
||||
const results = [];
|
||||
imdbRes.d.forEach((e) => {
|
||||
if (!e.id.startsWith('tt')) return;
|
||||
|
||||
// Block tv shows
|
||||
if (e.q === "TV series") return;
|
||||
if (e.q === "TV mini-series") return;
|
||||
if (e.q === "video game") return;
|
||||
if (e.q === "TV movie") return;
|
||||
if (e.q === "TV special") return;
|
||||
|
||||
results.push({
|
||||
title: e.l,
|
||||
slug: e.id,
|
||||
type: 'movie',
|
||||
year: e.y,
|
||||
source: 'gomostream'
|
||||
})
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
// Get stream to go with IMDB ID
|
||||
const site1 = await fetch(`${MOVIE_URL}/${slug}`).then((d) => d.text());
|
||||
|
||||
if (site1 === "Movie not available.")
|
||||
return { url: '' };
|
||||
|
||||
const tc = site1.match(/var tc = '(.+)';/)?.[1]
|
||||
const _token = site1.match(/"_token": "(.+)",/)?.[1]
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('tokenCode', tc)
|
||||
fd.append('_token', _token)
|
||||
|
||||
const src = await fetch(DECODING_URL, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
headers: {
|
||||
'x-token': tc.slice(5, 13).split("").reverse().join("") + "13574199"
|
||||
}
|
||||
}).then((d) => d.json());
|
||||
|
||||
const embedUrl = src.find(url => url.includes('gomo.to'));
|
||||
const site2 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${embedUrl}`).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const site2Dom = parser.parseFromString(site2, "text/html");
|
||||
|
||||
if (site2Dom.body.innerText === "File was deleted")
|
||||
return { url: '' }
|
||||
|
||||
const script = site2Dom.querySelectorAll("script")[8].innerHTML;
|
||||
|
||||
let unpacked = unpack(script).split('');
|
||||
unpacked.splice(0, 43);
|
||||
let index = unpacked.findIndex((e) => e === '"');
|
||||
const url = unpacked.slice(0, index).join('');
|
||||
|
||||
return { url }
|
||||
}
|
||||
|
||||
const gomostream = { findContent, getStreamUrl }
|
||||
export default gomostream;
|
@ -1,164 +0,0 @@
|
||||
import Fuse from 'fuse.js'
|
||||
import JSON5 from 'json5'
|
||||
|
||||
const BASE_URL = `https://lookmovie.io`;
|
||||
const API_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://lookmovie125.xyz`;
|
||||
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
|
||||
let phpsessid;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
const searchUrl = `${CORS_URL}/${type}s/search/?q=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
// Parse DOM to find search results on full search page
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll('.movie-item-style-1'));
|
||||
const results = nodes.map(node => {
|
||||
return {
|
||||
type,
|
||||
title: node.querySelector('h6 a').innerText.trim(),
|
||||
year: node.querySelector('.year').innerText.trim(),
|
||||
slug: node.querySelector('a').href.split('/').pop(),
|
||||
}
|
||||
});
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, distance: 200, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm.toString())
|
||||
.map((result) => result.item);
|
||||
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] }
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type,
|
||||
year: r.year,
|
||||
source: 'lookmovie'
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type, year } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type, year, source: 'lookmovie' }]
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return { options: [] }
|
||||
}
|
||||
}
|
||||
async function getVideoUrl(config) {
|
||||
let url = '';
|
||||
|
||||
if (config.type === 'movie') {
|
||||
url = `${API_URL}/api/v1/security/movie-access?id_movie=${config.id}&token=1&sk=&step=1`;
|
||||
} else if (config.type === 'show') {
|
||||
url = `${API_URL}/api/v1/security/episode-access?id_episode=${config.id}`;
|
||||
}
|
||||
|
||||
const data = await fetch(url, {
|
||||
headers: { phpsessid },
|
||||
}).then((d) => d.json());
|
||||
|
||||
const subs = data?.subtitles.filter((sub) => {
|
||||
if (typeof sub.file === 'object') return false;
|
||||
return true;
|
||||
})
|
||||
|
||||
// Find video URL and return it (with a check for a full url if needed)
|
||||
const opts = ["1080p", "1080", "720p", "720", "480p", "480", "auto"];
|
||||
|
||||
let videoUrl = "";
|
||||
for (let res of opts) {
|
||||
if (data.streams[res] && !data.streams[res].includes('dummy') && !data.streams[res].includes('earth-1984') && !videoUrl) {
|
||||
videoUrl = data.streams[res]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
videoUrl: videoUrl.startsWith("/") ? `${BASE_URL}${videoUrl}` : videoUrl,
|
||||
subs: subs,
|
||||
};
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
const url = `${CORS_URL}/shows/view/${slug}`;
|
||||
const pageReq = await fetch(url, {
|
||||
headers: { phpsessid },
|
||||
}).then((d) => d.text());
|
||||
|
||||
const data = JSON5.parse("{" +
|
||||
pageReq
|
||||
.slice(pageReq.indexOf(`show_storage`))
|
||||
.split("};")[0]
|
||||
.split("= {")[1]
|
||||
.trim() +
|
||||
"}"
|
||||
);
|
||||
|
||||
let seasons = [];
|
||||
let episodes = [];
|
||||
data.seasons.forEach((e) => {
|
||||
if (!seasons.includes(e.season))
|
||||
seasons.push(e.season);
|
||||
|
||||
if (!episodes[e.season])
|
||||
episodes[e.season] = []
|
||||
episodes[e.season].push(e.episode)
|
||||
})
|
||||
|
||||
return { seasons, episodes }
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
const url = `${CORS_URL}/${type}s/view/${slug}`;
|
||||
const pageRes = await fetch(url);
|
||||
if (pageRes.headers.get('phpsessid')) phpsessid = pageRes.headers.get('phpsessid');
|
||||
const pageResText = await pageRes.text();
|
||||
|
||||
const data = JSON5.parse("{" +
|
||||
pageResText
|
||||
.slice(pageResText.indexOf(`${type}_storage`))
|
||||
.split("};")[0]
|
||||
.split("= {")[1]
|
||||
.trim() +
|
||||
"}"
|
||||
);
|
||||
|
||||
let id = '';
|
||||
|
||||
if (type === "movie") {
|
||||
id = data.id_movie;
|
||||
} else if (type === "show") {
|
||||
const episodeObj = data.seasons.find((v) => { return v.season === season && v.episode === episode; });
|
||||
|
||||
if (episodeObj) {
|
||||
id = episodeObj.id_episode;
|
||||
}
|
||||
}
|
||||
|
||||
if (id === '') {
|
||||
return { url: '' }
|
||||
}
|
||||
|
||||
const videoUrl = await getVideoUrl({
|
||||
slug: slug,
|
||||
id: id,
|
||||
type: type,
|
||||
});
|
||||
|
||||
return { url: videoUrl.videoUrl, subtitles: videoUrl.subs };
|
||||
}
|
||||
|
||||
|
||||
const lookMovie = { findContent, getStreamUrl, getEpisodes };
|
||||
export default lookMovie;
|
@ -1,120 +0,0 @@
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://theflix.to`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
const term = searchTerm.toLowerCase()
|
||||
const tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/search/${type === 'show' ? 'tv' : type}?query=${term}`).then(d => d.text());
|
||||
|
||||
const doc = new DOMParser().parseFromString(tmdbRes, 'text/html');
|
||||
const nodes = Array.from(doc.querySelectorAll('div.results > div > div.wrapper'));
|
||||
const results = nodes.slice(0, 10).map((node) => {
|
||||
let type = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('data-media-type');
|
||||
type = type === 'tv' ? 'show' : type;
|
||||
|
||||
let title;
|
||||
let year;
|
||||
let slug;
|
||||
|
||||
if (type === 'movie') {
|
||||
try {
|
||||
title = node.querySelector('div.details > div.wrapper > div.title > div > a').textContent;
|
||||
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
|
||||
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
}
|
||||
} else if (type === 'show') {
|
||||
try {
|
||||
title = node.querySelector('div.details > div.wrapper > div.title > div > a > h2').textContent;
|
||||
year = node.querySelector('div.details > div.wrapper > div.title > span').textContent.trim().split(' ')[2];
|
||||
slug = node.querySelector('div.details > div.wrapper > div.title > div > a').getAttribute('href').split('/')[2];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line array-callback-return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: type,
|
||||
title: title,
|
||||
year: year,
|
||||
slug: slug + '-' + title.replace(/[^a-z0-9]+|\s+/gmi, " ").replace(/\s+/g, '-').toLowerCase(),
|
||||
source: 'theflix'
|
||||
}
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
let tmdbRes;
|
||||
|
||||
try {
|
||||
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/seasons`).then(d => d.text());
|
||||
} catch (err) {
|
||||
tmdbRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug.split('-')[0]}/seasons`).then(d => d.text());
|
||||
|
||||
if (tmdbRes)
|
||||
slug = slug.split('-')[0];
|
||||
}
|
||||
|
||||
const sNodes = Array.from(new DOMParser().parseFromString(tmdbRes, 'text/html').querySelectorAll('div.column_wrapper > div.flex > div'));
|
||||
|
||||
let seasons = [];
|
||||
let episodes = [];
|
||||
|
||||
for (let s of sNodes) {
|
||||
const text = s.querySelector('div > section > div > div > div > h2 > a').textContent;
|
||||
if (!text.includes('Season')) continue;
|
||||
|
||||
const season = text.split(' ')[1];
|
||||
|
||||
if (!seasons.includes(season)) {
|
||||
seasons.push(season);
|
||||
}
|
||||
|
||||
if (!episodes[season]) {
|
||||
episodes[season] = [];
|
||||
}
|
||||
|
||||
const epRes = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}https://www.themoviedb.org/tv/${slug}/season/${season}`).then(d => d.text());
|
||||
const epNodes = Array.from(new DOMParser().parseFromString(epRes, 'text/html').querySelectorAll('div.episode_list > div.card'));
|
||||
epNodes.forEach((e, i) => episodes[season].push(++i));
|
||||
}
|
||||
|
||||
return { seasons, episodes };
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url;
|
||||
|
||||
if (type === 'show') {
|
||||
url = `${BASE_URL}/tv-show/${slug}/season-${season}/episode-${episode}`;
|
||||
} else {
|
||||
url = `${BASE_URL}/movie/${slug}?movieInfo=${slug}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
|
||||
const scripts = Array.from(new DOMParser().parseFromString(res, "text/html").querySelectorAll('script'));
|
||||
const prop = scripts.find((e) => e.textContent.includes("theflixvd.b-cdn"));
|
||||
|
||||
if (prop) {
|
||||
const data = JSON.parse(prop.textContent);
|
||||
return { url: data.props.pageProps.videoUrl };
|
||||
}
|
||||
|
||||
return { url: '' }
|
||||
}
|
||||
|
||||
const theflix = { findContent, getStreamUrl, getEpisodes }
|
||||
export default theflix;
|
@ -1,41 +0,0 @@
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://stream.vidzstore.com`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
if (type === 'show') return { options: [] };
|
||||
try {
|
||||
const searchUrl = `${BASE_URL}/search.php?sd=${searchTerm.replace(/ /g, "_")}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = [...doc.querySelectorAll(".post")];
|
||||
const results = nodes.map(node => {
|
||||
const title = node.querySelector("a").title.replace(/-/g, " ").trim();
|
||||
const titleArray = title.split(" ");
|
||||
titleArray.splice(-2);
|
||||
return {
|
||||
type,
|
||||
title: titleArray.join(" "),
|
||||
year: node.querySelector(".post-meta").innerText.split(" ").pop().split("-").shift(),
|
||||
slug: encodeURIComponent(node.querySelector("a").href.split('/').pop()),
|
||||
source: "vidzstore",
|
||||
}
|
||||
});
|
||||
|
||||
return { options: results };
|
||||
} catch {
|
||||
return { options: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug) {
|
||||
const url = `${BASE_URL}/${decodeURIComponent(slug)}`;
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html");
|
||||
|
||||
return { url: DOM.querySelector("source").src };
|
||||
}
|
||||
|
||||
const vidzstore = { findContent, getStreamUrl }
|
||||
export default vidzstore;
|
@ -1,84 +0,0 @@
|
||||
// THIS SCRAPER DOES NOT CURRENTLY WORK AND IS NOT IN USE
|
||||
|
||||
import { unpack } from '../util/unpacker';
|
||||
|
||||
const BASE_URL = `https://www.vmovee.watch`;
|
||||
const CORS_URL = `${process.env.REACT_APP_CORS_PROXY_URL}${BASE_URL}`;
|
||||
const SHOW_URL = `${CORS_URL}/series`
|
||||
const MOVIE_URL = `${CORS_URL}/movies`
|
||||
const MOVIE_URL_NO_CORS = `${BASE_URL}/movies`
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
if (type !== 'movie') return;
|
||||
|
||||
const searchUrl = `${CORS_URL}/?s=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
const nodes = Array.from(doc.querySelectorAll('div.search-page > div.result-item > article'));
|
||||
const results = nodes.map(node => {
|
||||
const imgHolder = node.querySelector('div.image > div.thumbnail > a');
|
||||
const titleHolder = node.querySelector('div.title > a');
|
||||
|
||||
return {
|
||||
type: imgHolder.querySelector('span').textContent === 'TV' ? 'show' : 'movie',
|
||||
title: titleHolder.textContent,
|
||||
year: node.querySelector('div.details > div.meta > span.year').textContent,
|
||||
slug: titleHolder.href.split('/')[4],
|
||||
source: 'vmovee'
|
||||
}
|
||||
});
|
||||
|
||||
if (results.length > 1) {
|
||||
return { options: results };
|
||||
} else {
|
||||
return { options: [ results[0] ] }
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url = '';
|
||||
|
||||
if (type === 'movie') {
|
||||
url = `${MOVIE_URL}/${slug}`;
|
||||
} else if (type === 'show') {
|
||||
url = `${SHOW_URL}/${slug}`;
|
||||
}
|
||||
|
||||
const res1 = await fetch(url, { headers: new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`) });
|
||||
const id = res1.headers.get('link').split('>')[0].split('?p=')[1];
|
||||
|
||||
const res2Headers = new Headers().append('referer', `${BASE_URL}/dashboard/admin-ajax.php`);
|
||||
const form = new FormData();
|
||||
form.append('action', 'doo_player_ajax')
|
||||
form.append('post', id)
|
||||
form.append('nume', '2')
|
||||
form.append('type', type)
|
||||
|
||||
const res2 = await fetch(`${CORS_URL}/dashboard/admin-ajax.php`, {
|
||||
method: 'POST',
|
||||
headers: res2Headers,
|
||||
body: form
|
||||
}).then((res) => res.json());
|
||||
let realUrl = res2.embed_url;
|
||||
|
||||
console.log(res2)
|
||||
|
||||
if (realUrl.startsWith('//')) {
|
||||
realUrl = `https:${realUrl}`;
|
||||
}
|
||||
|
||||
const res3 = await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${realUrl}`);
|
||||
res3.headers.forEach(console.log)
|
||||
|
||||
return { url: '' }
|
||||
|
||||
}
|
||||
|
||||
const vmovee = { findContent, getStreamUrl }
|
||||
export default vmovee;
|
@ -1,121 +0,0 @@
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
const BASE_URL = `${process.env.REACT_APP_CORS_PROXY_URL}https://xemovie.co`;
|
||||
|
||||
async function findContent(searchTerm, type) {
|
||||
try {
|
||||
let results;
|
||||
|
||||
const searchUrl = `${BASE_URL}/search?q=${encodeURIComponent(searchTerm)}`;
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.text());
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(searchRes, "text/html");
|
||||
switch (type) {
|
||||
case 'show':
|
||||
// const showContainer = doc.querySelectorAll(".py-10")[1].querySelector(".grid");
|
||||
// const showNodes = [...showContainer.querySelectorAll("a")].filter(link => !link.className);
|
||||
// results = showNodes.map(node => {
|
||||
// node = node.parentElement
|
||||
// return {
|
||||
// type,
|
||||
// title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
|
||||
// year: [...new Set(node.innerText.split("\n"))][3],
|
||||
// slug: node.querySelector("a").href.split('/').pop(),
|
||||
// source: "xemovie"
|
||||
// }
|
||||
// })
|
||||
// break;
|
||||
return { options: [] };
|
||||
case 'movie':
|
||||
const movieContainer = doc.querySelectorAll(".py-10")[0].querySelector(".grid");
|
||||
const movieNodes = [...movieContainer.querySelectorAll("a")].filter(link => !link.className);
|
||||
results = movieNodes.map(node => {
|
||||
node = node.parentElement
|
||||
return {
|
||||
type,
|
||||
title: [...new Set(node.innerText.split("\n"))][1].split("(")[0].trim(),
|
||||
year: [...new Set(node.innerText.split("\n"))][3],
|
||||
slug: node.querySelector("a").href.split('/').pop(),
|
||||
source: "xemovie"
|
||||
}
|
||||
})
|
||||
break;
|
||||
default:
|
||||
results = [];
|
||||
break;
|
||||
}
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm)
|
||||
.map(result => result.item);
|
||||
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] };
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type,
|
||||
year: r.year,
|
||||
source: 'xemovie'
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type, year } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type, year, source: 'xemovie' }]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { options: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamUrl(slug, type, season, episode) {
|
||||
let url;
|
||||
|
||||
if (type === "show") {
|
||||
|
||||
} else {
|
||||
url = `${BASE_URL}/movies/${slug}/watch`;
|
||||
}
|
||||
|
||||
let mediaUrl = "";
|
||||
let subtitles = [];
|
||||
|
||||
const res = await fetch(url).then(d => d.text());
|
||||
const DOM = new DOMParser().parseFromString(res, "text/html");
|
||||
|
||||
for (const script of DOM.scripts) {
|
||||
if (script.textContent.match(/https:\/\/s[0-9]\.xemovie\.com/)) {
|
||||
// eslint-disable-next-line
|
||||
let data = JSON.parse(JSON.stringify(eval(`(${script.textContent.replace("const data = ", "").split("};")[0]}})`)));
|
||||
// eslint-disable-next-line
|
||||
mediaUrl = data.playlist[0].file;
|
||||
// eslint-disable-next-line
|
||||
for (const subtitleTrack of data.playlist[0].tracks) {
|
||||
const subtitleBlob = URL.createObjectURL(await fetch(`${process.env.REACT_APP_CORS_PROXY_URL}${subtitleTrack.file}`).then(res => res.blob())); // do this so no need for CORS errors
|
||||
subtitles.push({
|
||||
file: subtitleBlob,
|
||||
language: subtitleTrack.label
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return { url: mediaUrl, subtitles: subtitles }
|
||||
}
|
||||
|
||||
async function getEpisodes(slug) {
|
||||
|
||||
}
|
||||
|
||||
const xemovie = { findContent, getStreamUrl, getEpisodes }
|
||||
export default xemovie;
|
@ -1,43 +0,0 @@
|
||||
import { versionedStoreBuilder } from './base.js';
|
||||
|
||||
/*
|
||||
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 VideoProgressStore = versionedStoreBuilder()
|
||||
.setKey('video-progress')
|
||||
.addVersion({
|
||||
version: 0,
|
||||
create() {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
.build()
|
@ -1,230 +0,0 @@
|
||||
function buildStoreObject(d) {
|
||||
const data = {
|
||||
versions: d.versions,
|
||||
currentVersion: d.maxVersion,
|
||||
id: d.storageString,
|
||||
}
|
||||
|
||||
function update(obj) {
|
||||
if (!obj)
|
||||
throw new Error("object to update is not an object");
|
||||
|
||||
// repeat until object fully updated
|
||||
if (obj["--version"] === undefined)
|
||||
obj["--version"] = 0;
|
||||
while (obj["--version"] !== this.currentVersion) {
|
||||
// get version
|
||||
let version = obj["--version"] || 0;
|
||||
if (version.constructor !== Number || version < 0)
|
||||
version = -42; // invalid on purpose so it will reset
|
||||
else {
|
||||
version = (version+1).toString()
|
||||
}
|
||||
|
||||
// check if version exists
|
||||
if (!this.versions[version]) {
|
||||
console.error(`Version not found for storage item in store ${this.id}, resetting`);
|
||||
obj = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// update object
|
||||
obj = this.versions[version].update(obj);
|
||||
}
|
||||
|
||||
// if resulting obj is null, use latest version as init object
|
||||
if (obj === null) {
|
||||
console.error(`Storage item for store ${this.id} has been reset due to faulty updates`);
|
||||
return this.versions[this.currentVersion.toString()].init();
|
||||
}
|
||||
|
||||
// updates succesful, return
|
||||
return obj;
|
||||
}
|
||||
|
||||
function get() {
|
||||
// get from storage api
|
||||
const store = this;
|
||||
let data = localStorage.getItem(this.id);
|
||||
|
||||
// parse json if item exists
|
||||
if (data) {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
if (!data.constructor) {
|
||||
console.error(`Storage item for store ${this.id} has not constructor`)
|
||||
throw new Error("storage item has no constructor")
|
||||
}
|
||||
if (data.constructor !== Object) {
|
||||
console.error(`Storage item for store ${this.id} is not an object`)
|
||||
throw new Error("storage item is not an object")
|
||||
}
|
||||
} catch (_) {
|
||||
// if errored, set to null so it generates new one, see below
|
||||
console.error(`Failed to parse storage item for store ${this.id}`)
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if item doesnt exist, generate from version init
|
||||
if (!data) {
|
||||
data = this.versions[this.currentVersion.toString()].init();
|
||||
}
|
||||
|
||||
// update the data if needed
|
||||
data = this.update(data);
|
||||
|
||||
// add a save object to return value
|
||||
data.save = function save() {
|
||||
localStorage.setItem(store.id, JSON.stringify(data));
|
||||
}
|
||||
|
||||
// add instance helpers
|
||||
Object.entries(d.instanceHelpers).forEach(([name, helper]) => {
|
||||
if (data[name] !== undefined)
|
||||
throw new Error(`helper name: ${name} on instance of store ${this.id} is reserved`)
|
||||
data[name] = helper.bind(data);
|
||||
})
|
||||
|
||||
// return data
|
||||
return data;
|
||||
}
|
||||
|
||||
// add functions to store
|
||||
data.get = get.bind(data);
|
||||
data.update = update.bind(data);
|
||||
|
||||
// add static helpers
|
||||
Object.entries(d.staticHelpers).forEach(([name, helper]) => {
|
||||
if (data[name] !== undefined)
|
||||
throw new Error(`helper name: ${name} on store ${data.id} is reserved`)
|
||||
data[name] = helper.bind({});
|
||||
})
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/*
|
||||
* Builds a versioned store
|
||||
*
|
||||
* manages versioning of localstorage items
|
||||
*/
|
||||
export function versionedStoreBuilder() {
|
||||
return {
|
||||
_data: {
|
||||
versionList: [],
|
||||
maxVersion: 0,
|
||||
versions: {},
|
||||
storageString: null,
|
||||
instanceHelpers: {},
|
||||
staticHelpers: {},
|
||||
},
|
||||
|
||||
/*
|
||||
* set key of localstorage item, must be unique
|
||||
*/
|
||||
setKey(str) {
|
||||
this._data.storageString = str;
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* add a version to the store
|
||||
*
|
||||
* version: version number
|
||||
* migrate: function to update from previous version to this version
|
||||
* create: function to return an empty storage item from this version (in correct syntax)
|
||||
*/
|
||||
addVersion({ version, migrate, create }) {
|
||||
// input checking
|
||||
if (version < 0)
|
||||
throw new Error("Cannot add version below 0 in store");
|
||||
if (version > 0 && !migrate)
|
||||
throw new Error(`Missing migration on version ${version} (needed for any version above 0)`);
|
||||
|
||||
// update max version list
|
||||
if (version > this._data.maxVersion)
|
||||
this._data.maxVersion = version;
|
||||
// add to version list
|
||||
this._data.versionList.push(version);
|
||||
|
||||
|
||||
// register version
|
||||
this._data.versions[version.toString()] = {
|
||||
version: version, // version number
|
||||
update: migrate ? (data) => { // update function, and increment version
|
||||
migrate(data);
|
||||
data["--version"] = version;
|
||||
return data;
|
||||
} : null,
|
||||
init: create ? () => { // return an initial object
|
||||
const data = create();
|
||||
data["--version"] = version;
|
||||
return data;
|
||||
} : null
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* register a instance or static helper to the store
|
||||
*
|
||||
* name: name of the helper function
|
||||
* helper: function to execute, the 'this' context is the current storage item (type is instance)
|
||||
* type: "instance" or "static". instance is put on the storage item when you store.get() it, static is on the store
|
||||
*/
|
||||
registerHelper({ name, helper, type }) {
|
||||
// type
|
||||
if (!type)
|
||||
type = "instance"
|
||||
|
||||
// input checking
|
||||
if (!name || name.constructor !== String) {
|
||||
throw new Error("helper name is not a string")
|
||||
}
|
||||
if (!helper || helper.constructor !== Function) {
|
||||
throw new Error("helper function is not a function")
|
||||
}
|
||||
if (!["instance", "static"].includes(type)) {
|
||||
throw new Error("helper type must be either 'instance' or 'static'")
|
||||
}
|
||||
|
||||
// register helper
|
||||
if (type === "instance")
|
||||
this._data.instanceHelpers[name] = helper
|
||||
else if (type === "static")
|
||||
this._data.staticHelpers[name] = helper
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/*
|
||||
* returns function store based on what has been set
|
||||
*/
|
||||
build() {
|
||||
// check if version list doesnt skip versions
|
||||
const versionListSorted = this._data.versionList.sort((a,b)=>a-b);
|
||||
versionListSorted.forEach((v, i, arr) => {
|
||||
if (i === 0)
|
||||
return;
|
||||
if (v !== arr[i-1]+1)
|
||||
throw new Error("Version list of store is not incremental");
|
||||
})
|
||||
|
||||
// version zero must exist
|
||||
if (versionListSorted[0] !== 0)
|
||||
throw new Error("Version 0 doesn't exist in version list of store");
|
||||
|
||||
// max version must have init function
|
||||
if (!this._data.versions[this._data.maxVersion.toString()].init)
|
||||
throw new Error(`Missing create function on version ${this._data.maxVersion} (needed for latest version of store)`);
|
||||
|
||||
// check storage string
|
||||
if (!this._data.storageString)
|
||||
throw new Error("storage key not set in store");
|
||||
|
||||
// build versioned store
|
||||
return buildStoreObject(this._data);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,53 +0,0 @@
|
||||
const alphabet = {
|
||||
62: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
95: '!"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'
|
||||
};
|
||||
|
||||
function _filterargs(str) {
|
||||
var juicers = [
|
||||
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\), *(\d+), *([\s\S]*)\)\)/,
|
||||
/}\('([\s\S]*)', *(\d+), *(\d+), *'([\s\S]*)'\.split\('\|'\)/
|
||||
];
|
||||
|
||||
for (var c = 0; c < juicers.length; ++c) {
|
||||
var m, juicer = juicers[c];
|
||||
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
if (m = juicer.exec(str)) {
|
||||
return [m[1], m[4].split('|'), parseInt(m[2]), parseInt(m[3])];
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Could not make sense of p.a.c.k.e.r data (unexpected code structure)");
|
||||
}
|
||||
|
||||
function _unbaser(base) {
|
||||
if (2 <= base <= 36) return (str) => parseInt(str, base);
|
||||
|
||||
const dictionary = {};
|
||||
var alpha = alphabet[base];
|
||||
if (!alpha) throw new Error("Unsupported encoding");
|
||||
|
||||
for (let c = 0; c < alpha.length; ++alpha) {
|
||||
dictionary[alpha[c]] = c;
|
||||
}
|
||||
|
||||
return (str) => str.split("").reverse().reduce((cipher, ind) => Math.pow(base, ind) * dictionary[cipher]);
|
||||
}
|
||||
|
||||
function unpack(str) {
|
||||
var params = _filterargs(str);
|
||||
var payload = params[0], symtab = params[1], radix = params[2], count = params[3];
|
||||
|
||||
if (count !== symtab.length) {
|
||||
throw new Error("Malformed p.a.c.k.e.r. symtab. (" + count + " != " + symtab.length + ")");
|
||||
}
|
||||
|
||||
var unbase = _unbaser(radix);
|
||||
var lookup = (word) => symtab[unbase(word)] || word;
|
||||
var source = payload.replace(/\b\w+\b/g, lookup);
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export { unpack };
|
@ -1,6 +0,0 @@
|
||||
.showType-show .title-size-big {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.showType-show .title-size-small, .showType-movie .title-size-big {
|
||||
margin-bottom: 30px;
|
||||
}
|
@ -1,156 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useRouteMatch, useHistory } from 'react-router-dom'
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Title } from '../components/Title'
|
||||
import { Card } from '../components/Card'
|
||||
import { useMovie } from '../hooks/useMovie'
|
||||
import { VideoElement } from '../components/VideoElement'
|
||||
import { EpisodeSelector } from '../components/EpisodeSelector'
|
||||
import { getStreamUrl } from '../lib/index'
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
|
||||
import './Movie.css'
|
||||
|
||||
export function MovieView(props) {
|
||||
const baseRouteMatch = useRouteMatch('/:type/:source/:title/:slug');
|
||||
const showRouteMatch = useRouteMatch('/:type/:source/:title/:slug/season/:season/episode/:episode');
|
||||
const history = useHistory();
|
||||
|
||||
const { streamUrl, streamData, setStreamUrl } = useMovie();
|
||||
const [ seasonList, setSeasonList ] = React.useState([]);
|
||||
const [ episodeLists, setEpisodeList ] = React.useState([]);
|
||||
const [ loading, setLoading ] = React.useState(false);
|
||||
const [ selectedSeason, setSelectedSeason ] = React.useState("1");
|
||||
const [ startTime, setStartTime ] = React.useState(0);
|
||||
const videoRef = React.useRef(null);
|
||||
let isVideoTimeSet = React.useRef(false);
|
||||
|
||||
const season = showRouteMatch?.params.season || "1";
|
||||
const episode = showRouteMatch?.params.episode || "1";
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
function setEpisode({ season, episode }) {
|
||||
history.push(`${baseRouteMatch.url}/season/${season}/episode/${episode}`);
|
||||
isVideoTimeSet.current = false;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamData.type === "show" && !showRouteMatch) history.replace(`${baseRouteMatch.url}/season/1/episode/1`);
|
||||
}, [streamData.type, showRouteMatch, history, baseRouteMatch.url]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamData.type === "show" && showRouteMatch) setSelectedSeason(showRouteMatch.params.season.toString());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancel = false;
|
||||
|
||||
if (streamData.type !== "show") return () => {
|
||||
cancel = true;
|
||||
};
|
||||
|
||||
if (!episode) {
|
||||
setLoading(false);
|
||||
setStreamUrl('');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
getStreamUrl(streamData.slug, streamData.type, streamData.source, season, episode)
|
||||
.then(({ url, subtitles }) => {
|
||||
if (cancel) return;
|
||||
streamData.subtitles = subtitles;
|
||||
setStreamUrl(url)
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (cancel) return;
|
||||
console.error(e)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancel = true;
|
||||
}
|
||||
}, [episode, streamData, setStreamUrl, season]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamData.type === "show") {
|
||||
setSeasonList(streamData.seasons);
|
||||
setEpisodeList(streamData.episodes[selectedSeason]);
|
||||
}
|
||||
}, [streamData.seasons, streamData.episodes, streamData.type, selectedSeason])
|
||||
|
||||
React.useEffect(() => {
|
||||
const progressData = VideoProgressStore.get();
|
||||
let key = streamData.type === "show" ? `${season}-${episode}` : "full"
|
||||
let time = progressData?.[streamData.source]?.[streamData.type]?.[streamData.slug]?.[key]?.currentlyAt;
|
||||
setStartTime(time);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseRouteMatch, showRouteMatch]);
|
||||
|
||||
const setProgress = (evt) => {
|
||||
let progressSave = VideoProgressStore.get();
|
||||
|
||||
if (!progressSave[streamData.source])
|
||||
progressSave[streamData.source] = {}
|
||||
if (!progressSave[streamData.source][streamData.type])
|
||||
progressSave[streamData.source][streamData.type] = {}
|
||||
if (!progressSave[streamData.source][streamData.type][streamData.slug])
|
||||
progressSave[streamData.source][streamData.type][streamData.slug] = {}
|
||||
|
||||
// Store real data
|
||||
let key = streamData.type === "show" ? `${season}-${episode}` : "full"
|
||||
progressSave[streamData.source][streamData.type][streamData.slug][key] = {
|
||||
currentlyAt: Math.floor(evt.currentTarget.currentTime),
|
||||
totalDuration: Math.floor(evt.currentTarget.duration),
|
||||
updatedAt: Date.now(),
|
||||
meta: streamData
|
||||
}
|
||||
|
||||
if(streamData.type === "show") {
|
||||
progressSave[streamData.source][streamData.type][streamData.slug][key].show = {
|
||||
season,
|
||||
episode
|
||||
}
|
||||
}
|
||||
|
||||
progressSave.save();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`cardView showType-${streamData.type}`}>
|
||||
<Helmet>
|
||||
<title>{streamData.title}{streamData.type === 'show' ? ` | S${season}E${episode}` : ''} | movie-web</title>
|
||||
</Helmet>
|
||||
|
||||
<Card fullWidth>
|
||||
<Title accent="Return to home" accentLink="search">
|
||||
{streamData.title}
|
||||
</Title>
|
||||
{streamData.type === "show" ? <Title size="small">
|
||||
Season {season}: Episode {episode}
|
||||
</Title> : undefined}
|
||||
|
||||
<VideoElement streamUrl={streamUrl} loading={loading} setProgress={setProgress} videoRef={videoRef} startTime={startTime} streamData={streamData} />
|
||||
|
||||
{streamData.type === "show" ?
|
||||
<EpisodeSelector
|
||||
setSelectedSeason={setSelectedSeason}
|
||||
selectedSeason={selectedSeason}
|
||||
|
||||
setEpisode={setEpisode}
|
||||
|
||||
seasons={seasonList}
|
||||
episodes={episodeLists}
|
||||
|
||||
currentSeason={season}
|
||||
currentEpisode={episode}
|
||||
|
||||
streamData={streamData}
|
||||
/>
|
||||
: ''}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
.cardView {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cardView nav {
|
||||
width: 100%;
|
||||
max-width: 624px;
|
||||
}
|
||||
.cardView nav span {
|
||||
padding: 8px 16px;
|
||||
margin-right: 10px;
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cardView nav span:focus-visible {
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
.cardView nav span:not(.selected-link) {
|
||||
cursor: pointer;
|
||||
}
|
||||
.cardView nav span.selected-link {
|
||||
background: var(--card);
|
||||
color: var(--button-text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.cardView > * {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.cardView > *:first-child {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.topRightCredits {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
top: 1rem;
|
||||
margin-top: 0 !important;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.topRightCredits a, .topRightCredits a:visited {
|
||||
color: var(--theme-color);
|
||||
text-decoration: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topRightCredits a:hover, .topRightCredits a:active {
|
||||
color: var(--theme-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topRightCredits a .arrow {
|
||||
transform: translateY(.1rem);
|
||||
}
|
||||
|
||||
.topRightCredits a:hover .arrow {
|
||||
transform: translateY(.1rem) translateX(.2rem);
|
||||
}
|
||||
|
||||
p.source {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: var(--source-headings);
|
||||
font-size: 0.8em;
|
||||
margin-top: 2rem;
|
||||
}
|
@ -1,299 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Redirect, useHistory, useRouteMatch } from 'react-router-dom';
|
||||
import { Arrow } from '../components/Arrow';
|
||||
import { Card } from '../components/Card';
|
||||
import { ErrorBanner } from '../components/ErrorBanner';
|
||||
import { InputBox } from '../components/InputBox';
|
||||
import { MovieRow } from '../components/MovieRow';
|
||||
import { Progress } from '../components/Progress';
|
||||
import { Title } from '../components/Title';
|
||||
import { TypeSelector } from '../components/TypeSelector';
|
||||
import { useMovie } from '../hooks/useMovie';
|
||||
import { findContent, getEpisodes, getStreamUrl } from '../lib/index';
|
||||
import { VideoProgressStore } from '../lib/storage/VideoProgress'
|
||||
|
||||
import './Search.css';
|
||||
|
||||
export function SearchView() {
|
||||
const { navigate, setStreamUrl, setStreamData } = useMovie();
|
||||
|
||||
const history = useHistory();
|
||||
const routeMatch = useRouteMatch('/:type');
|
||||
const type = routeMatch?.params?.type;
|
||||
const streamRouteMatch = useRouteMatch('/:type/:source/:title/:slug');
|
||||
|
||||
const maxSteps = 4;
|
||||
const [options, setOptions] = React.useState([]);
|
||||
const [progress, setProgress] = React.useState(0);
|
||||
const [text, setText] = React.useState("");
|
||||
const [failed, setFailed] = React.useState(false);
|
||||
const [showingOptions, setShowingOptions] = React.useState(false);
|
||||
const [errorStatus, setErrorStatus] = React.useState(false);
|
||||
const [page, setPage] = React.useState('search');
|
||||
const [continueWatching, setContinueWatching] = React.useState([])
|
||||
|
||||
const fail = (str) => {
|
||||
setProgress(maxSteps);
|
||||
setText(str)
|
||||
setFailed(true)
|
||||
}
|
||||
|
||||
async function getStream(title, slug, type, source, year) {
|
||||
setStreamUrl("");
|
||||
|
||||
try {
|
||||
setProgress(2);
|
||||
setText(`Getting stream for "${title}"`);
|
||||
|
||||
let seasons = [];
|
||||
let episodes = [];
|
||||
if (type === "show") {
|
||||
const data = await getEpisodes(slug, source);
|
||||
seasons = data.seasons;
|
||||
episodes = data.episodes;
|
||||
}
|
||||
|
||||
let realUrl = '';
|
||||
let subtitles = []
|
||||
|
||||
if (type === "movie") {
|
||||
const { url, subtitles: subs } = await getStreamUrl(slug, type, source);
|
||||
|
||||
if (url === '') {
|
||||
return fail(`Not found: ${title}`)
|
||||
}
|
||||
|
||||
realUrl = url;
|
||||
subtitles = subs
|
||||
}
|
||||
|
||||
setProgress(maxSteps);
|
||||
setStreamUrl(realUrl);
|
||||
setStreamData({
|
||||
title,
|
||||
type,
|
||||
seasons,
|
||||
episodes,
|
||||
slug,
|
||||
source,
|
||||
year,
|
||||
subtitles
|
||||
})
|
||||
setText(`Streaming...`)
|
||||
navigate("movie")
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
fail("Failed to get stream")
|
||||
}
|
||||
}
|
||||
|
||||
async function searchMovie(query, contentType) {
|
||||
setFailed(false);
|
||||
setText(`Searching for ${contentType} "${query}"`);
|
||||
setProgress(1)
|
||||
setShowingOptions(false)
|
||||
|
||||
try {
|
||||
const { options } = await findContent(query, contentType);
|
||||
|
||||
if (options.length === 0) {
|
||||
return fail(`Could not find that ${contentType}`)
|
||||
} else if (options.length > 1) {
|
||||
setProgress(2);
|
||||
setText(`Choose your ${contentType}`);
|
||||
setOptions(options);
|
||||
setShowingOptions(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, slug, type, source, year } = options[0];
|
||||
history.push(`${routeMatch.url}/${source}/${title}/${slug}`);
|
||||
getStream(title, slug, type, source, year);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
fail(`Failed to watch ${contentType}`)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchHealth() {
|
||||
await fetch(process.env.REACT_APP_CORS_PROXY_URL).catch(() => {
|
||||
// Request failed; source likely offline
|
||||
setErrorStatus(`Our content provider is currently offline, apologies.`)
|
||||
})
|
||||
}
|
||||
fetchHealth()
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (streamRouteMatch) {
|
||||
if (streamRouteMatch?.params.type === 'movie' || streamRouteMatch.params.type === 'show') getStream(streamRouteMatch.params.title, streamRouteMatch.params.slug, streamRouteMatch.params.type, streamRouteMatch.params.source);
|
||||
else return setErrorStatus("Failed to find movie. Please try searching below.");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const progressData = VideoProgressStore.get();
|
||||
let newContinueWatching = []
|
||||
|
||||
Object.keys(progressData).forEach((source) => {
|
||||
const all = [
|
||||
...Object.entries(progressData[source]?.show ?? {}),
|
||||
...Object.entries(progressData[source]?.movie ?? {})
|
||||
];
|
||||
|
||||
for (const [slug, data] of all) {
|
||||
for (let subselection of Object.values(data)) {
|
||||
let entry = {
|
||||
slug,
|
||||
data: subselection,
|
||||
type: subselection.show ? 'show' : 'movie',
|
||||
percentageDone: Math.floor((subselection.currentlyAt / subselection.totalDuration) * 100),
|
||||
source
|
||||
}
|
||||
|
||||
// due to a constraint with incompatible localStorage data,
|
||||
// we must quit here if episode and season data is not included
|
||||
// in the show's data. watching the show will resolve.
|
||||
if (!subselection.meta) continue;
|
||||
|
||||
if (entry.percentageDone < 90) {
|
||||
newContinueWatching.push(entry)
|
||||
// begin next episode logic
|
||||
} else {
|
||||
// we can't do next episode for movies!
|
||||
if (!subselection.show) continue;
|
||||
|
||||
let newShow = {};
|
||||
|
||||
// if the current season has a next episode, load it
|
||||
if (subselection.meta.episodes[subselection.show.season].includes(`${parseInt(subselection.show.episode) + 1}`)) {
|
||||
newShow.season = subselection.show.season;
|
||||
newShow.episode = `${parseInt(subselection.show.episode) + 1}`;
|
||||
entry.percentageDone = 0;
|
||||
// if the current season does not have a next epsiode, and the next season has a first episode, load that
|
||||
} else if (subselection.meta.episodes?.[`${parseInt(subselection.show.season) + 1}`]?.[0]) {
|
||||
newShow.season = `${parseInt(subselection.show.season) + 1}`;
|
||||
newShow.episode = subselection.meta.episodes[`${parseInt(subselection.show.season) + 1}`][0];
|
||||
entry.percentageDone = 0;
|
||||
// the next episode does not exist
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// assign the new episode and season data
|
||||
entry.data.show = { ...newShow };
|
||||
|
||||
// if the next episode exists, continue. we don't want to end up with duplicate data.
|
||||
let nextEpisode = progressData?.[source]?.show?.[slug]?.[`${entry.data.show.season}-${entry.data.show.episode}`];
|
||||
if (nextEpisode) continue;
|
||||
|
||||
newContinueWatching.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newContinueWatching = newContinueWatching.sort((a, b) => {
|
||||
return b.data.updatedAt - a.data.updatedAt
|
||||
});
|
||||
|
||||
setContinueWatching(newContinueWatching)
|
||||
})
|
||||
}, []);
|
||||
|
||||
if (!type || (type !== 'movie' && type !== 'show')) {
|
||||
return <Redirect to="/movie" />
|
||||
}
|
||||
|
||||
const handleKeyPress = page => event => {
|
||||
if (event.code === 'Enter' || event.code === 'Space'){
|
||||
setPage(page);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cardView">
|
||||
<Helmet>
|
||||
<title>{type === 'movie' ? 'movies' : 'shows'} | movie-web</title>
|
||||
</Helmet>
|
||||
|
||||
{/* Nav */}
|
||||
<nav>
|
||||
<span className={page === 'search' ? 'selected-link' : ''} onClick={() => setPage('search')} onKeyPress={handleKeyPress('search')} tabIndex={0}>Search</span>
|
||||
{continueWatching.length > 0 ?
|
||||
<span className={page === 'watching' ? 'selected-link' : ''} onClick={() => setPage('watching')} onKeyPress={handleKeyPress('watching')} tabIndex={0}>Continue watching</span>
|
||||
: ''}
|
||||
</nav>
|
||||
|
||||
{/* Search */}
|
||||
{page === 'search' ?
|
||||
<React.Fragment>
|
||||
<Card>
|
||||
{errorStatus ? <ErrorBanner>{errorStatus}</ErrorBanner> : ''}
|
||||
<Title accent="Because watching content legally is boring">
|
||||
What do you wanna watch?
|
||||
</Title>
|
||||
<TypeSelector
|
||||
setType={(type) => history.push(`/${type}`)}
|
||||
choices={[
|
||||
{ label: "Movie", value: "movie" },
|
||||
{ label: "TV Show", value: "show" }
|
||||
]}
|
||||
noWrap={true}
|
||||
selected={type}
|
||||
/>
|
||||
<InputBox placeholder={type === "movie" ? "Hamilton" : "Atypical"} onSubmit={(str) => searchMovie(str, type)} />
|
||||
<Progress show={progress > 0} failed={failed} progress={progress} steps={maxSteps} text={text} />
|
||||
</Card>
|
||||
|
||||
<Card show={showingOptions} doTransition>
|
||||
<Title size="medium">
|
||||
Whoops, there are a few {type}s like that
|
||||
</Title>
|
||||
{Object.entries(options.reduce((a, v) => {
|
||||
if (!a[v.source]) a[v.source] = []
|
||||
a[v.source].push(v)
|
||||
return a;
|
||||
}, {})).map(v => (
|
||||
<div key={v[0]}>
|
||||
<p className="source">{v[0]}</p>
|
||||
{v[1].map((v, i) => (
|
||||
<MovieRow key={i} title={v.title} slug={v.slug} type={v.type} year={v.year} source={v.source} onClick={() => {
|
||||
history.push(`${routeMatch.url}/${v.source}/${v.title}/${v.slug}`);
|
||||
setShowingOptions(false)
|
||||
getStream(v.title, v.slug, v.type, v.source, v.year)
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</React.Fragment> : <React.Fragment />}
|
||||
|
||||
{/* Continue watching */}
|
||||
{continueWatching.length > 0 && page === 'watching' ? <Card>
|
||||
<Title>Continue watching</Title>
|
||||
<Progress show={progress > 0} failed={failed} progress={progress} steps={maxSteps} text={text} />
|
||||
{continueWatching?.map((v, i) => (
|
||||
<MovieRow key={i} title={v.data.meta.title} slug={v.data.meta.slug} type={v.type} year={v.data.meta.year} source={v.source} place={v.data.show} percentage={v.percentageDone} deletable onClick={() => {
|
||||
if (v.type === 'show') {
|
||||
history.push(`/show/${v.source}/${v.data.meta.title}/${v.slug}/season/${v.data.show.season}/episode/${v.data.show.episode}`)
|
||||
} else {
|
||||
history.push(`/movie/${v.source}/${v.data.meta.title}/${v.slug}`)
|
||||
}
|
||||
|
||||
setShowingOptions(false)
|
||||
getStream(v.data.meta.title, v.data.meta.slug, v.type, v.source, v.data.meta.year)
|
||||
}} />
|
||||
))}
|
||||
</Card> : <React.Fragment></React.Fragment>}
|
||||
|
||||
<div className="topRightCredits">
|
||||
<a href="https://github.com/JamesHawkinss/movie-web" target="_blank" rel="noreferrer">Check it out on GitHub <Arrow /></a>
|
||||
<br />
|
||||
<a href="https://discord.gg/vXsRvye8BS" target="_blank" rel="noreferrer">Join the Discord <Arrow /></a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user