diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d0f0ca6f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,3 @@ +* @movie-web/core + +.github @binaryoverload diff --git a/package.json b/package.json index ad03942a..cdceb49e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@sentry/integrations": "^7.49.0", "@sentry/react": "^7.49.0", "@use-gesture/react": "^10.2.24", + "classnames": "^2.3.2", "core-js": "^3.29.1", "crypto-js": "^4.1.1", "dompurify": "^3.0.1", @@ -97,6 +98,7 @@ "prettier-plugin-tailwindcss": "^0.1.7", "tailwind-scrollbar": "^2.0.1", "tailwindcss": "^3.2.4", + "tailwindcss-themer": "^3.1.0", "typescript": "^4.6.4", "vite": "^4.0.1", "vite-plugin-checker": "^0.5.6", diff --git a/public/fishie.png b/public/fishie.png new file mode 100644 index 00000000..8c528ba4 Binary files /dev/null and b/public/fishie.png differ diff --git a/src/backend/embeds/upcloud.ts b/src/backend/embeds/upcloud.ts index b2877bb3..4bac2b94 100644 --- a/src/backend/embeds/upcloud.ts +++ b/src/backend/embeds/upcloud.ts @@ -51,27 +51,35 @@ registerEmbedScraper({ } ); - let sources: - | { - file: string; - type: string; - } - | string = streamRes.sources; + let sources: { file: string; type: string } | null = null; - if (!isJSON(sources) || typeof sources === "string") { - const decryptionKey = await proxiedFetch( - `https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt` - ); + if (!isJSON(streamRes.sources)) { + const decryptionKey = JSON.parse( + await proxiedFetch( + `https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt` + ) + ) as [number, number][]; - const decryptedStream = AES.decrypt(sources, decryptionKey).toString( - enc.Utf8 - ); + let extractedKey = ""; + const sourcesArray = streamRes.sources.split(""); + for (const index of decryptionKey) { + for (let i: number = index[0]; i < index[1]; i += 1) { + extractedKey += streamRes.sources[i]; + sourcesArray[i] = ""; + } + } + const decryptedStream = AES.decrypt( + sourcesArray.join(""), + extractedKey + ).toString(enc.Utf8); const parsedStream = JSON.parse(decryptedStream)[0]; if (!parsedStream) throw new Error("No stream found"); - sources = parsedStream as { file: string; type: string }; + sources = parsedStream; } + if (!sources) throw new Error("upcloud source not found"); + return { embedId: MWEmbedType.UPCLOUD, streamUrl: sources.file, diff --git a/src/backend/providers/flixhq.ts b/src/backend/providers/flixhq.ts deleted file mode 100644 index fd905019..00000000 --- a/src/backend/providers/flixhq.ts +++ /dev/null @@ -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 = { - "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( - `/${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(`/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(`/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), - }, - }; - }, -}); diff --git a/src/backend/providers/flixhq/common.ts b/src/backend/providers/flixhq/common.ts new file mode 100644 index 00000000..a4e6b639 --- /dev/null +++ b/src/backend/providers/flixhq/common.ts @@ -0,0 +1 @@ +export const flixHqBase = "https://flixhq.to"; diff --git a/src/backend/providers/flixhq/index.ts b/src/backend/providers/flixhq/index.ts new file mode 100644 index 00000000..a30e6772 --- /dev/null +++ b/src/backend/providers/flixhq/index.ts @@ -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), + }, + ], + }; + }, +}); diff --git a/src/backend/providers/flixhq/scrape.ts b/src/backend/providers/flixhq/scrape.ts new file mode 100644 index 00000000..3ca32732 --- /dev/null +++ b/src/backend/providers/flixhq/scrape.ts @@ -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( + `/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 { + const jsonData = await proxiedFetch>( + `/ajax/sources/${sourceId}`, + { + baseURL: flixHqBase, + } + ); + + return jsonData.link; +} diff --git a/src/backend/providers/flixhq/search.ts b/src/backend/providers/flixhq/search.ts new file mode 100644 index 00000000..64db2407 --- /dev/null +++ b/src/backend/providers/flixhq/search.ts @@ -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 { + const searchResults = await proxiedFetch( + `/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; +} diff --git a/src/backend/providers/hdwatched.ts b/src/backend/providers/hdwatched.ts index 458c3424..533f711d 100644 --- a/src/backend/providers/hdwatched.ts +++ b/src/backend/providers/hdwatched.ts @@ -120,6 +120,7 @@ registerProvider({ id: "hdwatched", displayName: "HDwatched", rank: 150, + disabled: true, // very slow, haven't seen it work for a while type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape(options) { const { media, progress } = options; diff --git a/src/backend/providers/sflix.ts b/src/backend/providers/sflix.ts index 2cb1c598..db331e3c 100644 --- a/src/backend/providers/sflix.ts +++ b/src/backend/providers/sflix.ts @@ -9,6 +9,7 @@ registerProvider({ id: "sflix", displayName: "Sflix", rank: 50, + disabled: true, // domain dead type: [MWMediaType.MOVIE, MWMediaType.SERIES], async scrape({ media, episode, progress }) { let searchQuery = `${media.meta.title} `; diff --git a/src/backend/providers/superstream/index.ts b/src/backend/providers/superstream/index.ts index 5af85cb9..883d1ad5 100644 --- a/src/backend/providers/superstream/index.ts +++ b/src/backend/providers/superstream/index.ts @@ -18,6 +18,12 @@ import { compareTitle } from "@/utils/titleMatch"; const nanoid = customAlphabet("0123456789abcdef", 32); +function makeFasterUrl(url: string) { + const fasterUrl = new URL(url); + fasterUrl.host = "mp4.shegu.net"; // this domain is faster + return fasterUrl.toString(); +} + const qualityMap = { "360p": MWStreamQuality.Q360P, "480p": MWStreamQuality.Q480P, @@ -199,7 +205,7 @@ registerProvider({ return { embeds: [], stream: { - streamUrl: hdQuality.path, + streamUrl: makeFasterUrl(hdQuality.path), quality: qualityMap[hdQuality.quality as QualityInMap], type: MWStreamType.MP4, captions: mappedCaptions, @@ -248,13 +254,14 @@ registerProvider({ const mappedCaptions = subtitleRes.list .map(convertSubtitles) .filter(Boolean); + return { embeds: [], stream: { quality: qualityMap[ hdQuality.quality as QualityInMap ] as MWStreamQuality, - streamUrl: hdQuality.path, + streamUrl: makeFasterUrl(hdQuality.path), type: MWStreamType.MP4, captions: mappedCaptions, }, diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 1d2ce354..28048e16 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,8 @@ +import c from "classnames"; +import { useState } from "react"; + import { MWQuery } from "@/backend/metadata/types/mw"; +import { Flare } from "@/components/utils/Flare"; import { Icon, Icons } from "./Icon"; import { TextInputControl } from "./text-inputs/TextInputControl"; @@ -11,6 +15,8 @@ export interface SearchBarProps { } export function SearchBarInput(props: SearchBarProps) { + const [focused, setFocused] = useState(false); + function setSearch(value: string) { props.onChange( { @@ -22,18 +28,42 @@ export function SearchBarInput(props: SearchBarProps) { } return ( -
-
- -
- - setSearch(val)} - value={props.value.searchQuery} - className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2" - placeholder={props.placeholder} + + -
+ + +
+ +
+ + { + setFocused(false); + props.onUnFocus(); + }} + onFocus={() => setFocused(true)} + onChange={(val) => setSearch(val)} + value={props.value.searchQuery} + className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-search-text placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2" + placeholder={props.placeholder} + /> +
+ ); } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 00000000..910e337e --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -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 ( + + + {props.children} + + ); +} + +function Dmca() { + const { t } = useTranslation(); + return ( + + {t("footer.links.dmca")} + + ); +} + +export function Footer() { + const { t } = useTranslation(); + + return ( +
+ +
+
+ +
+

{t("footer.tagline")}

+
+
+

+ {t("footer.legal.disclaimer")} +

+

{t("footer.legal.disclaimerText")}

+
+
+ + {t("footer.links.github")} + + + {t("footer.links.discord")} + +
+ +
+
+
+ +
+
+
+ ); +} + +export function FooterView(props: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+
{props.children}
+
+
+ ); +} diff --git a/src/components/layout/Navigation.tsx b/src/components/layout/Navigation.tsx index a9a3e0c1..7138ed35 100644 --- a/src/components/layout/Navigation.tsx +++ b/src/components/layout/Navigation.tsx @@ -1,11 +1,11 @@ -import { ReactNode, useState } from "react"; +import { ReactNode } from "react"; import { Link } from "react-router-dom"; import { IconPatch } from "@/components/buttons/IconPatch"; import { Icons } from "@/components/Icon"; +import { Lightbar } from "@/components/utils/Lightbar"; import { useBannerSize } from "@/hooks/useBanner"; import { conf } from "@/setup/config"; -import SettingsModal from "@/views/SettingsModal"; import { BrandPill } from "./BrandPill"; @@ -16,62 +16,59 @@ export interface NavigationProps { export function Navigation(props: NavigationProps) { const bannerHeight = useBannerSize(); - const [showModal, setShowModal] = useState(false); return ( -
-
-
-
+ <> +
+
+
-
-
- - - -
- {props.children} -
-
- { - setShowModal(true); - }} - /> - + - setShowModal(false)} /> -
+ ); } diff --git a/src/components/layout/WideContainer.tsx b/src/components/layout/WideContainer.tsx index f7d745fe..bcccd5e5 100644 --- a/src/components/layout/WideContainer.tsx +++ b/src/components/layout/WideContainer.tsx @@ -3,14 +3,15 @@ import { ReactNode } from "react"; interface WideContainerProps { classNames?: string; children?: ReactNode; + ultraWide?: boolean; } export function WideContainer(props: WideContainerProps) { return (
{props.children}
diff --git a/src/components/media/MediaCard.tsx b/src/components/media/MediaCard.tsx index 828f6bd7..608458a8 100644 --- a/src/components/media/MediaCard.tsx +++ b/src/components/media/MediaCard.tsx @@ -1,9 +1,11 @@ +import c from "classnames"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { TMDBMediaToId } from "@/backend/metadata/tmdb"; import { MWMediaMeta } from "@/backend/metadata/types/mw"; import { DotList } from "@/components/text/DotList"; +import { Flare } from "@/components/utils/Flare"; import { IconPatch } from "../buttons/IconPatch"; import { Icons } from "../Icon"; @@ -39,19 +41,27 @@ function MediaCardContent({ if (media.year) dotListContent.push(media.year); return ( -
-
+

@@ -82,19 +91,19 @@ function MediaCardContent({ {percentage !== undefined ? ( <>

-
+
closable && onClose?.()} icon={Icons.X} /> @@ -121,8 +130,8 @@ function MediaCardContent({ {media.title} -
-
+ + ); } diff --git a/src/components/media/MediaGrid.tsx b/src/components/media/MediaGrid.tsx index a9f75b22..17bd4c7a 100644 --- a/src/components/media/MediaGrid.tsx +++ b/src/components/media/MediaGrid.tsx @@ -7,7 +7,10 @@ interface MediaGridProps { export const MediaGrid = forwardRef( (props, ref) => { return ( -
+
{props.children}
); diff --git a/src/components/text-inputs/TextInputControl.tsx b/src/components/text-inputs/TextInputControl.tsx index a6d18994..c3b42616 100644 --- a/src/components/text-inputs/TextInputControl.tsx +++ b/src/components/text-inputs/TextInputControl.tsx @@ -1,6 +1,7 @@ export interface TextInputControlPropsNoLabel { onChange?: (data: string) => void; onUnFocus?: () => void; + onFocus?: () => void; value?: string; placeholder?: string; className?: string; @@ -17,6 +18,7 @@ export function TextInputControl({ label, className, placeholder, + onFocus, }: TextInputControlProps) { const input = ( onChange && onChange(e.target.value)} value={value} onBlur={() => onUnFocus && onUnFocus()} + onFocus={() => onFocus?.()} /> ); diff --git a/src/components/utils/Flare.css b/src/components/utils/Flare.css new file mode 100644 index 00000000..1c17cda8 --- /dev/null +++ b/src/components/utils/Flare.css @@ -0,0 +1,7 @@ +.flare-enabled .flare-light { + opacity: 1 !important; +} + +.hover\:flare-enabled:hover .flare-light { + opacity: 1 !important; +} diff --git a/src/components/utils/Flare.tsx b/src/components/utils/Flare.tsx new file mode 100644 index 00000000..267f4c85 --- /dev/null +++ b/src/components/utils/Flare.tsx @@ -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
{props.children}
; +} + +function Child(props: { className?: string; children?: ReactNode }) { + return
{props.children}
; +} + +function Light(props: FlareProps) { + const outerRef = useRef(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 ( +
+
+
+
+
+ ); +} + +export const Flare = { + Base, + Light, + Child, +}; diff --git a/src/components/utils/Lightbar.css b/src/components/utils/Lightbar.css new file mode 100644 index 00000000..0850c1f0 --- /dev/null +++ b/src/components/utils/Lightbar.css @@ -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); + } +} \ No newline at end of file diff --git a/src/components/utils/Lightbar.tsx b/src/components/utils/Lightbar.tsx new file mode 100644 index 00000000..a01b725e --- /dev/null +++ b/src/components/utils/Lightbar.tsx @@ -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(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 | 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 ; +} + +export function Lightbar(props: { className?: string }) { + return ( +
+
+ +
+
+
+ ); +} diff --git a/src/views/developer/DeveloperView.tsx b/src/pages/DeveloperPage.tsx similarity index 95% rename from src/views/developer/DeveloperView.tsx rename to src/pages/DeveloperPage.tsx index 6168758b..1927ccc8 100644 --- a/src/views/developer/DeveloperView.tsx +++ b/src/pages/DeveloperPage.tsx @@ -3,7 +3,7 @@ import { ThinContainer } from "@/components/layout/ThinContainer"; import { ArrowLink } from "@/components/text/ArrowLink"; import { Title } from "@/components/text/Title"; -export default function DeveloperView() { +export default function DeveloperPage() { return (
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 00000000..a2b54afd --- /dev/null +++ b/src/pages/HomePage.tsx @@ -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(false); + const [loading, setLoading] = useState(false); + + const debouncedSearch = useDebounce(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(false); + const searchParams = useSearchQuery(); + const [search] = searchParams; + const s = useSearch(search); + + return ( + +
+ + {t("global.name")} + + +
+ + {s.loading ? ( + + ) : s.searching ? ( + + ) : ( + <> + + + + )} + +
+ ); +} diff --git a/src/views/PlayerView.tsx b/src/pages/PlayerView.tsx similarity index 100% rename from src/views/PlayerView.tsx rename to src/pages/PlayerView.tsx diff --git a/src/views/search/SearchResultsView.tsx b/src/pages/SearchPage.tsx similarity index 95% rename from src/views/search/SearchResultsView.tsx rename to src/pages/SearchPage.tsx index f6507ef1..94b3b40e 100644 --- a/src/views/search/SearchResultsView.tsx +++ b/src/pages/SearchPage.tsx @@ -9,8 +9,7 @@ import { SectionHeading } from "@/components/layout/SectionHeading"; import { MediaGrid } from "@/components/media/MediaGrid"; import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; import { useLoading } from "@/hooks/useLoading"; - -import { SearchLoadingView } from "./SearchLoadingView"; +import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart"; function SearchSuffix(props: { failed?: boolean; results?: number }) { const { t } = useTranslation(); @@ -63,7 +62,7 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { if (searchQuery.searchQuery !== "") runSearch(searchQuery); }, [searchQuery, runSearchQuery]); - if (loading) return ; + if (loading) return ; if (error) return ; if (!results) return null; diff --git a/src/views/developer/EmbedTesterView.tsx b/src/pages/developer/EmbedTesterView.tsx similarity index 100% rename from src/views/developer/EmbedTesterView.tsx rename to src/pages/developer/EmbedTesterView.tsx diff --git a/src/views/developer/ProviderTesterView.tsx b/src/pages/developer/ProviderTesterView.tsx similarity index 100% rename from src/views/developer/ProviderTesterView.tsx rename to src/pages/developer/ProviderTesterView.tsx diff --git a/src/views/developer/TestView.tsx b/src/pages/developer/TestView.tsx similarity index 100% rename from src/views/developer/TestView.tsx rename to src/pages/developer/TestView.tsx diff --git a/src/views/developer/VideoTesterView.tsx b/src/pages/developer/VideoTesterView.tsx similarity index 100% rename from src/views/developer/VideoTesterView.tsx rename to src/pages/developer/VideoTesterView.tsx diff --git a/src/pages/errors/NotFoundPage.tsx b/src/pages/errors/NotFoundPage.tsx new file mode 100644 index 00000000..a944e05f --- /dev/null +++ b/src/pages/errors/NotFoundPage.tsx @@ -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 ( + + + {t("notFound.page.title")} +

{t("notFound.page.description")}

+ +
+ ); +} diff --git a/src/pages/layouts/HomeLayout.tsx b/src/pages/layouts/HomeLayout.tsx new file mode 100644 index 00000000..9b50c9c1 --- /dev/null +++ b/src/pages/layouts/HomeLayout.tsx @@ -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 ( + + + {props.children} + + ); +} diff --git a/src/pages/layouts/PageLayout.tsx b/src/pages/layouts/PageLayout.tsx new file mode 100644 index 00000000..01c2a017 --- /dev/null +++ b/src/pages/layouts/PageLayout.tsx @@ -0,0 +1,11 @@ +import { FooterView } from "@/components/layout/Footer"; +import { Navigation } from "@/components/layout/Navigation"; + +export function PageLayout(props: { children: React.ReactNode }) { + return ( + + + {props.children} + + ); +} diff --git a/src/views/media/MediaErrorView.tsx b/src/pages/media/MediaErrorView.tsx similarity index 100% rename from src/views/media/MediaErrorView.tsx rename to src/pages/media/MediaErrorView.tsx diff --git a/src/views/media/MediaScrapeLog.tsx b/src/pages/media/MediaScrapeLog.tsx similarity index 100% rename from src/views/media/MediaScrapeLog.tsx rename to src/pages/media/MediaScrapeLog.tsx diff --git a/src/views/media/MediaView.tsx b/src/pages/media/MediaView.tsx similarity index 97% rename from src/views/media/MediaView.tsx rename to src/pages/media/MediaView.tsx index 98fac4c4..763ddc56 100644 --- a/src/views/media/MediaView.tsx +++ b/src/pages/media/MediaView.tsx @@ -23,11 +23,12 @@ import { Loading } from "@/components/layout/Loading"; import { useGoBack } from "@/hooks/useGoBack"; import { useLoading } from "@/hooks/useLoading"; import { SelectedMediaData, useScrape } from "@/hooks/useScrape"; +import { ErrorWrapperPart } from "@/pages/parts/errors/ErrorWrapperPart"; +import { MediaNotFoundPart } from "@/pages/parts/errors/MediaNotFoundPart"; import { useWatchedItem } from "@/state/watched"; import { MediaFetchErrorView } from "./MediaErrorView"; import { MediaScrapeLog } from "./MediaScrapeLog"; -import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView"; function MediaViewLoading(props: { onGoBack(): void }) { const { t } = useTranslation(); @@ -241,9 +242,9 @@ export function MediaView() { if (error) return ; if (!meta || !selected) return ( - - - + + + ); // scraping view will start scraping and return with onStream diff --git a/src/pages/parts/errors/ErrorWrapperPart.tsx b/src/pages/parts/errors/ErrorWrapperPart.tsx new file mode 100644 index 00000000..2272cc9f --- /dev/null +++ b/src/pages/parts/errors/ErrorWrapperPart.tsx @@ -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 ( +
+ + {t("notFound.genericTitle")} + + {props.video ? ( +
+ +
+ ) : ( + + )} +
+ {props.children} +
+
+ ); +} diff --git a/src/pages/parts/errors/MediaNotFoundPart.tsx b/src/pages/parts/errors/MediaNotFoundPart.tsx new file mode 100644 index 00000000..9fcf24e6 --- /dev/null +++ b/src/pages/parts/errors/MediaNotFoundPart.tsx @@ -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 ( +
+ + {t("notFound.media.title")} +

{t("notFound.media.description")}

+ +
+ ); +} diff --git a/src/pages/parts/errors/ProviderNotFoundPart.tsx b/src/pages/parts/errors/ProviderNotFoundPart.tsx new file mode 100644 index 00000000..1cc58794 --- /dev/null +++ b/src/pages/parts/errors/ProviderNotFoundPart.tsx @@ -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 ( +
+ + {t("notFound.provider.title")} +

+ {t("notFound.provider.description")} +

+ +
+ ); +} diff --git a/src/pages/parts/home/BookmarksPart.tsx b/src/pages/parts/home/BookmarksPart.tsx new file mode 100644 index 00000000..7c8dfeb6 --- /dev/null +++ b/src/pages/parts/home/BookmarksPart.tsx @@ -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(); + 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 ( +
+ + + + + {bookmarksSorted.map((v) => ( + setItemBookmark(v, false)} + /> + ))} + +
+ ); +} diff --git a/src/pages/parts/home/HeroPart.tsx b/src/pages/parts/home/HeroPart.tsx new file mode 100644 index 00000000..6e924957 --- /dev/null +++ b/src/pages/parts/home/HeroPart.tsx @@ -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; +} + +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 ( + +
+
+ {t("search.title")} +
+
+ + + +
+
+
+ ); +} diff --git a/src/pages/parts/home/WatchingPart.tsx b/src/pages/parts/home/WatchingPart.tsx new file mode 100644 index 00000000..be222398 --- /dev/null +++ b/src/pages/parts/home/WatchingPart.tsx @@ -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(); + + const bookmarks = getFilteredBookmarks(); + const watchedItems = getFilteredWatched().filter( + (v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta) + ); + + if (watchedItems.length === 0) return null; + + return ( +
+ + + + + {watchedItems.map((v) => ( + removeProgress(v.item.meta.id)} + /> + ))} + +
+ ); +} diff --git a/src/pages/parts/search/SearchListPart.tsx b/src/pages/parts/search/SearchListPart.tsx new file mode 100644 index 00000000..9d39805c --- /dev/null +++ b/src/pages/parts/search/SearchListPart.tsx @@ -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 ( +
+ + + {/* standard suffix */} + {!props.failed ? ( +
+ {(props.results ?? 0) > 0 ? ( +

{t("search.allResults")}

+ ) : ( +

{t("search.noResults")}

+ )} +
+ ) : null} + + {/* Error result */} + {props.failed ? ( +
+

{t("search.allFailed")}

+
+ ) : null} +
+ ); +} + +export function SearchListPart({ searchQuery }: { searchQuery: MWQuery }) { + const { t } = useTranslation(); + + const [results, setResults] = useState([]); + 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 ; + if (error) return ; + if (!results) return null; + + return ( +
+ {results.length > 0 ? ( +
+ + + {results.map((v) => ( + + ))} + +
+ ) : null} + + +
+ ); +} diff --git a/src/views/search/SearchLoadingView.tsx b/src/pages/parts/search/SearchLoadingPart.tsx similarity index 86% rename from src/views/search/SearchLoadingView.tsx rename to src/pages/parts/search/SearchLoadingPart.tsx index 0c59f5d9..bce56db1 100644 --- a/src/views/search/SearchLoadingView.tsx +++ b/src/pages/parts/search/SearchLoadingPart.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { Loading } from "@/components/layout/Loading"; -export function SearchLoadingView() { +export function SearchLoadingPart() { const { t } = useTranslation(); return ( diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 6bb6b957..318ca4ac 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -11,14 +11,13 @@ import { import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; import { BannerContextProvider } from "@/hooks/useBanner"; +import { NotFoundPage } from "@/pages/errors/NotFoundPage"; +import { HomePage } from "@/pages/HomePage"; +import { MediaView } from "@/pages/media/MediaView"; import { Layout } from "@/setup/Layout"; import { BookmarkContextProvider } from "@/state/bookmark"; import { SettingsProvider } from "@/state/settings"; import { WatchedContextProvider } from "@/state/watched"; -import { MediaView } from "@/views/media/MediaView"; -import { NotFoundPage } from "@/views/notfound/NotFoundView"; -import { V2MigrationView } from "@/views/other/v2Migration"; -import { SearchView } from "@/views/search/SearchView"; function LegacyUrlView({ children }: { children: ReactElement }) { const location = useLocation(); @@ -62,7 +61,6 @@ function App() { {/* functional routes */} - @@ -87,22 +85,20 @@ function App() { {/* other */} import("@/views/developer/DeveloperView") - )} + component={lazy(() => import("@/pages/DeveloperPage"))} /> import("@/views/developer/VideoTesterView") + () => import("@/pages/developer/VideoTesterView") )} /> {/* developer routes that can abuse workers are disabled in production */} @@ -112,7 +108,7 @@ function App() { exact path="/dev/test" component={lazy( - () => import("@/views/developer/TestView") + () => import("@/pages/developer/TestView") )} /> @@ -120,14 +116,14 @@ function App() { exact path="/dev/providers" component={lazy( - () => import("@/views/developer/ProviderTesterView") + () => import("@/pages/developer/ProviderTesterView") )} /> import("@/views/developer/EmbedTesterView") + () => import("@/pages/developer/EmbedTesterView") )} /> diff --git a/src/setup/index.css b/src/setup/index.css index 259aaa61..168ce2ea 100644 --- a/src/setup/index.css +++ b/src/setup/index.css @@ -4,9 +4,10 @@ html, body { - @apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden; + @apply bg-background-main font-open-sans text-denim-700 overflow-x-hidden; min-height: 100vh; min-height: 100dvh; + position: relative; } html[data-full], @@ -198,4 +199,4 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower { ::-webkit-scrollbar { /* For some reason the styles don't get applied without the width */ width: 13px; -} \ No newline at end of file +} diff --git a/src/setup/locales/en/translation.json b/src/setup/locales/en/translation.json index 8f48a9d6..e11e67de 100644 --- a/src/setup/locales/en/translation.json +++ b/src/setup/locales/en/translation.json @@ -10,7 +10,7 @@ "headingTitle": "Search results", "bookmarks": "Bookmarks", "continueWatching": "Continue Watching", - "title": "What do you want to watch?", + "title": "What to watch tonight?", "placeholder": "What do you want to watch?" }, "media": { @@ -131,5 +131,17 @@ }, "errors": { "offline": "Check your internet connection" + }, + "footer": { + "tagline": "Watch your favorite shows and movies with this open source streaming app.", + "links": { + "github": "GitHub", + "dmca": "DMCA", + "discord": "Discord" + }, + "legal": { + "disclaimer": "Disclaimer", + "disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web is not responsible for any media files shown by the video providers." + } } } diff --git a/src/views/SettingsModal.tsx b/src/views/SettingsModal.tsx deleted file mode 100644 index bff4fd24..00000000 --- a/src/views/SettingsModal.tsx +++ /dev/null @@ -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 ( - - -
-
- {t("settings.title")} -
props.onClose()} - className="hover:cursor-pointer" - > - -
-
-
-
-
- - { - i18n.changeLanguage(val.id); - setLanguage(val.id as LangCode); - }} - options={appLanguageOptions} - /> -
-
- - { - setCaptionLanguage(val.id as LangCode); - }} - options={captionLanguages} - /> -
-
- setCaptionFontSize(e.target.valueAsNumber)} - /> - - setCaptionBackgroundColor(e.target.valueAsNumber) - } - /> -
- -
- {colors.map((color) => ( - - ))} -
-
-
-
-
-
-
-
- -
-
-
-
-
-
v{conf().APP_VERSION}
- - - ); -} diff --git a/src/views/notfound/NotFoundView.tsx b/src/views/notfound/NotFoundView.tsx deleted file mode 100644 index 946b0776..00000000 --- a/src/views/notfound/NotFoundView.tsx +++ /dev/null @@ -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 ( -
- - {t("notFound.genericTitle")} - - {props.video ? ( -
- -
- ) : ( - - )} -
- {props.children} -
-
- ); -} - -export function NotFoundMedia() { - const { t } = useTranslation(); - - return ( -
- - {t("notFound.media.title")} -

{t("notFound.media.description")}

- -
- ); -} - -export function NotFoundProvider() { - const { t } = useTranslation(); - - return ( -
- - {t("notFound.provider.title")} -

- {t("notFound.provider.description")} -

- -
- ); -} - -export function NotFoundPage() { - const { t } = useTranslation(); - - return ( - - - {t("notFound.page.title")} -

{t("notFound.page.description")}

- -
- ); -} diff --git a/src/views/other/v2Migration.tsx b/src/views/other/v2Migration.tsx deleted file mode 100644 index d0b05e42..00000000 --- a/src/views/other/v2Migration.tsx +++ /dev/null @@ -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