56 changed files with 1256 additions and 6816 deletions
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
* @movie-web/core |
||||
|
||||
.github @binaryoverload |
After Width: | Height: | Size: 314 B |
@ -1,128 +0,0 @@
@@ -1,128 +0,0 @@
|
||||
import { compareTitle } from "@/utils/titleMatch"; |
||||
|
||||
import { |
||||
getMWCaptionTypeFromUrl, |
||||
isSupportedSubtitle, |
||||
} from "../helpers/captions"; |
||||
import { mwFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams"; |
||||
import { MWMediaType } from "../metadata/types/mw"; |
||||
|
||||
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)
|
||||
|
||||
type FlixHQMediaType = "Movie" | "TV Series"; |
||||
interface FLIXMediaBase { |
||||
id: number; |
||||
title: string; |
||||
url: string; |
||||
image: string; |
||||
type: FlixHQMediaType; |
||||
releaseDate: string; |
||||
} |
||||
interface FLIXSubType { |
||||
url: string; |
||||
lang: string; |
||||
} |
||||
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null { |
||||
if (lang.includes("(maybe)")) return null; |
||||
const supported = isSupportedSubtitle(url); |
||||
if (!supported) return null; |
||||
const type = getMWCaptionTypeFromUrl(url); |
||||
return { |
||||
url, |
||||
langIso: lang, |
||||
type, |
||||
}; |
||||
} |
||||
|
||||
const qualityMap: Record<string, MWStreamQuality> = { |
||||
"360": MWStreamQuality.Q360P, |
||||
"540": MWStreamQuality.Q540P, |
||||
"480": MWStreamQuality.Q480P, |
||||
"720": MWStreamQuality.Q720P, |
||||
"1080": MWStreamQuality.Q1080P, |
||||
}; |
||||
|
||||
function flixTypeToMWType(type: FlixHQMediaType) { |
||||
if (type === "Movie") return MWMediaType.MOVIE; |
||||
return MWMediaType.SERIES; |
||||
} |
||||
|
||||
registerProvider({ |
||||
id: "flixhq", |
||||
displayName: "FlixHQ", |
||||
rank: 100, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
async scrape({ media, episode, progress }) { |
||||
if (!this.type.includes(media.meta.type)) { |
||||
throw new Error("Unsupported type"); |
||||
} |
||||
// search for relevant item
|
||||
const searchResults = await mwFetch<any>( |
||||
`/${encodeURIComponent(media.meta.title)}`, |
||||
{ |
||||
baseURL: flixHqBase, |
||||
} |
||||
); |
||||
|
||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => { |
||||
if (v.type !== "Movie" && v.type !== "TV Series") return false; |
||||
return ( |
||||
compareTitle(v.title, media.meta.title) && |
||||
flixTypeToMWType(v.type) === media.meta.type && |
||||
v.releaseDate === media.meta.year |
||||
); |
||||
}); |
||||
|
||||
if (!foundItem) throw new Error("No watchable item found"); |
||||
|
||||
// get media info
|
||||
progress(25); |
||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, { |
||||
baseURL: flixHqBase, |
||||
params: { |
||||
type: flixTypeToMWType(foundItem.type), |
||||
}, |
||||
}); |
||||
if (!mediaInfo.id) throw new Error("No watchable item found"); |
||||
// get stream info from media
|
||||
progress(50); |
||||
|
||||
let episodeId: string | undefined; |
||||
if (media.meta.type === MWMediaType.MOVIE) { |
||||
episodeId = mediaInfo.episodeId; |
||||
} else if (media.meta.type === MWMediaType.SERIES) { |
||||
const seasonNo = media.meta.seasonData.number; |
||||
const episodeNo = media.meta.seasonData.episodes.find( |
||||
(e) => e.id === episode |
||||
)?.number; |
||||
|
||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo); |
||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id; |
||||
} |
||||
if (!episodeId) throw new Error("No watchable item found"); |
||||
progress(75); |
||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, { |
||||
baseURL: flixHqBase, |
||||
params: { |
||||
id: mediaInfo.id, |
||||
}, |
||||
}); |
||||
|
||||
if (!watchInfo.sources) throw new Error("No watchable item found"); |
||||
|
||||
// get best quality source
|
||||
// comes sorted by quality in descending order
|
||||
const source = watchInfo.sources[0]; |
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: source.url, |
||||
quality: qualityMap[source.quality], |
||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, |
||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean), |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
export const flixHqBase = "https://flixhq.to"; |
@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
|
||||
import { MWEmbedType } from "@/backend/helpers/embed"; |
||||
import { registerProvider } from "@/backend/helpers/register"; |
||||
import { MWMediaType } from "@/backend/metadata/types/mw"; |
||||
import { |
||||
getFlixhqSourceDetails, |
||||
getFlixhqSources, |
||||
} from "@/backend/providers/flixhq/scrape"; |
||||
import { getFlixhqId } from "@/backend/providers/flixhq/search"; |
||||
|
||||
registerProvider({ |
||||
id: "flixhq", |
||||
displayName: "FlixHQ", |
||||
rank: 100, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
async scrape({ media }) { |
||||
const id = await getFlixhqId(media.meta); |
||||
if (!id) throw new Error("flixhq no matching item found"); |
||||
|
||||
// TODO tv shows not supported. just need to scrape the specific episode sources
|
||||
|
||||
const sources = await getFlixhqSources(id); |
||||
const upcloudStream = sources.find( |
||||
(v) => v.embed.toLowerCase() === "upcloud" |
||||
); |
||||
if (!upcloudStream) throw new Error("upcloud stream not found for flixhq"); |
||||
|
||||
return { |
||||
embeds: [ |
||||
{ |
||||
type: MWEmbedType.UPCLOUD, |
||||
url: await getFlixhqSourceDetails(upcloudStream.episodeId), |
||||
}, |
||||
], |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { flixHqBase } from "@/backend/providers/flixhq/common"; |
||||
|
||||
export async function getFlixhqSources(id: string) { |
||||
const type = id.split("/")[0]; |
||||
const episodeParts = id.split("-"); |
||||
const episodeId = episodeParts[episodeParts.length - 1]; |
||||
|
||||
const data = await proxiedFetch<string>( |
||||
`/ajax/${type}/episodes/${episodeId}`, |
||||
{ |
||||
baseURL: flixHqBase, |
||||
} |
||||
); |
||||
const doc = new DOMParser().parseFromString(data, "text/html"); |
||||
|
||||
const sourceLinks = [...doc.querySelectorAll(".nav-item > a")].map((el) => { |
||||
const embedTitle = el.getAttribute("title"); |
||||
const linkId = el.getAttribute("data-linkid"); |
||||
if (!embedTitle || !linkId) throw new Error("invalid sources"); |
||||
return { |
||||
embed: embedTitle, |
||||
episodeId: linkId, |
||||
}; |
||||
}); |
||||
|
||||
return sourceLinks; |
||||
} |
||||
|
||||
export async function getFlixhqSourceDetails( |
||||
sourceId: string |
||||
): Promise<string> { |
||||
const jsonData = await proxiedFetch<Record<string, any>>( |
||||
`/ajax/sources/${sourceId}`, |
||||
{ |
||||
baseURL: flixHqBase, |
||||
} |
||||
); |
||||
|
||||
return jsonData.link; |
||||
} |
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||
import { MWMediaMeta } from "@/backend/metadata/types/mw"; |
||||
import { flixHqBase } from "@/backend/providers/flixhq/common"; |
||||
import { compareTitle } from "@/utils/titleMatch"; |
||||
|
||||
export async function getFlixhqId(meta: MWMediaMeta): Promise<string | null> { |
||||
const searchResults = await proxiedFetch<string>( |
||||
`/search/${meta.title.replaceAll(/[^a-z0-9A-Z]/g, "-")}`, |
||||
{ |
||||
baseURL: flixHqBase, |
||||
} |
||||
); |
||||
|
||||
const doc = new DOMParser().parseFromString(searchResults, "text/html"); |
||||
const items = [...doc.querySelectorAll(".film_list-wrap > div.flw-item")].map( |
||||
(el) => { |
||||
const id = el |
||||
.querySelector("div.film-poster > a") |
||||
?.getAttribute("href") |
||||
?.slice(1); |
||||
const title = el |
||||
.querySelector("div.film-detail > h2 > a") |
||||
?.getAttribute("title"); |
||||
const year = el.querySelector( |
||||
"div.film-detail > div.fd-infor > span:nth-child(1)" |
||||
)?.textContent; |
||||
|
||||
if (!id || !title || !year) return null; |
||||
return { |
||||
id, |
||||
title, |
||||
year, |
||||
}; |
||||
} |
||||
); |
||||
|
||||
const matchingItem = items.find( |
||||
(v) => v && compareTitle(meta.title, v.title) && meta.year === v.year |
||||
); |
||||
|
||||
if (!matchingItem) return null; |
||||
return matchingItem.id; |
||||
} |
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { BrandPill } from "@/components/layout/BrandPill"; |
||||
import { WideContainer } from "@/components/layout/WideContainer"; |
||||
import { conf } from "@/setup/config"; |
||||
|
||||
function FooterLink(props: { |
||||
href: string; |
||||
children: React.ReactNode; |
||||
icon: Icons; |
||||
}) { |
||||
return ( |
||||
<a |
||||
href={props.href} |
||||
target="_blank" |
||||
className="inline-flex items-center space-x-3 transition-colors duration-200 hover:text-type-emphasis" |
||||
rel="noreferrer" |
||||
> |
||||
<Icon icon={props.icon} className="text-2xl" /> |
||||
<span className="font-medium">{props.children}</span> |
||||
</a> |
||||
); |
||||
} |
||||
|
||||
function Dmca() { |
||||
const { t } = useTranslation(); |
||||
return ( |
||||
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo"> |
||||
{t("footer.links.dmca")} |
||||
</FooterLink> |
||||
); |
||||
} |
||||
|
||||
export function Footer() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<footer className="mt-16 border-t border-type-divider py-16 md:py-8"> |
||||
<WideContainer ultraWide classNames="grid md:grid-cols-2 gap-16 md:gap-8"> |
||||
<div> |
||||
<div className="inline-block"> |
||||
<BrandPill /> |
||||
</div> |
||||
<p className="mt-4 lg:max-w-[400px]">{t("footer.tagline")}</p> |
||||
</div> |
||||
<div className="md:text-right"> |
||||
<h3 className="font-semibold text-type-emphasis"> |
||||
{t("footer.legal.disclaimer")} |
||||
</h3> |
||||
<p className="mt-3">{t("footer.legal.disclaimerText")}</p> |
||||
</div> |
||||
<div className="space-x-[2rem]"> |
||||
<FooterLink icon={Icons.GITHUB} href={conf().GITHUB_LINK}> |
||||
{t("footer.links.github")} |
||||
</FooterLink> |
||||
<FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}> |
||||
{t("footer.links.discord")} |
||||
</FooterLink> |
||||
<div className="inline md:hidden"> |
||||
<Dmca /> |
||||
</div> |
||||
</div> |
||||
<div className="hidden items-center justify-end md:flex"> |
||||
<Dmca /> |
||||
</div> |
||||
</WideContainer> |
||||
</footer> |
||||
); |
||||
} |
||||
|
||||
export function FooterView(props: { |
||||
children: React.ReactNode; |
||||
className?: string; |
||||
}) { |
||||
return ( |
||||
<div |
||||
className={["flex min-h-screen flex-col", props.className || ""].join( |
||||
" " |
||||
)} |
||||
> |
||||
<div style={{ flex: "1 0 auto" }}>{props.children}</div> |
||||
<Footer /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
.flare-enabled .flare-light { |
||||
opacity: 1 !important; |
||||
} |
||||
|
||||
.hover\:flare-enabled:hover .flare-light { |
||||
opacity: 1 !important; |
||||
} |
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
import c from "classnames"; |
||||
import { ReactNode, useEffect, useRef } from "react"; |
||||
import "./Flare.css"; |
||||
|
||||
export interface FlareProps { |
||||
className?: string; |
||||
backgroundClass: string; |
||||
flareSize?: number; |
||||
cssColorVar?: string; |
||||
enabled?: boolean; |
||||
} |
||||
|
||||
const SIZE_DEFAULT = 200; |
||||
const CSS_VAR_DEFAULT = "--colors-global-accentA"; |
||||
|
||||
function Base(props: { className?: string; children?: ReactNode }) { |
||||
return <div className={c(props.className, "relative")}>{props.children}</div>; |
||||
} |
||||
|
||||
function Child(props: { className?: string; children?: ReactNode }) { |
||||
return <div className={c(props.className, "relative")}>{props.children}</div>; |
||||
} |
||||
|
||||
function Light(props: FlareProps) { |
||||
const outerRef = useRef<HTMLDivElement>(null); |
||||
const size = props.flareSize ?? SIZE_DEFAULT; |
||||
const cssVar = props.cssColorVar ?? CSS_VAR_DEFAULT; |
||||
|
||||
useEffect(() => { |
||||
function mouseMove(e: MouseEvent) { |
||||
if (!outerRef.current) return; |
||||
const rect = outerRef.current.getBoundingClientRect(); |
||||
const halfSize = size / 2; |
||||
outerRef.current.style.setProperty( |
||||
"--bg-x", |
||||
`${(e.clientX - rect.left - halfSize).toFixed(0)}px` |
||||
); |
||||
outerRef.current.style.setProperty( |
||||
"--bg-y", |
||||
`${(e.clientY - rect.top - halfSize).toFixed(0)}px` |
||||
); |
||||
} |
||||
document.addEventListener("mousemove", mouseMove); |
||||
|
||||
return () => document.removeEventListener("mousemove", mouseMove); |
||||
}, [size]); |
||||
|
||||
return ( |
||||
<div |
||||
ref={outerRef} |
||||
className={c( |
||||
"flare-light pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-[400ms]", |
||||
props.className, |
||||
{ |
||||
"!opacity-100": props.enabled ?? false, |
||||
} |
||||
)} |
||||
style={{ |
||||
backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`, |
||||
backgroundPosition: `var(--bg-x) var(--bg-y)`, |
||||
backgroundRepeat: "no-repeat", |
||||
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`, |
||||
}} |
||||
> |
||||
<div |
||||
className={c( |
||||
"absolute inset-[1px] overflow-hidden", |
||||
props.className, |
||||
props.backgroundClass |
||||
)} |
||||
> |
||||
<div |
||||
className="absolute inset-0 opacity-10" |
||||
style={{ |
||||
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`, |
||||
backgroundPosition: `var(--bg-x) var(--bg-y)`, |
||||
backgroundRepeat: "no-repeat", |
||||
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`, |
||||
}} |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export const Flare = { |
||||
Base, |
||||
Light, |
||||
Child, |
||||
}; |
@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
|
||||
.lightbar, .lightbar-visual { |
||||
position: absolute; |
||||
top: 0; |
||||
width: 500vw; |
||||
height: 800px; |
||||
pointer-events: none; |
||||
user-select: none; |
||||
} |
||||
|
||||
.lightbar { |
||||
left: 50vw; |
||||
transform: translateX(-50%); |
||||
} |
||||
|
||||
@screen sm { |
||||
.lightbar, .lightbar-visual { |
||||
width: 150vw; |
||||
} |
||||
|
||||
.lightbar { |
||||
left: -25vw; |
||||
transform: initial; |
||||
} |
||||
} |
||||
|
||||
.lightbar { |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
--d: 3s; |
||||
--animation: cubic-bezier(.75, -0.00, .25, 1); |
||||
animation: boot var(--d) var(--animation) forwards; |
||||
} |
||||
|
||||
.lightbar-visual { |
||||
left: 0; |
||||
--top: theme('colors.background.main'); |
||||
--bottom: theme('colors.lightBar.light'); |
||||
--first: conic-gradient(from 90deg at 80% 50%, var(--top), var(--bottom)); |
||||
--second: conic-gradient(from 270deg at 20% 50%, var(--bottom), var(--top)); |
||||
mask-image: radial-gradient(100% 50% at center center, black, transparent); |
||||
background-image: var(--first), var(--second); |
||||
background-position-x: 1%, 99%; |
||||
background-position-y: 0%, 0%; |
||||
background-size: 50% 100%, 50% 100%; |
||||
opacity: 1; |
||||
transform: rotate(180deg) translateZ(0px) translateY(400px); |
||||
transform-origin: center center; |
||||
background-repeat: no-repeat; |
||||
animation: lightbarBoot var(--d) var(--animation) forwards; |
||||
} |
||||
|
||||
.lightbar canvas { |
||||
width: 40%; |
||||
height: 300px; |
||||
transform: translateY(-250px); |
||||
} |
||||
|
||||
@keyframes boot { |
||||
from { |
||||
|
||||
opacity: 0.25; |
||||
} |
||||
|
||||
to { |
||||
opacity: 1; |
||||
} |
||||
} |
||||
|
||||
@keyframes lightbarBoot { |
||||
0% { |
||||
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(0.8); |
||||
} |
||||
|
||||
100% { |
||||
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(1); |
||||
} |
||||
} |
@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef } from "react"; |
||||
import "./Lightbar.css"; |
||||
|
||||
class Particle { |
||||
x = 0; |
||||
|
||||
y = 0; |
||||
|
||||
radius = 0; |
||||
|
||||
direction = 0; |
||||
|
||||
speed = 0; |
||||
|
||||
lifetime = 0; |
||||
|
||||
ran = 0; |
||||
|
||||
image: null | HTMLImageElement = null; |
||||
|
||||
constructor(canvas: HTMLCanvasElement, { doFish } = { doFish: false }) { |
||||
if (doFish) { |
||||
this.image = new Image(); |
||||
if (this.image) this.image.src = "/fishie.png"; |
||||
} |
||||
|
||||
this.reset(canvas); |
||||
this.initialize(canvas); |
||||
} |
||||
|
||||
reset(canvas: HTMLCanvasElement) { |
||||
this.x = Math.round((Math.random() * canvas.width) / 2 + canvas.width / 4); |
||||
this.y = Math.random() * 100 + 5; |
||||
|
||||
this.radius = 1 + Math.floor(Math.random() * 0.5); |
||||
this.direction = (Math.random() * Math.PI) / 2 + Math.PI / 4; |
||||
this.speed = 0.02 + Math.random() * 0.08; |
||||
|
||||
const second = 60; |
||||
this.lifetime = second * 3 + Math.random() * (second * 30); |
||||
|
||||
if (this.image) { |
||||
this.direction = Math.random() <= 0.5 ? 0 : Math.PI; |
||||
this.lifetime = 30 * second; |
||||
} |
||||
|
||||
this.ran = 0; |
||||
} |
||||
|
||||
initialize(canvas: HTMLCanvasElement) { |
||||
this.ran = Math.random() * this.lifetime; |
||||
const baseSpeed = this.speed; |
||||
this.speed = Math.random() * this.lifetime * baseSpeed; |
||||
this.update(canvas); |
||||
this.speed = baseSpeed; |
||||
} |
||||
|
||||
update(canvas: HTMLCanvasElement) { |
||||
this.ran += 1; |
||||
|
||||
const addX = this.speed * Math.cos(this.direction); |
||||
const addY = this.speed * Math.sin(this.direction); |
||||
this.x += addX; |
||||
this.y += addY; |
||||
|
||||
if (this.ran > this.lifetime) { |
||||
this.reset(canvas); |
||||
} |
||||
} |
||||
|
||||
render(canvas: HTMLCanvasElement) { |
||||
const ctx = canvas.getContext("2d"); |
||||
if (!ctx) return; |
||||
|
||||
ctx.save(); |
||||
ctx.beginPath(); |
||||
|
||||
const x = this.ran / this.lifetime; |
||||
const o = (x - x * x) * 4; |
||||
ctx.globalAlpha = Math.max(0, o * 0.8); |
||||
|
||||
if (this.image) { |
||||
ctx.translate(this.x, this.y); |
||||
const w = 10; |
||||
const h = (this.image.naturalWidth / this.image.naturalHeight) * w; |
||||
ctx.rotate(this.direction - Math.PI); |
||||
ctx.drawImage(this.image, -w / 2, h, h, w); |
||||
} else { |
||||
ctx.ellipse( |
||||
this.x, |
||||
this.y, |
||||
this.radius, |
||||
this.radius * 1.5, |
||||
this.direction, |
||||
0, |
||||
Math.PI * 2 |
||||
); |
||||
ctx.fillStyle = "white"; |
||||
ctx.fill(); |
||||
} |
||||
ctx.restore(); |
||||
} |
||||
} |
||||
|
||||
function ParticlesCanvas() { |
||||
const canvasRef = useRef<HTMLCanvasElement>(null); |
||||
|
||||
useEffect(() => { |
||||
if (!canvasRef.current) return; |
||||
const canvas = canvasRef.current; |
||||
const particles: Particle[] = []; |
||||
|
||||
canvas.width = canvas.scrollWidth; |
||||
canvas.height = canvas.scrollHeight; |
||||
|
||||
const shouldShowFishie = Math.floor(Math.random() * 600) === 1; |
||||
const particleCount = 20; |
||||
|
||||
for (let i = 0; i < particleCount; i += 1) { |
||||
const particle = new Particle(canvas, { |
||||
doFish: shouldShowFishie && i <= particleCount / 2, |
||||
}); |
||||
particles.push(particle); |
||||
} |
||||
|
||||
let shouldTick = true; |
||||
let handle: ReturnType<typeof requestAnimationFrame> | null = null; |
||||
function particlesLoop() { |
||||
const ctx = canvas.getContext("2d"); |
||||
if (!ctx) return; |
||||
|
||||
if (shouldTick) { |
||||
for (const particle of particles) { |
||||
particle.update(canvas); |
||||
} |
||||
shouldTick = false; |
||||
} |
||||
|
||||
canvas.width = canvas.scrollWidth; |
||||
canvas.height = canvas.scrollHeight; |
||||
for (const particle of particles) { |
||||
particle.render(canvas); |
||||
} |
||||
|
||||
handle = requestAnimationFrame(particlesLoop); |
||||
} |
||||
const interval = setInterval(() => { |
||||
shouldTick = true; |
||||
}, 1e3 / 120); // tick 120 times a sec
|
||||
|
||||
particlesLoop(); |
||||
|
||||
return () => { |
||||
if (handle) cancelAnimationFrame(handle); |
||||
clearInterval(interval); |
||||
}; |
||||
}, []); |
||||
|
||||
return <canvas className="particles" ref={canvasRef} />; |
||||
} |
||||
|
||||
export function Lightbar(props: { className?: string }) { |
||||
return ( |
||||
<div className={props.className}> |
||||
<div className="lightbar"> |
||||
<ParticlesCanvas /> |
||||
<div className="lightbar-visual" /> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { WideContainer } from "@/components/layout/WideContainer"; |
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
import { HomeLayout } from "@/pages/layouts/HomeLayout"; |
||||
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart"; |
||||
import { HeroPart } from "@/pages/parts/home/HeroPart"; |
||||
import { WatchingPart } from "@/pages/parts/home/WatchingPart"; |
||||
import { SearchListPart } from "@/pages/parts/search/SearchListPart"; |
||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; |
||||
|
||||
function useSearch(search: MWQuery) { |
||||
const [searching, setSearching] = useState<boolean>(false); |
||||
const [loading, setLoading] = useState<boolean>(false); |
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 500); |
||||
useEffect(() => { |
||||
setSearching(search.searchQuery !== ""); |
||||
setLoading(search.searchQuery !== ""); |
||||
}, [search]); |
||||
useEffect(() => { |
||||
setLoading(false); |
||||
}, [debouncedSearch]); |
||||
|
||||
return { |
||||
loading, |
||||
searching, |
||||
}; |
||||
} |
||||
|
||||
export function HomePage() { |
||||
const { t } = useTranslation(); |
||||
const [showBg, setShowBg] = useState<boolean>(false); |
||||
const searchParams = useSearchQuery(); |
||||
const [search] = searchParams; |
||||
const s = useSearch(search); |
||||
|
||||
return ( |
||||
<HomeLayout showBg={showBg}> |
||||
<div className="relative z-10 mb-16 sm:mb-24"> |
||||
<Helmet> |
||||
<title>{t("global.name")}</title> |
||||
</Helmet> |
||||
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} /> |
||||
</div> |
||||
<WideContainer> |
||||
{s.loading ? ( |
||||
<SearchLoadingPart /> |
||||
) : s.searching ? ( |
||||
<SearchListPart searchQuery={search} /> |
||||
) : ( |
||||
<> |
||||
<BookmarksPart /> |
||||
<WatchingPart /> |
||||
</> |
||||
)} |
||||
</WideContainer> |
||||
</HomeLayout> |
||||
); |
||||
} |
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { ErrorWrapperPart } from "@/pages/parts/errors/ErrorWrapperPart"; |
||||
|
||||
export function NotFoundPage() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<ErrorWrapperPart> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.page.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.page.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</ErrorWrapperPart> |
||||
); |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
import { FooterView } from "@/components/layout/Footer"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
|
||||
export function HomeLayout(props: { |
||||
showBg: boolean; |
||||
children: React.ReactNode; |
||||
}) { |
||||
return ( |
||||
<FooterView> |
||||
<Navigation bg={props.showBg} /> |
||||
{props.children} |
||||
</FooterView> |
||||
); |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
import { FooterView } from "@/components/layout/Footer"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
|
||||
export function PageLayout(props: { children: React.ReactNode }) { |
||||
return ( |
||||
<FooterView> |
||||
<Navigation /> |
||||
{props.children} |
||||
</FooterView> |
||||
); |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { useGoBack } from "@/hooks/useGoBack"; |
||||
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; |
||||
|
||||
export function ErrorWrapperPart(props: { |
||||
children?: ReactNode; |
||||
video?: boolean; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
const goBack = useGoBack(); |
||||
|
||||
return ( |
||||
<div className="relative flex flex-1 flex-col"> |
||||
<Helmet> |
||||
<title>{t("notFound.genericTitle")}</title> |
||||
</Helmet> |
||||
{props.video ? ( |
||||
<div className="absolute inset-x-0 top-0 px-8 py-6"> |
||||
<VideoPlayerHeader onClick={goBack} /> |
||||
</div> |
||||
) : ( |
||||
<Navigation /> |
||||
)} |
||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
|
||||
export function MediaNotFoundPart() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.media.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.media.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
|
||||
export function ProviderNotFoundPart() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.provider.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm"> |
||||
{t("notFound.provider.description")} |
||||
</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||
import { useMemo, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { useBookmarkContext } from "@/state/bookmark"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
|
||||
export function BookmarksPart() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext(); |
||||
const bookmarks = getFilteredBookmarks(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
const { watched } = useWatchedContext(); |
||||
|
||||
const bookmarksSorted = useMemo(() => { |
||||
return bookmarks |
||||
.map((v) => { |
||||
return { |
||||
...v, |
||||
watched: watched.items |
||||
.sort((a, b) => b.watchedAt - a.watchedAt) |
||||
.find((watchedItem) => watchedItem.item.meta.id === v.id), |
||||
}; |
||||
}) |
||||
.sort( |
||||
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0) |
||||
); |
||||
}, [watched.items, bookmarks]); |
||||
|
||||
if (bookmarks.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.bookmarks") || "Bookmarks"} |
||||
icon={Icons.BOOKMARK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{bookmarksSorted.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.id} |
||||
media={v} |
||||
closable={editing} |
||||
onClose={() => setItemBookmark(v, false)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import Sticky from "react-stickynode"; |
||||
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useBannerSize } from "@/hooks/useBanner"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
|
||||
export interface HeroPartProps { |
||||
setIsSticky: (val: boolean) => void; |
||||
searchParams: ReturnType<typeof useSearchQuery>; |
||||
} |
||||
|
||||
export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) { |
||||
const { t } = useTranslation(); |
||||
const [search, setSearch, setSearchUnFocus] = searchParams; |
||||
const [, setShowBg] = useState(false); |
||||
const bannerSize = useBannerSize(); |
||||
const stickStateChanged = useCallback( |
||||
({ status }: Sticky.Status) => { |
||||
const val = status === Sticky.STATUS_FIXED; |
||||
setShowBg(val); |
||||
setIsSticky(val); |
||||
}, |
||||
[setShowBg, setIsSticky] |
||||
); |
||||
|
||||
return ( |
||||
<ThinContainer> |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="relative z-10 mb-16"> |
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title> |
||||
</div> |
||||
<div className="relative z-30"> |
||||
<Sticky |
||||
enabled |
||||
top={16 + bannerSize} |
||||
onStateChange={stickStateChanged} |
||||
> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={ |
||||
t("search.placeholder") || "What do you want to watch?" |
||||
} |
||||
/> |
||||
</Sticky> |
||||
</div> |
||||
</div> |
||||
</ThinContainer> |
||||
); |
||||
} |
@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||
import { useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
|
||||
export function WatchingPart() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const { getFilteredWatched, removeProgress } = useWatchedContext(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
|
||||
const bookmarks = getFilteredBookmarks(); |
||||
const watchedItems = getFilteredWatched().filter( |
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta) |
||||
); |
||||
|
||||
if (watchedItems.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.continueWatching") || "Continue Watching"} |
||||
icon={Icons.CLOCK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{watchedItems.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.item.meta.id} |
||||
media={v.item.meta} |
||||
closable={editing} |
||||
onClose={() => removeProgress(v.item.meta.id)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { searchForMedia } from "@/backend/metadata/search"; |
||||
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; |
||||
|
||||
function SearchSuffix(props: { failed?: boolean; results?: number }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH; |
||||
|
||||
return ( |
||||
<div className="mb-24 mt-40 flex flex-col items-center justify-center space-y-3 text-center"> |
||||
<IconPatch |
||||
icon={icon} |
||||
className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`} |
||||
/> |
||||
|
||||
{/* standard suffix */} |
||||
{!props.failed ? ( |
||||
<div> |
||||
{(props.results ?? 0) > 0 ? ( |
||||
<p>{t("search.allResults")}</p> |
||||
) : ( |
||||
<p>{t("search.noResults")}</p> |
||||
)} |
||||
</div> |
||||
) : null} |
||||
|
||||
{/* Error result */} |
||||
{props.failed ? ( |
||||
<div> |
||||
<p>{t("search.allFailed")}</p> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function SearchListPart({ searchQuery }: { searchQuery: MWQuery }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [results, setResults] = useState<MWMediaMeta[]>([]); |
||||
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => |
||||
searchForMedia(query) |
||||
); |
||||
|
||||
useEffect(() => { |
||||
async function runSearch(query: MWQuery) { |
||||
const searchResults = await runSearchQuery(query); |
||||
if (!searchResults) return; |
||||
setResults(searchResults); |
||||
} |
||||
|
||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery); |
||||
}, [searchQuery, runSearchQuery]); |
||||
|
||||
if (loading) return <SearchLoadingPart />; |
||||
if (error) return <SearchSuffix failed />; |
||||
if (!results) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
{results.length > 0 ? ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.headingTitle") || "Search results"} |
||||
icon={Icons.SEARCH} |
||||
/> |
||||
<MediaGrid> |
||||
{results.map((v) => ( |
||||
<WatchedMediaCard key={v.id.toString()} media={v} /> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
) : null} |
||||
|
||||
<SearchSuffix results={results.length} /> |
||||
</div> |
||||
); |
||||
} |
@ -1,148 +0,0 @@
@@ -1,148 +0,0 @@
|
||||
import { useMemo } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { CaptionCue } from "@/_oldvideo/components/actions/CaptionRendererAction"; |
||||
import CaptionColorSelector, { |
||||
colors, |
||||
} from "@/components/CaptionColorSelector"; |
||||
import { Dropdown } from "@/components/Dropdown"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { Modal, ModalCard } from "@/components/layout/Modal"; |
||||
import { Slider } from "@/components/Slider"; |
||||
import { conf } from "@/setup/config"; |
||||
import { appLanguageOptions } from "@/setup/i18n"; |
||||
import { |
||||
CaptionLanguageOption, |
||||
LangCode, |
||||
captionLanguages, |
||||
} from "@/setup/iso6391"; |
||||
import { useSettings } from "@/state/settings"; |
||||
|
||||
export default function SettingsModal(props: { |
||||
onClose: () => void; |
||||
show: boolean; |
||||
}) { |
||||
const { |
||||
captionSettings, |
||||
language, |
||||
setLanguage, |
||||
setCaptionLanguage, |
||||
setCaptionBackgroundColor, |
||||
setCaptionFontSize, |
||||
} = useSettings(); |
||||
const { t, i18n } = useTranslation(); |
||||
|
||||
const selectedCaptionLanguage = useMemo( |
||||
() => captionLanguages.find((l) => l.id === captionSettings.language), |
||||
[captionSettings.language] |
||||
) as CaptionLanguageOption; |
||||
const appLanguage = useMemo( |
||||
() => appLanguageOptions.find((l) => l.id === language), |
||||
[language] |
||||
) as CaptionLanguageOption; |
||||
const captionBackgroundOpacity = ( |
||||
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) / |
||||
255) * |
||||
100 |
||||
).toFixed(0); |
||||
return ( |
||||
<Modal show={props.show}> |
||||
<ModalCard className="text-white"> |
||||
<div className="flex flex-col gap-4"> |
||||
<div className="flex flex-row justify-between"> |
||||
<span className="text-xl font-bold">{t("settings.title")}</span> |
||||
<div |
||||
onClick={() => props.onClose()} |
||||
className="hover:cursor-pointer" |
||||
> |
||||
<Icon icon={Icons.X} /> |
||||
</div> |
||||
</div> |
||||
<div className="flex flex-col gap-10 lg:flex-row"> |
||||
<div className="lg:w-1/2"> |
||||
<div className="flex flex-col justify-between"> |
||||
<label className="text-md font-semibold"> |
||||
{t("settings.language")} |
||||
</label> |
||||
<Dropdown |
||||
selectedItem={appLanguage} |
||||
setSelectedItem={(val) => { |
||||
i18n.changeLanguage(val.id); |
||||
setLanguage(val.id as LangCode); |
||||
}} |
||||
options={appLanguageOptions} |
||||
/> |
||||
</div> |
||||
<div className="flex flex-col justify-between"> |
||||
<label className="text-md font-semibold"> |
||||
{t("settings.captionLanguage")} |
||||
</label> |
||||
<Dropdown |
||||
selectedItem={selectedCaptionLanguage} |
||||
setSelectedItem={(val) => { |
||||
setCaptionLanguage(val.id as LangCode); |
||||
}} |
||||
options={captionLanguages} |
||||
/> |
||||
</div> |
||||
<div className="flex flex-col justify-between"> |
||||
<Slider |
||||
label={ |
||||
t( |
||||
"videoPlayer.popouts.captionPreferences.fontSize" |
||||
) as string |
||||
} |
||||
min={14} |
||||
step={1} |
||||
max={60} |
||||
value={captionSettings.style.fontSize} |
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)} |
||||
/> |
||||
<Slider |
||||
label={ |
||||
t( |
||||
"videoPlayer.popouts.captionPreferences.opacity" |
||||
) as string |
||||
} |
||||
step={1} |
||||
min={0} |
||||
max={255} |
||||
valueDisplay={`${captionBackgroundOpacity}%`} |
||||
value={parseInt( |
||||
captionSettings.style.backgroundColor.substring(7, 9), |
||||
16 |
||||
)} |
||||
onChange={(e) => |
||||
setCaptionBackgroundColor(e.target.valueAsNumber) |
||||
} |
||||
/> |
||||
<div className="flex flex-row justify-between"> |
||||
<label className="font-bold" htmlFor="color"> |
||||
{t("videoPlayer.popouts.captionPreferences.color")} |
||||
</label> |
||||
<div className="flex flex-row gap-2"> |
||||
{colors.map((color) => ( |
||||
<CaptionColorSelector key={color} color={color} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div /> |
||||
</div> |
||||
<div className="flex w-full flex-col justify-center"> |
||||
<div className="flex aspect-video flex-col justify-end rounded bg-zinc-800"> |
||||
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]"> |
||||
<CaptionCue |
||||
scale={0.5} |
||||
text={selectedCaptionLanguage.nativeName} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="float-right mt-1 text-sm">v{conf().APP_VERSION}</div> |
||||
</ModalCard> |
||||
</Modal> |
||||
); |
||||
} |
@ -1,87 +0,0 @@
@@ -1,87 +0,0 @@
|
||||
import { ReactNode } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useGoBack } from "@/hooks/useGoBack"; |
||||
|
||||
export function NotFoundWrapper(props: { |
||||
children?: ReactNode; |
||||
video?: boolean; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
const goBack = useGoBack(); |
||||
|
||||
return ( |
||||
<div className="relative flex flex-1 flex-col"> |
||||
<Helmet> |
||||
<title>{t("notFound.genericTitle")}</title> |
||||
</Helmet> |
||||
{props.video ? ( |
||||
<div className="absolute inset-x-0 top-0 px-8 py-6"> |
||||
<VideoPlayerHeader onClick={goBack} /> |
||||
</div> |
||||
) : ( |
||||
<Navigation /> |
||||
)} |
||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundMedia() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.media.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.media.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundProvider() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.provider.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm"> |
||||
{t("notFound.provider.description")} |
||||
</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundPage() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<NotFoundWrapper> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.page.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.page.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</NotFoundWrapper> |
||||
); |
||||
} |
@ -1,107 +0,0 @@
@@ -1,107 +0,0 @@
|
||||
import pako from "pako"; |
||||
import { useEffect, useState } from "react"; |
||||
|
||||
import { MWMediaType } from "@/backend/metadata/types/mw"; |
||||
import { conf } from "@/setup/config"; |
||||
|
||||
function fromBinary(str: string): Uint8Array { |
||||
const result = new Uint8Array(str.length); |
||||
[...str].forEach((char, i) => { |
||||
result[i] = char.charCodeAt(0); |
||||
}); |
||||
return result; |
||||
} |
||||
|
||||
export function importV2Data({ data, time }: { data: any; time: Date }) { |
||||
const savedTime = localStorage.getItem("mw-migration-date"); |
||||
if (savedTime) { |
||||
if (new Date(savedTime) >= time) { |
||||
// has already migrated this or something newer, skip
|
||||
return false; |
||||
} |
||||
} |
||||
|
||||
// restore migration data
|
||||
if (data.bookmarks) |
||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks)); |
||||
if (data.videoProgress) |
||||
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress)); |
||||
|
||||
localStorage.setItem("mw-migration-date", time.toISOString()); |
||||
|
||||
return true; |
||||
} |
||||
|
||||
export function EmbedMigration() { |
||||
let hasReceivedMigrationData = false; |
||||
|
||||
const onMessage = (e: any) => { |
||||
const data = e.data; |
||||
if (data && data.isMigrationData && !hasReceivedMigrationData) { |
||||
hasReceivedMigrationData = true; |
||||
const didImport = importV2Data({ |
||||
data: data.data, |
||||
time: data.date, |
||||
}); |
||||
if (didImport) window.location.reload(); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
window.addEventListener("message", onMessage); |
||||
|
||||
return () => { |
||||
window.removeEventListener("message", onMessage); |
||||
}; |
||||
}); |
||||
|
||||
return <iframe src="https://movie.squeezebox.dev" hidden />; |
||||
} |
||||
|
||||
export function V2MigrationView() { |
||||
const [done, setDone] = useState(false); |
||||
useEffect(() => { |
||||
const params = new URLSearchParams(window.location.search ?? ""); |
||||
if (!params.has("m-time") || !params.has("m-data")) { |
||||
// migration params missing, just redirect
|
||||
setDone(true); |
||||
return; |
||||
} |
||||
|
||||
const data = JSON.parse( |
||||
pako.inflate(fromBinary(atob(params.get("m-data") as string)), { |
||||
to: "string", |
||||
}) |
||||
); |
||||
const timeOfMigration = new Date(params.get("m-time") as string); |
||||
|
||||
importV2Data({ |
||||
data, |
||||
time: timeOfMigration, |
||||
}); |
||||
|
||||
// finished
|
||||
setDone(true); |
||||
}, []); |
||||
|
||||
// redirect when done
|
||||
useEffect(() => { |
||||
if (!done) return; |
||||
const newUrl = new URL(window.location.href); |
||||
|
||||
const newParams = [] as string[]; |
||||
newUrl.searchParams.forEach((_, key) => newParams.push(key)); |
||||
newParams.forEach((v) => newUrl.searchParams.delete(v)); |
||||
newUrl.searchParams.append("migrated", "1"); |
||||
|
||||
// hash router compatibility
|
||||
newUrl.hash = conf().NORMAL_ROUTER ? "" : `/search/${MWMediaType.MOVIE}`; |
||||
newUrl.pathname = conf().NORMAL_ROUTER |
||||
? `/search/${MWMediaType.MOVIE}` |
||||
: ""; |
||||
|
||||
window.location.href = newUrl.toString(); |
||||
}, [done]); |
||||
|
||||
return null; |
||||
} |
@ -1,208 +0,0 @@
@@ -1,208 +0,0 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||
import { useCallback, useEffect, useMemo, useState } from "react"; |
||||
import { Trans, useTranslation } from "react-i18next"; |
||||
import { useHistory } from "react-router-dom"; |
||||
|
||||
import { Button } from "@/components/Button"; |
||||
import { EditButton } from "@/components/buttons/EditButton"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Modal, ModalCard } from "@/components/layout/Modal"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
|
||||
import { EmbedMigration } from "../other/v2Migration"; |
||||
|
||||
function Bookmarks() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext(); |
||||
const bookmarks = getFilteredBookmarks(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
const { watched } = useWatchedContext(); |
||||
|
||||
const bookmarksSorted = useMemo(() => { |
||||
return bookmarks |
||||
.map((v) => { |
||||
return { |
||||
...v, |
||||
watched: watched.items |
||||
.sort((a, b) => b.watchedAt - a.watchedAt) |
||||
.find((watchedItem) => watchedItem.item.meta.id === v.id), |
||||
}; |
||||
}) |
||||
.sort( |
||||
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0) |
||||
); |
||||
}, [watched.items, bookmarks]); |
||||
|
||||
if (bookmarks.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.bookmarks") || "Bookmarks"} |
||||
icon={Icons.BOOKMARK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{bookmarksSorted.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.id} |
||||
media={v} |
||||
closable={editing} |
||||
onClose={() => setItemBookmark(v, false)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function Watched() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const { getFilteredWatched, removeProgress } = useWatchedContext(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
|
||||
const bookmarks = getFilteredBookmarks(); |
||||
const watchedItems = getFilteredWatched().filter( |
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta) |
||||
); |
||||
|
||||
if (watchedItems.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.continueWatching") || "Continue Watching"} |
||||
icon={Icons.CLOCK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{watchedItems.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.item.meta.id} |
||||
media={v.item.meta} |
||||
closable={editing} |
||||
onClose={() => removeProgress(v.item.meta.id)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function NewDomainModal() { |
||||
const [show, setShow] = useState( |
||||
new URLSearchParams(window.location.search).get("migrated") === "1" || |
||||
localStorage.getItem("mw-show-domain-modal") === "true" |
||||
); |
||||
const [loaded, setLoaded] = useState(false); |
||||
const history = useHistory(); |
||||
const { t } = useTranslation(); |
||||
|
||||
const closeModal = useCallback(() => { |
||||
localStorage.setItem("mw-show-domain-modal", "false"); |
||||
setShow(false); |
||||
}, []); |
||||
|
||||
useEffect(() => { |
||||
const newParams = new URLSearchParams(history.location.search); |
||||
newParams.delete("migrated"); |
||||
if (newParams.get("migrated") === "1") |
||||
localStorage.setItem("mw-show-domain-modal", "true"); |
||||
history.replace({ |
||||
search: newParams.toString(), |
||||
}); |
||||
}, [history]); |
||||
|
||||
useEffect(() => { |
||||
setTimeout(() => { |
||||
setLoaded(true); |
||||
}, 500); |
||||
}, []); |
||||
|
||||
// If you see this bit of code, don't snitch!
|
||||
// We need to urge users to update their bookmarks and usage,
|
||||
// so we're putting a fake deadline that's only 2 weeks away.
|
||||
const day = 1e3 * 60 * 60 * 24; |
||||
const months = [ |
||||
"January", |
||||
"February", |
||||
"March", |
||||
"April", |
||||
"May", |
||||
"June", |
||||
"July", |
||||
"August", |
||||
"September", |
||||
"October", |
||||
"November", |
||||
"December", |
||||
]; |
||||
const firstVisitToSite = new Date( |
||||
localStorage.getItem("firstVisitToSite") || Date.now() |
||||
); |
||||
localStorage.setItem("firstVisitToSite", firstVisitToSite.toISOString()); |
||||
const fakeEndResult = new Date(firstVisitToSite.getTime() + 14 * day); |
||||
const endDateString = `${fakeEndResult.getDate()} ${ |
||||
months[fakeEndResult.getMonth()] |
||||
} ${fakeEndResult.getFullYear()}`;
|
||||
|
||||
return ( |
||||
<Modal show={show && loaded}> |
||||
<ModalCard> |
||||
<div className="mb-12"> |
||||
<div |
||||
className="absolute left-0 top-0 h-[300px] w-full -translate-y-1/2 opacity-50" |
||||
style={{ |
||||
backgroundImage: `radial-gradient(ellipse 70% 9rem, #7831C1 0%, transparent 100%)`, |
||||
}} |
||||
/> |
||||
<div className="relative flex items-center justify-center"> |
||||
<div className="rounded-full bg-bink-200 px-12 py-4 text-center text-sm font-bold text-white md:text-xl"> |
||||
{t("v3.newDomain")} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="space-y-6"> |
||||
<h2 className="text-2xl font-bold text-white"> |
||||
{t("v3.newSiteTitle")} |
||||
</h2> |
||||
<p className="leading-7"> |
||||
<Trans i18nKey="v3.newDomainText" values={{ date: endDateString }}> |
||||
<span className="text-slate-300" /> |
||||
<span className="font-bold text-white" /> |
||||
</Trans> |
||||
</p> |
||||
<p>{t("v3.tireless")}</p> |
||||
</div> |
||||
<div className="mb-6 mt-16 flex items-center justify-center"> |
||||
<Button icon={Icons.PLAY} onClick={() => closeModal()}> |
||||
{t("v3.leaveAnnouncement")} |
||||
</Button> |
||||
</div> |
||||
</ModalCard> |
||||
</Modal> |
||||
); |
||||
} |
||||
|
||||
export function HomeView() { |
||||
return ( |
||||
<div className="mb-16"> |
||||
<EmbedMigration /> |
||||
<NewDomainModal /> |
||||
<Bookmarks /> |
||||
<Watched /> |
||||
</div> |
||||
); |
||||
} |
@ -1,34 +0,0 @@
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"; |
||||
|
||||
import { MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
|
||||
import { HomeView } from "./HomeView"; |
||||
import { SearchLoadingView } from "./SearchLoadingView"; |
||||
import { SearchResultsView } from "./SearchResultsView"; |
||||
|
||||
interface SearchResultsPartialProps { |
||||
search: MWQuery; |
||||
} |
||||
|
||||
export function SearchResultsPartial({ search }: SearchResultsPartialProps) { |
||||
const [searching, setSearching] = useState<boolean>(false); |
||||
const [loading, setLoading] = useState<boolean>(false); |
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 500); |
||||
useEffect(() => { |
||||
setSearching(search.searchQuery !== ""); |
||||
setLoading(search.searchQuery !== ""); |
||||
}, [search]); |
||||
useEffect(() => { |
||||
setLoading(false); |
||||
}, [debouncedSearch]); |
||||
|
||||
const resultView = useMemo(() => { |
||||
if (loading) return <SearchLoadingView />; |
||||
if (searching) return <SearchResultsView searchQuery={debouncedSearch} />; |
||||
return <HomeView />; |
||||
}, [loading, searching, debouncedSearch]); |
||||
|
||||
return resultView; |
||||
} |
@ -1,66 +0,0 @@
@@ -1,66 +0,0 @@
|
||||
import { useCallback, useState } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import Sticky from "react-stickynode"; |
||||
|
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { WideContainer } from "@/components/layout/WideContainer"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useBannerSize } from "@/hooks/useBanner"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial"; |
||||
|
||||
export function SearchView() { |
||||
const { t } = useTranslation(); |
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery(); |
||||
const [showBg, setShowBg] = useState(false); |
||||
const bannerSize = useBannerSize(); |
||||
|
||||
const stickStateChanged = useCallback( |
||||
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED), |
||||
[setShowBg] |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<div className="relative z-10 mb-16 sm:mb-24"> |
||||
<Helmet> |
||||
<title>{t("global.name")}</title> |
||||
</Helmet> |
||||
<Navigation bg={showBg} /> |
||||
<ThinContainer> |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="absolute bottom-0 left-0 right-0 flex h-0 justify-center"> |
||||
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" /> |
||||
</div> |
||||
<div className="relative z-10 mb-16"> |
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title> |
||||
</div> |
||||
<div className="relative z-30"> |
||||
<Sticky |
||||
enabled |
||||
top={16 + bannerSize} |
||||
onStateChange={stickStateChanged} |
||||
> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={ |
||||
t("search.placeholder") || "What do you want to watch?" |
||||
} |
||||
/> |
||||
</Sticky> |
||||
</div> |
||||
</div> |
||||
</ThinContainer> |
||||
</div> |
||||
<WideContainer> |
||||
<SearchResultsPartial search={search} /> |
||||
</WideContainer> |
||||
</> |
||||
); |
||||
} |
Loading…
Reference in new issue