├── public ├── ping.txt ├── _redirects ├── favicon.ico ├── robots.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── mstile-150x150.png ├── apple-touch-icon.png ├── splash_screens │ ├── icon.png │ ├── 10.2__iPad_landscape.png │ ├── 10.2__iPad_portrait.png │ ├── 10.5__iPad_Air_landscape.png │ ├── 10.5__iPad_Air_portrait.png │ ├── 10.9__iPad_Air_landscape.png │ ├── 10.9__iPad_Air_portrait.png │ ├── 12.9__iPad_Pro_landscape.png │ ├── 12.9__iPad_Pro_portrait.png │ ├── 8.3__iPad_Mini_landscape.png │ ├── 8.3__iPad_Mini_portrait.png │ ├── iPhone_11__iPhone_XR_landscape.png │ ├── iPhone_11__iPhone_XR_portrait.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_portrait.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_landscape.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png │ ├── 4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png │ ├── iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png │ ├── iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png │ ├── iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png │ ├── 9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png │ ├── iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png │ ├── iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png │ └── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── lightbar-images │ ├── fishie.png │ └── santa.png ├── browserconfig.xml ├── _headers ├── config.js ├── safari-pinned-tab.svg └── flags │ └── skull.svg ├── .npmrc ├── .gitattributes ├── plugins ├── .gitignore ├── handlebars.ts └── figmaTokensToThemeTokens.mjs ├── .github ├── CODEOWNERS ├── SECURITY.md ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── feature-request.yml │ └── bug-report.yml └── workflows │ └── linting_testing.yml ├── src ├── components │ ├── player │ │ ├── index.tsx │ │ ├── atoms │ │ │ ├── Title.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ ├── Fullscreen.tsx │ │ │ ├── Airplay.tsx │ │ │ ├── index.ts │ │ │ ├── Pip.tsx │ │ │ ├── EpisodeTitle.tsx │ │ │ ├── Pause.tsx │ │ │ ├── CastingNotification.tsx │ │ │ ├── Skips.tsx │ │ │ ├── AutoPlayStart.tsx │ │ │ ├── Chromecast.tsx │ │ │ └── VolumeChangedPopout.tsx │ │ ├── base │ │ │ ├── CenterControls.tsx │ │ │ ├── BlackOverlay.tsx │ │ │ ├── CenterMobileControls.tsx │ │ │ ├── LeftSideControls.tsx │ │ │ ├── BackLink.tsx │ │ │ ├── BottomControls.tsx │ │ │ └── TopControls.tsx │ │ ├── internals │ │ │ ├── ContextMenu │ │ │ │ ├── index.ts │ │ │ │ ├── Cards.tsx │ │ │ │ ├── Input.tsx │ │ │ │ └── Sections.tsx │ │ │ ├── HeadUpdater.tsx │ │ │ ├── BookmarkButton.tsx │ │ │ ├── Button.tsx │ │ │ └── ProgressSaver.tsx │ │ ├── utils │ │ │ ├── handleBuffered.ts │ │ │ ├── aired.ts │ │ │ ├── videoTracks.ts │ │ │ ├── mediaErrorDetails.ts │ │ │ └── convertRunoutputToSource.ts │ │ ├── Player.tsx │ │ ├── hooks │ │ │ ├── useSlashFocus.ts │ │ │ ├── useVolume.ts │ │ │ ├── useInitializePlayer.ts │ │ │ └── useShouldShowControls.tsx │ │ └── README.md │ ├── text │ │ ├── SecondaryLabel.tsx │ │ ├── Paragraph.tsx │ │ ├── Title.tsx │ │ ├── HeroTitle.tsx │ │ ├── DotList.tsx │ │ ├── Link.tsx │ │ └── ArrowLink.tsx │ ├── utils │ │ ├── Flare.css │ │ ├── Divider.tsx │ │ ├── ErrorLine.tsx │ │ ├── Ol.tsx │ │ ├── Text.tsx │ │ └── Lightbar.css │ ├── layout │ │ ├── Box.tsx │ │ ├── Spinner.tsx │ │ ├── Spinner.css │ │ ├── IconPill.tsx │ │ ├── WideContainer.tsx │ │ ├── Stepper.tsx │ │ ├── SectionHeading.tsx │ │ ├── ThinContainer.tsx │ │ ├── SettingsCard.tsx │ │ ├── Loading.tsx │ │ ├── BrandPill.tsx │ │ ├── ProgressRing.tsx │ │ ├── Sidebar.tsx │ │ └── LargeCard.tsx │ ├── media │ │ ├── MediaGrid.tsx │ │ └── WatchedMediaCard.tsx │ ├── overlays │ │ ├── OverlayAnchor.tsx │ │ ├── Modal.tsx │ │ ├── positions │ │ │ └── OverlayMobilePosition.tsx │ │ └── OverlayPage.tsx │ ├── buttons │ │ ├── Toggle.tsx │ │ ├── EditButton.tsx │ │ └── IconPatch.tsx │ ├── text-inputs │ │ └── AuthInputBox.tsx │ └── form │ │ ├── ColorPicker.tsx │ │ └── IconPicker.tsx ├── stores │ ├── __old │ │ ├── DONT_TOUCH_THIS_FOLDER │ │ ├── imports.ts │ │ ├── bookmark │ │ │ ├── types.ts │ │ │ └── store.ts │ │ ├── utils.ts │ │ ├── watched │ │ │ ├── types.ts │ │ │ └── store.ts │ │ ├── volume │ │ │ └── store.ts │ │ └── settings │ │ │ └── types.ts │ ├── player │ │ ├── types.ts │ │ ├── slices │ │ │ ├── progress.ts │ │ │ ├── types.ts │ │ │ ├── playing.ts │ │ │ └── casting.ts │ │ └── store.ts │ ├── onboarding │ │ └── index.tsx │ ├── preferences │ │ └── index.tsx │ ├── volume │ │ └── index.ts │ ├── quality │ │ └── index.ts │ ├── theme │ │ └── index.tsx │ ├── overlay │ │ └── store.ts │ ├── subtitles │ │ └── SettingsSyncer.tsx │ ├── language │ │ └── index.tsx │ ├── history │ │ └── index.ts │ └── banner │ │ └── BannerLocation.tsx ├── utils │ ├── typeguard.ts │ ├── mediaTypes.ts │ ├── cdn.ts │ ├── timestamp.ts │ ├── formatSeconds.ts │ ├── onboarding.ts │ ├── events.ts │ └── proxyUrls.ts ├── pages │ ├── errors │ │ ├── NotFoundPage.tsx │ │ └── ErrorBoundary.tsx │ ├── layouts │ │ ├── PageLayout.tsx │ │ ├── HomeLayout.tsx │ │ ├── ErrorLayout.tsx │ │ ├── MinimalPageLayout.tsx │ │ └── SubPageLayout.tsx │ ├── parts │ │ ├── search │ │ │ └── SearchLoadingPart.tsx │ │ ├── util │ │ │ ├── PageTitle.tsx │ │ │ ├── WarningPart.tsx │ │ │ └── LargeTextPart.tsx │ │ ├── migrations │ │ │ └── MigrationPart.tsx │ │ ├── settings │ │ │ └── RegisterCalloutPart.tsx │ │ ├── admin │ │ │ └── ConfigValuesPart.tsx │ │ ├── auth │ │ │ └── PassphraseGeneratePart.tsx │ │ ├── errors │ │ │ └── NotFoundPart.tsx │ │ └── player │ │ │ └── PlaybackErrorPart.tsx │ ├── developer │ │ └── TestView.tsx │ ├── Login.tsx │ ├── DeveloperPage.tsx │ ├── admin │ │ └── AdminPage.tsx │ ├── Dmca.tsx │ ├── onboarding │ │ └── onboardingHooks.ts │ ├── About.tsx │ └── HomePage.tsx ├── setup │ ├── ga.ts │ ├── constants.ts │ ├── pwa.ts │ ├── Layout.tsx │ ├── i18n.ts │ └── chromecast.ts ├── backend │ ├── extension │ │ ├── compatibility.ts │ │ ├── request.ts │ │ ├── streams.ts │ │ └── plasmo.ts │ ├── accounts │ │ ├── meta.ts │ │ ├── auth.ts │ │ ├── import.ts │ │ ├── login.ts │ │ ├── settings.ts │ │ ├── sessions.ts │ │ ├── register.ts │ │ └── bookmarks.ts │ ├── providers │ │ └── providers.ts │ ├── metadata │ │ ├── types │ │ │ ├── mw.ts │ │ │ └── justwatch.ts │ │ └── search.ts │ └── helpers │ │ └── subs.ts ├── hooks │ ├── auth │ │ ├── useBackendUrl.ts │ │ └── useAuthRestore.ts │ ├── useChromecastAvailable.ts │ ├── useDebounce.ts │ ├── useIsMobile.ts │ ├── useRandomTranslation.ts │ ├── useQueryParams.ts │ ├── useSearchQuery.ts │ └── usePing.ts ├── assets │ ├── templates │ │ └── opensearch.xml.hbs │ ├── README.md │ └── locales │ │ ├── nv.json │ │ ├── km.json │ │ ├── ta.json │ │ └── pa.json └── @types │ └── country-language.d.ts ├── .dockerignore ├── prettierrc.js ├── postcss.config.js ├── .editorconfig ├── .vscode ├── extensions.json └── settings.json ├── themes ├── all.ts ├── index.ts └── types.ts ├── example.env ├── Dockerfile ├── .gitignore ├── tsconfig.json ├── LICENSE.md └── tailwind.config.ts /public/ping.txt: -------------------------------------------------------------------------------- 1 | pong 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /plugins/.gitignore: -------------------------------------------------------------------------------- 1 | figmaTokens.json 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @movie-web/project-leads 2 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /assets/* /assets/:splat 200 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /src/components/player/index.tsx: -------------------------------------------------------------------------------- 1 | export * as Player from "./Player"; 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | build 4 | .env.local 5 | .github 6 | .vscode 7 | -------------------------------------------------------------------------------- /prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "all", 3 | singleQuote: true 4 | }; 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/mstile-150x150.png -------------------------------------------------------------------------------- /src/stores/__old/DONT_TOUCH_THIS_FOLDER: -------------------------------------------------------------------------------- 1 | just dont, it's old stuff that needs to stay for legacy localstorage 2 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/splash_screens/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/icon.png -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/lightbar-images/fishie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/lightbar-images/fishie.png -------------------------------------------------------------------------------- /public/lightbar-images/santa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/lightbar-images/santa.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_size = 2 7 | indent_style = space 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "editorconfig.editorconfig" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/stores/__old/imports.ts: -------------------------------------------------------------------------------- 1 | import "./bookmark/store"; 2 | import "./settings/store"; 3 | import "./volume/store"; 4 | import "./watched/store"; 5 | -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.2__iPad_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.2__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.5__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.5__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.9__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/10.9__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/12.9__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/12.9__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/8.3__iPad_Mini_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/8.3__iPad_Mini_portrait.png -------------------------------------------------------------------------------- /src/utils/typeguard.ts: -------------------------------------------------------------------------------- 1 | export function isNotNull(obj: T | null): obj is T { 2 | return obj != null; 3 | } 4 | 5 | export type ValuesOf = T[keyof T]; 6 | -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_11__iPhone_XR_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_11__iPhone_XR_portrait.png -------------------------------------------------------------------------------- /src/utils/mediaTypes.ts: -------------------------------------------------------------------------------- 1 | export interface MediaItem { 2 | id: string; 3 | title: string; 4 | year?: number; 5 | poster?: string; 6 | type: "show" | "movie"; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/errors/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import { NotFoundPart } from "@/pages/parts/errors/NotFoundPart"; 2 | 3 | export function NotFoundPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/stores/__old/bookmark/types.ts: -------------------------------------------------------------------------------- 1 | import { MWMediaMeta } from "@/backend/metadata/types/mw"; 2 | 3 | export interface BookmarkStoreData { 4 | bookmarks: MWMediaMeta[]; 5 | } 6 | -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /src/components/text/SecondaryLabel.tsx: -------------------------------------------------------------------------------- 1 | export function SecondaryLabel(props: { children: React.ReactNode }) { 2 | return

{props.children}

; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/utils/Flare.css: -------------------------------------------------------------------------------- 1 | .flare-enabled .flare-light { 2 | opacity: 1 !important; 3 | } 4 | 5 | .hover\:flare-enabled:hover .flare-light { 6 | opacity: 1 !important; 7 | } 8 | -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png -------------------------------------------------------------------------------- /src/setup/ga.ts: -------------------------------------------------------------------------------- 1 | import ReactGA from "react-ga4"; 2 | 3 | import { GA_ID } from "@/setup/constants"; 4 | 5 | ReactGA.initialize([ 6 | { 7 | trackingId: GA_ID, 8 | }, 9 | ]); 10 | -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png -------------------------------------------------------------------------------- /src/components/player/atoms/Title.tsx: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores/player/store"; 2 | 3 | export function Title() { 4 | const title = usePlayerStore((s) => s.meta?.title); 5 | return

{title}

; 6 | } 7 | -------------------------------------------------------------------------------- /public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png -------------------------------------------------------------------------------- /themes/all.ts: -------------------------------------------------------------------------------- 1 | import teal from "./list/teal"; 2 | import blue from "./list/blue"; 3 | import red from "./list/red"; 4 | import gray from "./list/gray"; 5 | 6 | export const allThemes = [ 7 | teal, 8 | blue, 9 | gray, 10 | red 11 | ] 12 | -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AaqibhafeezKhan/movie-web/HEAD/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png -------------------------------------------------------------------------------- /src/backend/extension/compatibility.ts: -------------------------------------------------------------------------------- 1 | import { satisfies } from "semver"; 2 | 3 | const allowedExtensionRange = "~1.0.2"; 4 | 5 | export function isAllowedExtensionVersion(version: string): boolean { 6 | return satisfies(version, allowedExtensionRange); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/layout/Box.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | export function Box(props: { children?: ReactNode }) { 4 | return ( 5 |
6 | {props.children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/layout/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import "./Spinner.css"; 2 | 3 | interface SpinnerProps { 4 | className?: string; 5 | } 6 | 7 | export function Spinner(props: SpinnerProps) { 8 | return
; 9 | } 10 | -------------------------------------------------------------------------------- /src/hooks/auth/useBackendUrl.ts: -------------------------------------------------------------------------------- 1 | import { conf } from "@/setup/config"; 2 | import { useAuthStore } from "@/stores/auth"; 3 | 4 | export function useBackendUrl() { 5 | const backendUrl = useAuthStore((s) => s.backendUrl); 6 | return backendUrl ?? conf().BACKEND_URL; 7 | } 8 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | VITE_TMDB_READ_API_KEY=... 2 | VITE_OPENSEARCH_ENABLED=false 3 | 4 | # make sure the cors proxy url does NOT have a slash at the end 5 | VITE_CORS_PROXY_URL=... 6 | 7 | # make sure the domain does NOT have a slash at the end 8 | VITE_APP_DOMAIN=http://localhost:5173 9 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #120f1d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/player/base/CenterControls.tsx: -------------------------------------------------------------------------------- 1 | export function CenterControls(props: { children: React.ReactNode }) { 2 | return ( 3 |
4 | {props.children} 5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/player/internals/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | import * as Cards from "./Cards"; 2 | import * as Links from "./Links"; 3 | import * as Misc from "./Misc"; 4 | import * as Sections from "./Sections"; 5 | 6 | export const Menu = { 7 | ...Cards, 8 | ...Links, 9 | ...Sections, 10 | ...Misc, 11 | }; 12 | -------------------------------------------------------------------------------- /themes/index.ts: -------------------------------------------------------------------------------- 1 | import { allThemes } from "./all"; 2 | 3 | export { defaultTheme } from "./default"; 4 | export { allThemes } from "./all"; 5 | 6 | export const safeThemeList = allThemes 7 | .flatMap(v=>v.selectors) 8 | .filter(v=>v.startsWith(".")) 9 | .map(v=>v.slice(1)); // remove dot from selector 10 | -------------------------------------------------------------------------------- /src/components/player/utils/handleBuffered.ts: -------------------------------------------------------------------------------- 1 | export function handleBuffered(time: number, buffered: TimeRanges): number { 2 | for (let i = 0; i < buffered.length; i += 1) { 3 | if (buffered.start(buffered.length - 1 - i) < time) { 4 | return buffered.end(buffered.length - 1 - i); 5 | } 6 | } 7 | return 0; 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 4 | "eslint.format.enable": true, 5 | "[json]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[typescriptreact]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | } 11 | } -------------------------------------------------------------------------------- /src/stores/__old/utils.ts: -------------------------------------------------------------------------------- 1 | function normalizeTitle(title: string): string { 2 | return title 3 | .trim() 4 | .toLowerCase() 5 | .replace(/['":]/g, "") 6 | .replace(/[^a-zA-Z0-9]+/g, "_"); 7 | } 8 | 9 | export function compareTitle(a: string, b: string): boolean { 10 | return normalizeTitle(a) === normalizeTitle(b); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/utils/Divider.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Divider(props: { marginClass?: string }) { 4 | return ( 5 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/pages/layouts/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FooterView } from "@/components/layout/Footer"; 2 | import { Navigation } from "@/components/layout/Navigation"; 3 | 4 | export function PageLayout(props: { children: React.ReactNode }) { 5 | return ( 6 | 7 | 8 | {props.children} 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/player/utils/aired.ts: -------------------------------------------------------------------------------- 1 | const hasAiredCache: { [key: string]: boolean } = {}; 2 | 3 | export function hasAired(date: string) { 4 | if (hasAiredCache[date]) return hasAiredCache[date]; 5 | 6 | const now = new Date(); 7 | const airDate = new Date(date); 8 | 9 | hasAiredCache[date] = airDate < now; 10 | return hasAiredCache[date]; 11 | } 12 | -------------------------------------------------------------------------------- /src/assets/templates/opensearch.xml.hbs: -------------------------------------------------------------------------------- 1 | 2 | movie-web 3 | The place for your favorite movies & shows 4 | UTF-8 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/player/atoms/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Spinner } from "@/components/layout/Spinner"; 2 | import { usePlayerStore } from "@/stores/player/store"; 3 | 4 | export function LoadingSpinner() { 5 | const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); 6 | 7 | if (!isLoading) return null; 8 | 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /src/setup/constants.ts: -------------------------------------------------------------------------------- 1 | export const APP_VERSION = import.meta.env.PACKAGE_VERSION; 2 | export const DISCORD_LINK = "https://discord.movie-web.app"; 3 | export const GITHUB_LINK = "https://github.com/movie-web/movie-web"; 4 | export const DONATION_LINK = "https://ko-fi.com/movieweb"; 5 | export const GA_ID = "G-44YVXRL61C"; 6 | export const BACKEND_URL = "https://backend.movie-web.app"; 7 | -------------------------------------------------------------------------------- /src/components/player/base/BlackOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { Transition } from "@/components/utils/Transition"; 2 | 3 | export function BlackOverlay(props: { show?: boolean }) { 4 | return ( 5 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/parts/search/SearchLoadingPart.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { Loading } from "@/components/layout/Loading"; 4 | 5 | export function SearchLoadingPart() { 6 | const { t } = useTranslation(); 7 | return ( 8 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/developer/TestView.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { Button } from "@/components/buttons/Button"; 4 | 5 | // mostly empty view, add whatever you need 6 | export default function TestView() { 7 | const [val, setVal] = useState(false); 8 | 9 | if (val) throw new Error("I crashed"); 10 | 11 | return ; 12 | } 13 | -------------------------------------------------------------------------------- /src/backend/accounts/meta.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | export interface MetaResponse { 4 | version: string; 5 | name: string; 6 | description?: string; 7 | hasCaptcha: boolean; 8 | captchaClientKey?: string; 9 | } 10 | 11 | export async function getBackendMeta(url: string): Promise { 12 | return ofetch("/meta", { 13 | baseURL: url, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/text/Paragraph.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Paragraph(props: { 4 | children: React.ReactNode; 5 | marginClass?: string; 6 | }) { 7 | return ( 8 |

14 | {props.children} 15 |

16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/layouts/HomeLayout.tsx: -------------------------------------------------------------------------------- 1 | import { FooterView } from "@/components/layout/Footer"; 2 | import { Navigation } from "@/components/layout/Navigation"; 3 | 4 | export function HomeLayout(props: { 5 | showBg: boolean; 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 | 10 | 11 | {props.children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/text/Title.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Title(props: { 4 | children: React.ReactNode; 5 | className?: string; 6 | }) { 7 | return ( 8 |

14 | {props.children} 15 |

16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/components/text/HeroTitle.tsx: -------------------------------------------------------------------------------- 1 | export interface HeroTitleProps { 2 | children?: React.ReactNode; 3 | className?: string; 4 | } 5 | 6 | export function HeroTitle(props: HeroTitleProps) { 7 | return ( 8 |

13 | {props.children} 14 |

15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /themes/types.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial } from "vite-plugin-checker/dist/esm/types"; 2 | import { defaultTheme } from "./default"; 3 | 4 | export interface Theme { 5 | name: string; 6 | extend: DeepPartial<(typeof defaultTheme)["extend"]> 7 | } 8 | 9 | export function createTheme(theme: Theme) { 10 | return { 11 | name: theme.name, 12 | selectors: [`.theme-${theme.name}`], 13 | extend: theme.extend 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/_headers: -------------------------------------------------------------------------------- 1 | /* 2 | X-Frame-Options: DENY 3 | X-XSS-Protection: 1; mode=block 4 | X-Content-Type-Options: nosniff 5 | Referrer-Policy: origin-when-cross-origin 6 | Cache-Control: public, max-age=0, s-maxage=0, must-revalidate 7 | 8 | /manifest.webmanifest 9 | Content-Type: application/manifest+json 10 | 11 | # assets get a long cache instead of no cache 12 | /assets/* 13 | Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable 14 | -------------------------------------------------------------------------------- /src/components/media/MediaGrid.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef } from "react"; 2 | 3 | interface MediaGridProps { 4 | children?: React.ReactNode; 5 | } 6 | 7 | export const MediaGrid = forwardRef( 8 | (props, ref) => { 9 | return ( 10 |
14 | {props.children} 15 |
16 | ); 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /src/hooks/useChromecastAvailable.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { isChromecastAvailable } from "@/setup/chromecast"; 6 | 7 | export function useChromecastAvailable() { 8 | const [available, setAvailable] = useState(null); 9 | 10 | useEffect(() => { 11 | isChromecastAvailable((bool) => setAvailable(bool)); 12 | }, []); 13 | 14 | return available; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/layout/Spinner.css: -------------------------------------------------------------------------------- 1 | .spinner { 2 | width: 1em; 3 | height: 1em; 4 | border: 0.12em solid var(--color,white); 5 | border-bottom-color: transparent; 6 | border-radius: 50%; 7 | display: inline-block; 8 | box-sizing: border-box; 9 | animation: spinner-rotation 800ms linear infinite; 10 | } 11 | 12 | @keyframes spinner-rotation { 13 | 0% { 14 | transform: rotate(0deg); 15 | } 16 | 100% { 17 | transform: rotate(360deg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/player/Player.tsx: -------------------------------------------------------------------------------- 1 | export * from "./atoms"; 2 | export * from "./base/Container"; 3 | export * from "./base/TopControls"; 4 | export * from "./base/CenterControls"; 5 | export * from "./base/BottomControls"; 6 | export * from "./base/BlackOverlay"; 7 | export * from "./base/BackLink"; 8 | export * from "./base/LeftSideControls"; 9 | export * from "./base/CenterMobileControls"; 10 | export * from "./base/SubtitleView"; 11 | export * from "./internals/BookmarkButton"; 12 | -------------------------------------------------------------------------------- /src/components/layout/IconPill.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Icons } from "@/components/Icon"; 2 | 3 | export function IconPill(props: { icon: Icons; children?: React.ReactNode }) { 4 | return ( 5 |
6 | 10 | {props.children} 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine as build 2 | WORKDIR /app 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | RUN corepack enable 6 | 7 | COPY package.json ./ 8 | COPY pnpm-lock.yaml ./ 9 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 10 | 11 | COPY . ./ 12 | RUN pnpm run build 13 | 14 | # production environment 15 | FROM nginx:stable-alpine 16 | COPY --from=build /app/dist /usr/share/nginx/html 17 | EXPOSE 80 18 | CMD ["nginx", "-g", "daemon off;"] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /dist 13 | dev-dist 14 | /stats.html 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | 25 | # other package managers 26 | yarn.lock 27 | package-lock.json 28 | 29 | # config 30 | .env 31 | -------------------------------------------------------------------------------- /src/stores/player/types.ts: -------------------------------------------------------------------------------- 1 | import { DetailedMeta } from "@/backend/metadata/getmeta"; 2 | 3 | export interface Thumbnail { 4 | from: number; 5 | to: number; 6 | imgUrl: string; 7 | } 8 | export type VideoPlayerMeta = { 9 | meta: DetailedMeta; 10 | episode?: { 11 | episodeId: string; 12 | seasonId: string; 13 | }; 14 | seasons?: { 15 | id: string; 16 | number: number; 17 | title: string; 18 | episodes?: { id: string; number: number; title: string }[]; 19 | }[]; 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/cdn.ts: -------------------------------------------------------------------------------- 1 | import { conf } from "@/setup/config"; 2 | 3 | export function processCdnLink(url: string): string { 4 | const parsedUrl = new URL(url); 5 | const replacements = conf().CDN_REPLACEMENTS; 6 | for (const [before, after] of replacements) { 7 | if (parsedUrl.hostname.endsWith(before)) { 8 | parsedUrl.hostname = after; 9 | parsedUrl.port = ""; 10 | parsedUrl.protocol = "https://"; 11 | return parsedUrl.toString(); 12 | } 13 | } 14 | 15 | return url; 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | // State and setters for debounced value 5 | const [debouncedValue, setDebouncedValue] = useState(value); 6 | 7 | useEffect(() => { 8 | const handler = setTimeout(() => { 9 | setDebouncedValue(value); 10 | }, delay); 11 | return () => { 12 | clearTimeout(handler); 13 | }; 14 | }, [value, delay]); 15 | 16 | return debouncedValue; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/layout/WideContainer.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | interface WideContainerProps { 4 | classNames?: string; 5 | children?: ReactNode; 6 | ultraWide?: boolean; 7 | } 8 | 9 | export function WideContainer(props: WideContainerProps) { 10 | return ( 11 |
16 | {props.children} 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/overlays/OverlayAnchor.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode } from "react"; 3 | 4 | interface Props { 5 | id: string; 6 | children?: ReactNode; 7 | className?: string; 8 | } 9 | 10 | export function OverlayAnchor(props: Props) { 11 | return ( 12 |
13 |
17 | {props.children} 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/stores/__old/watched/types.ts: -------------------------------------------------------------------------------- 1 | import { MWMediaMeta } from "@/backend/metadata/types/mw"; 2 | 3 | export interface StoreMediaItem { 4 | meta: MWMediaMeta; 5 | series?: { 6 | episodeId: string; 7 | seasonId: string; 8 | episode: number; 9 | season: number; 10 | }; 11 | } 12 | 13 | export interface WatchedStoreItem { 14 | item: StoreMediaItem; 15 | progress: number; 16 | percentage: number; 17 | watchedAt: number; 18 | } 19 | 20 | export interface WatchedStoreData { 21 | items: WatchedStoreItem[]; 22 | } 23 | -------------------------------------------------------------------------------- /src/components/utils/ErrorLine.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode } from "react"; 3 | 4 | import { Icon, Icons } from "@/components/Icon"; 5 | 6 | export function ErrorLine(props: { children?: ReactNode; className?: string }) { 7 | return ( 8 |

14 | 15 | {props.children} 16 |

17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The movie-web maintainers only support the latest version of movie-web published at https://movie-web.app. 6 | 7 | Support is not provided for any forks or mirrors of movie-web. 8 | 9 | ## Reporting a Vulnerability 10 | 11 | There are two ways you can contact the movie-web maintainers to report a vulnerability: 12 | - Email [security@movie-web.app](mailto:security@movie-web.app) 13 | - Report the vulnerability in the [movie-web Discord server](https://discord.movie-web.app) 14 | -------------------------------------------------------------------------------- /src/pages/parts/util/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet-async"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export interface PageTitleProps { 5 | k: string; 6 | subpage?: boolean; 7 | } 8 | 9 | export function PageTitle(props: PageTitleProps) { 10 | const { t } = useTranslation(); 11 | 12 | const title = t(props.k); 13 | const subPageTitle = t("global.pages.pagetitle", { title }); 14 | 15 | return ( 16 | 17 | {props.subpage ? subPageTitle : title} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/text/DotList.tsx: -------------------------------------------------------------------------------- 1 | export interface DotListProps { 2 | content: string[]; 3 | className?: string; 4 | } 5 | 6 | export function DotList(props: DotListProps) { 7 | return ( 8 |

9 | {props.content.map((item, index) => ( 10 | 11 | {index !== 0 ? ( 12 | 13 | ) : null} 14 | {item} 15 | 16 | ))} 17 |

18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/player/atoms/Fullscreen.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/Icon"; 2 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | 5 | export function Fullscreen() { 6 | const { isFullscreen } = usePlayerStore((s) => s.interface); 7 | const display = usePlayerStore((s) => s.display); 8 | 9 | return ( 10 | display?.toggleFullscreen()} 12 | icon={isFullscreen ? Icons.COMPRESS : Icons.EXPAND} 13 | /> 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/player/atoms/Airplay.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/Icon"; 2 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | 5 | export function Airplay() { 6 | const canAirplay = usePlayerStore((s) => s.interface.canAirplay); 7 | const display = usePlayerStore((s) => s.display); 8 | 9 | if (!canAirplay) return null; 10 | 11 | return ( 12 | display?.startAirplay()} 14 | icon={Icons.AIRPLAY} 15 | /> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/@types/country-language.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@ladjs/country-language" { 2 | export interface LanguageObj { 3 | countries: Array<{ 4 | code_2: string; 5 | code_3: string; 6 | numCode: string; 7 | }>; 8 | direction: "RTL" | "LTR"; 9 | name: string[]; 10 | nativeName: string[]; 11 | iso639_1: string; 12 | } 13 | 14 | type Callback = (err: null | string, result: null | T) => void; 15 | 16 | declare namespace lib { 17 | function getLanguage(locale: string, cb: Callback): void; 18 | } 19 | 20 | export = lib; 21 | } 22 | -------------------------------------------------------------------------------- /src/pages/parts/util/WarningPart.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Icons } from "@/components/Icon"; 2 | import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; 3 | 4 | export function WarningPart(props: { children: React.ReactNode }) { 5 | return ( 6 |
7 | 8 | 9 |
10 | {props.children} 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/backend/extension/request.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionMakeRequestBodyType } from "./plasmo"; 2 | 3 | export function getBodyTypeFromBody( 4 | body: unknown, 5 | ): ExtensionMakeRequestBodyType { 6 | if (typeof body === "string") return "string"; 7 | if (body instanceof FormData) return "FormData"; 8 | if (body instanceof URLSearchParams) return "URLSearchParams"; 9 | return "object"; 10 | } 11 | 12 | export function convertBodyToObject(body: unknown): any { 13 | if (body instanceof FormData || body instanceof URLSearchParams) { 14 | return [...body]; 15 | } 16 | return body; 17 | } 18 | -------------------------------------------------------------------------------- /src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "react-router-dom"; 2 | 3 | import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 4 | import { LoginFormPart } from "@/pages/parts/auth/LoginFormPart"; 5 | import { PageTitle } from "@/pages/parts/util/PageTitle"; 6 | 7 | export function LoginPage() { 8 | const navigate = useNavigate(); 9 | 10 | return ( 11 | 12 | 13 | { 15 | navigate("/"); 16 | }} 17 | /> 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/stores/onboarding/index.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | export interface OnboardingStore { 6 | completed: boolean; 7 | setCompleted(v: boolean): void; 8 | } 9 | 10 | export const useOnboardingStore = create( 11 | persist( 12 | immer((set) => ({ 13 | completed: false, 14 | setCompleted(v) { 15 | set((s) => { 16 | s.completed = v; 17 | }); 18 | }, 19 | })), 20 | { name: "__MW::onboarding" }, 21 | ), 22 | ); 23 | -------------------------------------------------------------------------------- /src/components/player/utils/videoTracks.ts: -------------------------------------------------------------------------------- 1 | export interface VideoTrack { 2 | selected: boolean; 3 | id: string; 4 | kind: string; 5 | label: string; 6 | language: string; 7 | } 8 | 9 | export type VideoTrackList = Array & { 10 | selectedIndex: number; 11 | getTrackById(id: string): VideoTrack | null; 12 | addEventListener(type: "change", listener: (ev: Event) => any): void; 13 | }; 14 | 15 | export function getVideoTracks(video: HTMLVideoElement): VideoTrackList | null { 16 | const videoAsAny = video as any; 17 | if (!videoAsAny.videoTracks) return null; 18 | return videoAsAny.videoTracks; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/player/atoms/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Pause"; 2 | export * from "./Fullscreen"; 3 | export * from "./Pip"; 4 | export * from "./ProgressBar"; 5 | export * from "./Skips"; 6 | export * from "./Time"; 7 | export * from "./LoadingSpinner"; 8 | export * from "./AutoPlayStart"; 9 | export * from "./Volume"; 10 | export * from "./Title"; 11 | export * from "./EpisodeTitle"; 12 | export * from "./Settings"; 13 | export * from "./Episodes"; 14 | export * from "./Airplay"; 15 | export * from "./VolumeChangedPopout"; 16 | export * from "./NextEpisodeButton"; 17 | export * from "./Chromecast"; 18 | export * from "./CastingNotification"; 19 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | This pull request resolves #XXX 2 | 3 | - [ ] I have read and agreed to the [code of conduct](https://github.com/movie-web/movie-web/blob/dev/.github/CODE_OF_CONDUCT.md). 4 | - [ ] I have read and complied with the [contributing guidelines](https://github.com/movie-web/movie-web/blob/dev/.github/CONTRIBUTING.md). 5 | - [ ] What I'm implementing was assigned to me and is an [approved issue](https://github.com/movie-web/movie-web/issues?q=is%3Aopen+is%3Aissue+label%3Aapproved). For reference, please take a look at our [GitHub projects](https://github.com/movie-web/movie-web/projects). 6 | - [ ] I have tested all of my changes. 7 | -------------------------------------------------------------------------------- /src/components/player/internals/ContextMenu/Cards.tsx: -------------------------------------------------------------------------------- 1 | export function Card(props: { children: React.ReactNode }) { 2 | return ( 3 |
4 |
5 | {props.children} 6 |
7 |
8 | ); 9 | } 10 | 11 | export function CardWithScrollable(props: { children: React.ReactNode }) { 12 | return ( 13 |
14 | {props.children} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/stores/preferences/index.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | export interface PreferencesStore { 6 | enableThumbnails: boolean; 7 | setEnableThumbnails(v: boolean): void; 8 | } 9 | 10 | export const usePreferencesStore = create( 11 | persist( 12 | immer((set) => ({ 13 | enableThumbnails: false, 14 | setEnableThumbnails(v) { 15 | set((s) => { 16 | s.enableThumbnails = v; 17 | }); 18 | }, 19 | })), 20 | { 21 | name: "__MW::preferences", 22 | }, 23 | ), 24 | ); 25 | -------------------------------------------------------------------------------- /src/utils/timestamp.ts: -------------------------------------------------------------------------------- 1 | // Convert `t` param to time. Supports having only seconds (like `?t=192`), but also `3:30` or `1:30:02` 2 | export function parseTimestamp(str: string | undefined | null): number | null { 3 | const input = str ?? ""; 4 | const isValid = !!input.match(/^\d+(:\d+)*$/); 5 | if (!isValid) return null; 6 | 7 | const timeArr = input.split(":").map(Number).reverse(); 8 | const hours = timeArr[2] ?? 0; 9 | const minutes = Math.min(timeArr[1] ?? 0, 59); 10 | const seconds = Math.min(timeArr[0] ?? 0, minutes > 0 ? 59 : Infinity); 11 | 12 | const timeInSeconds = hours * 60 * 60 + minutes * 60 + seconds; 13 | return timeInSeconds; 14 | } 15 | -------------------------------------------------------------------------------- /src/components/player/atoms/Pip.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/Icon"; 2 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | import { 5 | canPictureInPicture, 6 | canWebkitPictureInPicture, 7 | } from "@/utils/detectFeatures"; 8 | 9 | export function Pip() { 10 | const display = usePlayerStore((s) => s.display); 11 | 12 | if (!canPictureInPicture() && !canWebkitPictureInPicture()) return null; 13 | 14 | return ( 15 | display?.togglePictureInPicture()} 17 | icon={Icons.PICTURE_IN_PICTURE} 18 | /> 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/README.md: -------------------------------------------------------------------------------- 1 | # About the languages 2 | 3 | Locales are difficult, here is some guidance. 4 | 5 | ## Process on adding new languages 6 | 1. Use Weblate to add translations, see contributing guidelines. 7 | 2. Add your language to `@/assets/languages.ts`. Must be in ISO format (ISO-639 for language and ISO-3166 for country/region). For joke languages, use any format. 8 | 3. If the language code doesn't have a region specified (Such as in `pt-BR`, `BR` being the region), add a default region in `@/utils/language.ts` at `defaultLanguageCodes` 9 | 4. If the language code doesn't contain a region (Such as in `zh-Hant`), add a default country in `@/utils/language.ts` at `countryPriority`. 10 | -------------------------------------------------------------------------------- /src/components/player/hooks/useSlashFocus.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function useSlashFocus(ref: React.RefObject) { 4 | useEffect(() => { 5 | const listener = (e: KeyboardEvent) => { 6 | if (e.key === "/") { 7 | if ( 8 | document.activeElement && 9 | document.activeElement.tagName.toLowerCase() === "input" 10 | ) 11 | return; 12 | e.preventDefault(); 13 | ref.current?.focus(); 14 | } 15 | }; 16 | 17 | window.addEventListener("keydown", listener); 18 | return () => { 19 | window.removeEventListener("keydown", listener); 20 | }; 21 | }, [ref]); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/layouts/ErrorLayout.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode } from "react"; 3 | 4 | export function ErrorContainer(props: { 5 | children: React.ReactNode; 6 | maxWidth?: string; 7 | }) { 8 | return ( 9 |
15 | {props.children} 16 |
17 | ); 18 | } 19 | 20 | export function ErrorLayout(props: { children?: ReactNode }) { 21 | return ( 22 |
23 | {props.children} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/setup/pwa.ts: -------------------------------------------------------------------------------- 1 | import { registerSW } from "virtual:pwa-register"; 2 | 3 | const intervalMS = 60 * 60 * 1000; 4 | 5 | registerSW({ 6 | immediate: true, 7 | onRegisteredSW(swUrl, r) { 8 | if (!r) return; 9 | setInterval(async () => { 10 | if (!(!r.installing && navigator)) return; 11 | 12 | if ("connection" in navigator && !navigator.onLine) return; 13 | 14 | const resp = await fetch(swUrl, { 15 | cache: "no-store", 16 | headers: { 17 | cache: "no-store", 18 | "cache-control": "no-cache", 19 | }, 20 | }); 21 | 22 | if (resp?.status === 200) { 23 | await r.update(); 24 | } 25 | }, intervalMS); 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/pages/DeveloperPage.tsx: -------------------------------------------------------------------------------- 1 | import { Navigation } from "@/components/layout/Navigation"; 2 | import { ThinContainer } from "@/components/layout/ThinContainer"; 3 | import { ArrowLink } from "@/components/text/ArrowLink"; 4 | import { Title } from "@/components/text/Title"; 5 | 6 | export default function DeveloperPage() { 7 | return ( 8 |
9 | 10 | 11 | Developer tools 12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/stores/__old/volume/store.ts: -------------------------------------------------------------------------------- 1 | import { useVolumeStore } from "@/stores/volume"; 2 | 3 | import { createVersionedStore } from "../migrations"; 4 | 5 | interface VolumeStoreData { 6 | volume: number; 7 | } 8 | 9 | export const volumeStore = createVersionedStore>() 10 | .setKey("mw-volume") 11 | .addVersion({ 12 | version: 0, 13 | create() { 14 | return { 15 | volume: 1, 16 | }; 17 | }, 18 | migrate(data: VolumeStoreData): Record { 19 | useVolumeStore.getState().setVolume(data.volume); 20 | return {}; 21 | }, 22 | }) 23 | .addVersion({ 24 | version: 1, 25 | create() { 26 | return {}; 27 | }, 28 | }) 29 | .build(); 30 | -------------------------------------------------------------------------------- /src/assets/locales/nv.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": { 3 | "description": "movie-web T'áá hwiił yá at'ééh naat'áanii t'áá hwiił yá at'ééh bitsiin. Bee ahéhí bitsííʼííł dóó sinas dziní asdzą́ą́.", 4 | "faqTitle": "hastiin nahatʼá", 5 | "q1": { 6 | "body": "Bee hwiił bitsííʼííł hólǫ́, t'áá hwiił yá at'ééh naat'áanii t'áá hwiił yá at'ééh bitsiin. Hwiił yá at'ééh naat'áanii at'é, bitsííʼííł yá at'ééh naat'áanii t'áá hwiił yá at'ééh bitsiin. Bitsííʼííł yá at'ééh naat'áanii hólǫ́, t'áá hwiił yá at'ééh naat'áanii t'áá hwiił yá at'ééh bitsiin." 7 | }, 8 | "q3": { 9 | "body": "Hałáágo áłtsééh hózhǫǫgiisiił Nílchʼi Datasoii (TMDB) yá’át’ééhí dooleeł dįįʼgo doo dįįʼgií nihisin dóó tązhii yisdzohazlą́ą́ʼ." 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/player/atoms/EpisodeTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | 5 | export function EpisodeTitle() { 6 | const { t } = useTranslation(); 7 | const meta = usePlayerStore((s) => s.meta); 8 | 9 | if (meta?.type !== "show") return null; 10 | 11 | return ( 12 |
13 | 14 | {t("media.episodeDisplay", { 15 | season: meta?.season?.number, 16 | episode: meta?.episode?.number, 17 | })} 18 | 19 | 20 | {meta?.episode?.title} 21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/player/base/CenterMobileControls.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { Transition } from "@/components/utils/Transition"; 4 | 5 | export function CenterMobileControls(props: { 6 | children: React.ReactNode; 7 | show: boolean; 8 | className?: string; 9 | }) { 10 | return ( 11 | 16 |
*]:pointer-events-auto", 19 | props.className, 20 | ])} 21 | > 22 | {props.children} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/setup/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { useBannerSize, useBannerStore } from "@/stores/banner"; 4 | import { BannerLocation } from "@/stores/banner/BannerLocation"; 5 | 6 | export function Layout(props: { children: ReactNode }) { 7 | const bannerSize = useBannerSize(); 8 | const location = useBannerStore((s) => s.location); 9 | 10 | return ( 11 |
12 |
13 | 14 |
15 |
21 | {props.children} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/layout/Stepper.tsx: -------------------------------------------------------------------------------- 1 | export interface StepperProps { 2 | current: number; 3 | steps: number; 4 | className?: string; 5 | } 6 | 7 | export function Stepper(props: StepperProps) { 8 | const percentage = (props.current / props.steps) * 100; 9 | 10 | return ( 11 |
12 |

13 | {props.current}/{props.steps} 14 |

15 |
16 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/__old/settings/types.ts: -------------------------------------------------------------------------------- 1 | export interface CaptionStyleSettings { 2 | color: string; 3 | /** 4 | * Range is [10, 30] 5 | */ 6 | fontSize: number; 7 | backgroundColor: string; 8 | } 9 | 10 | export interface CaptionSettingsV1 { 11 | /** 12 | * Range is [-10, 10]s 13 | */ 14 | delay: number; 15 | style: CaptionStyleSettings; 16 | } 17 | 18 | export interface CaptionSettings { 19 | language: string; 20 | /** 21 | * Range is [-10, 10]s 22 | */ 23 | delay: number; 24 | style: CaptionStyleSettings; 25 | } 26 | export interface MWSettingsDataV1 { 27 | language: string; 28 | captionSettings: CaptionSettingsV1; 29 | } 30 | 31 | export interface MWSettingsData { 32 | language: string; 33 | captionSettings: CaptionSettings; 34 | } 35 | -------------------------------------------------------------------------------- /src/utils/formatSeconds.ts: -------------------------------------------------------------------------------- 1 | export function formatSeconds(secs: number, showHours = false): string { 2 | if (Number.isNaN(secs)) { 3 | if (showHours) return "0:00:00"; 4 | return "0:00"; 5 | } 6 | 7 | let time = secs; 8 | const seconds = Math.floor(time % 60); 9 | 10 | time /= 60; 11 | const minutes = Math.floor(time % 60); 12 | 13 | time /= 60; 14 | const hours = Math.floor(time); 15 | 16 | const paddedSecs = seconds.toString().padStart(2, "0"); 17 | const paddedMins = minutes.toString().padStart(2, "0"); 18 | 19 | if (!showHours) return [paddedMins, paddedSecs].join(":"); 20 | return [hours, paddedMins, paddedSecs].join(":"); 21 | } 22 | 23 | export function durationExceedsHour(secs: number): boolean { 24 | return secs > 60 * 60; 25 | } 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature 3 | title: "[Feature]: " 4 | labels: ["feature", "awaiting-approval"] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | id: what-feature 9 | attributes: 10 | label: What feature do you want to add? 11 | placeholder: A new button! 12 | validations: 13 | required: true 14 | - type: textarea 15 | id: why-feature 16 | attributes: 17 | label: Why do you want to have this feature? 18 | placeholder: A new button! 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: other-details 23 | attributes: 24 | label: Anything other details to share? 25 | validations: 26 | required: false 27 | -------------------------------------------------------------------------------- /src/components/player/atoms/Pause.tsx: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/Icon"; 2 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | 5 | export function Pause(props: { iconSizeClass?: string; className?: string }) { 6 | const display = usePlayerStore((s) => s.display); 7 | const { isPaused } = usePlayerStore((s) => s.mediaPlaying); 8 | 9 | const toggle = () => { 10 | if (isPaused) display?.play(); 11 | else display?.pause(); 12 | }; 13 | 14 | return ( 15 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/stores/player/slices/progress.ts: -------------------------------------------------------------------------------- 1 | import { MakeSlice } from "@/stores/player/slices/types"; 2 | 3 | export interface ProgressSlice { 4 | progress: { 5 | time: number; // current time of video 6 | duration: number; // length of video 7 | buffered: number; // how much is buffered 8 | draggingTime: number; // when dragging, time thats at the cursor 9 | }; 10 | setDraggingTime(draggingTime: number): void; 11 | } 12 | 13 | export const createProgressSlice: MakeSlice = (set) => ({ 14 | progress: { 15 | time: 0, 16 | duration: 0, 17 | buffered: 0, 18 | draggingTime: 0, 19 | }, 20 | setDraggingTime(draggingTime: number) { 21 | set((s) => { 22 | s.progress.draggingTime = draggingTime; 23 | }); 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /public/config.js: -------------------------------------------------------------------------------- 1 | window.__CONFIG__ = { 2 | // The URL for the CORS proxy, the URL must NOT end with a slash! 3 | VITE_CORS_PROXY_URL: "CHANGEME", 4 | 5 | // The READ API key to access TMDB 6 | VITE_TMDB_READ_API_KEY: "CHANGEME", 7 | 8 | // The DMCA email displayed in the footer, null to hide the DMCA link 9 | VITE_DMCA_EMAIL: null, 10 | 11 | // Whether to disable hash-based routing, leave this as false if you don't know what this is 12 | VITE_NORMAL_ROUTER: false, 13 | 14 | // The backend URL to communicate with, defaults to the movie-web hosted one at backend.movie-web.app 15 | VITE_BACKEND_URL: null, 16 | 17 | // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-" and "movie-" 18 | VITE_DISALLOWED_IDS: "" 19 | }; 20 | -------------------------------------------------------------------------------- /src/backend/providers/providers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | makeProviders, 3 | makeStandardFetcher, 4 | targets, 5 | } from "@movie-web/providers"; 6 | 7 | import { isExtensionActiveCached } from "@/backend/extension/messaging"; 8 | import { 9 | makeExtensionFetcher, 10 | makeLoadBalancedSimpleProxyFetcher, 11 | } from "@/backend/providers/fetchers"; 12 | 13 | export function getProviders() { 14 | if (isExtensionActiveCached()) { 15 | return makeProviders({ 16 | fetcher: makeExtensionFetcher(), 17 | target: targets.BROWSER_EXTENSION, 18 | consistentIpForRequests: true, 19 | }); 20 | } 21 | 22 | return makeProviders({ 23 | fetcher: makeStandardFetcher(fetch), 24 | proxiedFetcher: makeLoadBalancedSimpleProxyFetcher(), 25 | target: targets.BROWSER, 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/player/internals/ContextMenu/Input.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Icons } from "@/components/Icon"; 2 | 3 | export function Input(props: { 4 | value: string; 5 | onInput: (str: string) => void; 6 | }) { 7 | return ( 8 |
9 | 13 | props.onInput(e.currentTarget.value)} 18 | /> 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/components/text/Link.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Link as LinkRouter } from "react-router-dom"; 3 | 4 | export function MwLink(props: { 5 | children?: ReactNode; 6 | to?: string; 7 | url?: string; 8 | onClick?: () => void; 9 | }) { 10 | const isExternal = !!props.url; 11 | const isInternal = !!props.to; 12 | const content = ( 13 | 14 | {props.children} 15 | 16 | ); 17 | 18 | if (isExternal) return {content}; 19 | if (isInternal) return {content}; 20 | return ( 21 | props.onClick && props.onClick()}>{content} 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/parts/util/LargeTextPart.tsx: -------------------------------------------------------------------------------- 1 | import { BrandPill } from "@/components/layout/BrandPill"; 2 | import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; 3 | 4 | export function LargeTextPart(props: { 5 | iconSlot?: React.ReactNode; 6 | children: React.ReactNode; 7 | }) { 8 | return ( 9 |
10 | {/* Overlayed elements */} 11 | 12 |
13 | 14 |
15 | 16 | {/* Content */} 17 | {props.iconSlot ? props.iconSlot : null} 18 |
19 | {props.children} 20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/backend/accounts/auth.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | export interface SessionResponse { 4 | id: string; 5 | userId: string; 6 | createdAt: string; 7 | accessedAt: string; 8 | device: string; 9 | userAgent: string; 10 | } 11 | export interface LoginResponse { 12 | session: SessionResponse; 13 | token: string; 14 | } 15 | 16 | export function getAuthHeaders(token: string): Record { 17 | return { 18 | authorization: `Bearer ${token}`, 19 | }; 20 | } 21 | 22 | export async function accountLogin( 23 | url: string, 24 | id: string, 25 | deviceName: string, 26 | ): Promise { 27 | return ofetch("/auth/login", { 28 | method: "POST", 29 | body: { 30 | id, 31 | device: deviceName, 32 | }, 33 | baseURL: url, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/layout/SectionHeading.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { Icon, Icons } from "@/components/Icon"; 4 | 5 | interface SectionHeadingProps { 6 | icon?: Icons; 7 | title: string; 8 | children?: ReactNode; 9 | className?: string; 10 | } 11 | 12 | export function SectionHeading(props: SectionHeadingProps) { 13 | return ( 14 |
15 |
16 |

17 | {props.icon ? ( 18 | 19 | 20 | 21 | ) : null} 22 | {props.title} 23 |

24 | {props.children} 25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/player/internals/HeadUpdater.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet-async"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { usePlayerStore } from "@/stores/player/store"; 5 | 6 | export function HeadUpdater() { 7 | const { t } = useTranslation(); 8 | const meta = usePlayerStore((s) => s.meta); 9 | 10 | if (!meta) return null; 11 | if (meta.type !== "show") { 12 | return ( 13 | 14 | {meta.title} 15 | 16 | ); 17 | } 18 | 19 | const humanizedEpisodeId = t("media.episodeDisplay", { 20 | season: meta.season?.number, 21 | episode: meta.episode?.number, 22 | }); 23 | 24 | return ( 25 | 26 | 27 | {meta.title} - {humanizedEpisodeId} 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/stores/player/slices/types.ts: -------------------------------------------------------------------------------- 1 | import { StateCreator } from "zustand"; 2 | 3 | import { CastingSlice } from "@/stores/player/slices/casting"; 4 | import { DisplaySlice } from "@/stores/player/slices/display"; 5 | import { InterfaceSlice } from "@/stores/player/slices/interface"; 6 | import { PlayingSlice } from "@/stores/player/slices/playing"; 7 | import { ProgressSlice } from "@/stores/player/slices/progress"; 8 | import { SourceSlice } from "@/stores/player/slices/source"; 9 | import { ThumbnailSlice } from "@/stores/player/slices/thumbnails"; 10 | 11 | export type AllSlices = InterfaceSlice & 12 | PlayingSlice & 13 | ProgressSlice & 14 | SourceSlice & 15 | DisplaySlice & 16 | CastingSlice & 17 | ThumbnailSlice; 18 | export type MakeSlice = StateCreator< 19 | AllSlices, 20 | [["zustand/immer", never]], 21 | [], 22 | Slice 23 | >; 24 | -------------------------------------------------------------------------------- /src/components/layout/ThinContainer.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode } from "react"; 3 | 4 | interface ThinContainerProps { 5 | classNames?: string; 6 | children?: ReactNode; 7 | } 8 | 9 | export function ThinContainer(props: ThinContainerProps) { 10 | return ( 11 |
16 | {props.children} 17 |
18 | ); 19 | } 20 | 21 | export function CenterContainer(props: ThinContainerProps) { 22 | return ( 23 |
29 |
{props.children}
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/auth/useAuthRestore.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { useAsync, useInterval } from "react-use"; 3 | 4 | import { useAuth } from "@/hooks/auth/useAuth"; 5 | import { useAuthStore } from "@/stores/auth"; 6 | 7 | const AUTH_CHECK_INTERVAL = 12 * 60 * 60 * 1000; 8 | 9 | export function useAuthRestore() { 10 | const { account } = useAuthStore(); 11 | const { restore } = useAuth(); 12 | const hasRestored = useRef(false); 13 | 14 | useInterval(() => { 15 | if (account) restore(account); 16 | }, AUTH_CHECK_INTERVAL); 17 | 18 | const result = useAsync(async () => { 19 | if (hasRestored.current || !account) return; 20 | await restore(account).finally(() => { 21 | hasRestored.current = true; 22 | }); 23 | }, []); // no deps because we don't want to it ever rerun after the first time 24 | 25 | return result; 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useIsMobile.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | 3 | export function useIsMobile(horizontal?: boolean) { 4 | const [isMobile, setIsMobile] = useState(false); 5 | const isMobileCurrent = useRef(false); 6 | 7 | useEffect(() => { 8 | function onResize() { 9 | const value = horizontal 10 | ? window.innerHeight < 600 11 | : window.innerWidth < 1024; 12 | const isChanged = isMobileCurrent.current !== value; 13 | if (!isChanged) return; 14 | 15 | isMobileCurrent.current = value; 16 | setIsMobile(value); 17 | } 18 | 19 | onResize(); 20 | window.addEventListener("resize", onResize); 21 | 22 | return () => { 23 | window.removeEventListener("resize", onResize); 24 | }; 25 | }, [horizontal]); 26 | 27 | return { 28 | isMobile, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./src", 19 | "paths": { 20 | "@/*": ["./*"], 21 | "@sozialhelden/ietf-language-tags": [ 22 | "../node_modules/@sozialhelden/ietf-language-tags/dist/cjs" 23 | ] 24 | }, 25 | "types": ["vite/client", "vite-plugin-pwa/vanillajs"] 26 | }, 27 | "include": ["src"] 28 | } 29 | -------------------------------------------------------------------------------- /src/setup/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | 4 | import { locales } from "@/assets/languages"; 5 | import { getLocaleInfo } from "@/utils/language"; 6 | 7 | // Languages 8 | const langCodes = Object.keys(locales); 9 | const resources = Object.fromEntries( 10 | Object.entries(locales).map((entry) => [entry[0], { translation: entry[1] }]), 11 | ); 12 | i18n.use(initReactI18next).init({ 13 | fallbackLng: "en", 14 | resources, 15 | interpolation: { 16 | escapeValue: false, // not needed for react as it escapes by default 17 | }, 18 | }); 19 | 20 | export const appLanguageOptions = langCodes.map((lang) => { 21 | const langObj = getLocaleInfo(lang); 22 | if (!langObj) 23 | throw new Error(`Language with code ${lang} cannot be found in database`); 24 | return langObj; 25 | }); 26 | 27 | export default i18n; 28 | -------------------------------------------------------------------------------- /src/components/buttons/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Toggle(props: { onClick?: () => void; enabled?: boolean }) { 4 | return ( 5 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/parts/migrations/MigrationPart.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { BrandPill } from "@/components/layout/BrandPill"; 4 | import { Loading } from "@/components/layout/Loading"; 5 | import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; 6 | 7 | export function MigrationPart() { 8 | const { t } = useTranslation(); 9 | return ( 10 |
11 | {/* Overlaid elements */} 12 | 13 |
14 | 15 |
16 | 17 | {/* Content */} 18 | 19 |

20 | {t("screens.migration.inProgress")} 21 |

22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/player/atoms/CastingNotification.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { Icon, Icons } from "@/components/Icon"; 4 | import { usePlayerStore } from "@/stores/player/store"; 5 | 6 | export function CastingNotification() { 7 | const { t } = useTranslation(); 8 | const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); 9 | const display = usePlayerStore((s) => s.display); 10 | const isCasting = display?.getType() === "casting"; 11 | 12 | if (isLoading || !isCasting) return null; 13 | 14 | return ( 15 |
16 |
17 | 18 |
19 |

{t("player.casting.enabled")}

20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/player/base/LeftSideControls.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useCallback, useEffect } from "react"; 3 | 4 | import { usePlayerStore } from "@/stores/player/store"; 5 | 6 | export function LeftSideControls(props: { 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | const setHoveringLeftControls = usePlayerStore( 11 | (s) => s.setHoveringLeftControls, 12 | ); 13 | 14 | const mouseLeave = useCallback(() => { 15 | setHoveringLeftControls(false); 16 | }, [setHoveringLeftControls]); 17 | 18 | useEffect(() => { 19 | return () => { 20 | setHoveringLeftControls(false); 21 | }; 22 | }, [setHoveringLeftControls]); 23 | 24 | return ( 25 |
29 | {props.children} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.14, written by Peter Selinger 2001-2017 9 | 10 | 12 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/player/base/BackLink.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { Icon, Icons } from "@/components/Icon"; 5 | 6 | export function BackLink(props: { url: string }) { 7 | const { t } = useTranslation(); 8 | const navigate = useNavigate(); 9 | 10 | return ( 11 |
12 | 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/player/utils/mediaErrorDetails.ts: -------------------------------------------------------------------------------- 1 | const mediaErrorMap: Record = { 2 | 1: { 3 | name: "MEDIA_ERR_ABORTED", 4 | key: "player.playbackError.errors.errorAborted", 5 | }, 6 | 2: { 7 | name: "MEDIA_ERR_NETWORK", 8 | key: "player.playbackError.errors.errorNetwork", 9 | }, 10 | 3: { 11 | name: "MEDIA_ERR_DECODE", 12 | key: "player.playbackError.errors.errorDecode", 13 | }, 14 | 4: { 15 | name: "MEDIA_ERR_SRC_NOT_SUPPORTED", 16 | key: "player.playbackError.errors.errorNotSupported", 17 | }, 18 | }; 19 | 20 | export function getMediaErrorDetails( 21 | err: MediaError | null, 22 | ): (typeof mediaErrorMap)[number] { 23 | const item = mediaErrorMap[err?.code ?? -1]; 24 | if (!item) { 25 | return { 26 | name: "MEDIA_ERR_GENERIC", 27 | key: "player.playbackError.errors.errorGenericMedia", 28 | }; 29 | } 30 | return item; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/onboarding.ts: -------------------------------------------------------------------------------- 1 | import { isExtensionActive } from "@/backend/extension/messaging"; 2 | import { conf } from "@/setup/config"; 3 | import { useAuthStore } from "@/stores/auth"; 4 | import { useOnboardingStore } from "@/stores/onboarding"; 5 | 6 | export async function needsOnboarding(): Promise { 7 | // if onboarding is dislabed, no onboarding needed 8 | if (!conf().HAS_ONBOARDING) return false; 9 | 10 | // if extension is active and working, no onboarding needed 11 | const extensionActive = await isExtensionActive(); 12 | if (extensionActive) return false; 13 | 14 | // if there is any custom proxy urls, no onboarding needed 15 | const proxyUrls = useAuthStore.getState().proxySet; 16 | if (proxyUrls) return false; 17 | 18 | // if onboarding has been completed, no onboarding needed 19 | const completed = useOnboardingStore.getState().completed; 20 | if (completed) return false; 21 | 22 | return true; 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/admin/AdminPage.tsx: -------------------------------------------------------------------------------- 1 | import { ThinContainer } from "@/components/layout/ThinContainer"; 2 | import { Heading1, Paragraph } from "@/components/utils/Text"; 3 | import { SubPageLayout } from "@/pages/layouts/SubPageLayout"; 4 | import { ConfigValuesPart } from "@/pages/parts/admin/ConfigValuesPart"; 5 | import { TMDBTestPart } from "@/pages/parts/admin/TMDBTestPart"; 6 | import { WorkerTestPart } from "@/pages/parts/admin/WorkerTestPart"; 7 | 8 | import { BackendTestPart } from "../parts/admin/BackendTestPart"; 9 | 10 | export function AdminPage() { 11 | return ( 12 | 13 | 14 | Admin tools 15 | Useful tools to test out your current deployment 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/pages/layouts/MinimalPageLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | 3 | import { BrandPill } from "@/components/layout/BrandPill"; 4 | import { BlurEllipsis } from "@/pages/layouts/SubPageLayout"; 5 | 6 | export function MinimalPageLayout(props: { children: React.ReactNode }) { 7 | return ( 8 |
15 | 16 | {/* Main page */} 17 |
18 | 22 | 23 | 24 |
25 |
{props.children}
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/backend/accounts/import.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { getAuthHeaders } from "@/backend/accounts/auth"; 4 | import { AccountWithToken } from "@/stores/auth"; 5 | 6 | import { BookmarkInput } from "./bookmarks"; 7 | import { ProgressInput } from "./progress"; 8 | 9 | export function importProgress( 10 | url: string, 11 | account: AccountWithToken, 12 | progressItems: ProgressInput[], 13 | ) { 14 | return ofetch(`/users/${account.userId}/progress/import`, { 15 | method: "PUT", 16 | body: progressItems, 17 | baseURL: url, 18 | headers: getAuthHeaders(account.token), 19 | }); 20 | } 21 | 22 | export function importBookmarks( 23 | url: string, 24 | account: AccountWithToken, 25 | bookmarks: BookmarkInput[], 26 | ) { 27 | return ofetch(`/users/${account.userId}/bookmarks`, { 28 | method: "PUT", 29 | body: bookmarks, 30 | baseURL: url, 31 | headers: getAuthHeaders(account.token), 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/stores/volume/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | export interface VolumeStore { 6 | volume: number; 7 | setVolume(v: number): void; 8 | } 9 | 10 | export interface EmpheralVolumeStore { 11 | showVolume: boolean; 12 | setShowVolume(v: boolean): void; 13 | } 14 | 15 | export const useVolumeStore = create( 16 | persist( 17 | immer((set) => ({ 18 | volume: 1, 19 | setVolume(v: number) { 20 | set((s) => { 21 | s.volume = v; 22 | }); 23 | }, 24 | })), 25 | { 26 | name: "__MW::volume", 27 | }, 28 | ), 29 | ); 30 | 31 | export const useEmpheralVolumeStore = create( 32 | immer((set) => ({ 33 | showVolume: false, 34 | setShowVolume(bool: boolean) { 35 | set((s) => { 36 | s.showVolume = bool; 37 | }); 38 | }, 39 | })), 40 | ); 41 | -------------------------------------------------------------------------------- /src/components/layout/SettingsCard.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function SettingsCard(props: { 4 | children: React.ReactNode; 5 | className?: string; 6 | paddingClass?: string; 7 | }) { 8 | return ( 9 |
16 | {props.children} 17 |
18 | ); 19 | } 20 | 21 | export function SolidSettingsCard(props: { 22 | children: React.ReactNode; 23 | className?: string; 24 | paddingClass?: string; 25 | }) { 26 | return ( 27 |
34 | {props.children} 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/layout/Loading.tsx: -------------------------------------------------------------------------------- 1 | export interface LoadingProps { 2 | text?: string; 3 | className?: string; 4 | } 5 | 6 | export function Loading(props: LoadingProps) { 7 | return ( 8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {props.text && props.text.length ? ( 17 |

{props.text}

18 | ) : null} 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/errors/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | 3 | import { ErrorPart } from "@/pages/parts/errors/ErrorPart"; 4 | 5 | interface ErrorBoundaryState { 6 | error?: { 7 | error: any; 8 | errorInfo: any; 9 | }; 10 | } 11 | 12 | export class ErrorBoundary extends Component< 13 | Record, 14 | ErrorBoundaryState 15 | > { 16 | constructor(props: { children: any }) { 17 | super(props); 18 | this.state = { 19 | error: undefined, 20 | }; 21 | } 22 | 23 | componentDidCatch(error: any, errorInfo: any) { 24 | console.error("Render error caught", error, errorInfo); 25 | this.setState((s) => ({ 26 | ...s, 27 | error: { 28 | error, 29 | errorInfo, 30 | }, 31 | })); 32 | } 33 | 34 | render() { 35 | if (!this.state.error) return this.props.children as any; 36 | 37 | return ( 38 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/setup/chromecast.ts: -------------------------------------------------------------------------------- 1 | const CHROMECAST_SENDER_SDK = 2 | "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; 3 | 4 | const callbacks: ((available: boolean) => void)[] = []; 5 | let _available: boolean | null = null; 6 | 7 | function init(available: boolean) { 8 | _available = available; 9 | callbacks.forEach((cb) => cb(available)); 10 | } 11 | 12 | export function isChromecastAvailable(cb: (available: boolean) => void) { 13 | if (_available !== null) return cb(_available); 14 | callbacks.push(cb); 15 | } 16 | 17 | export function initializeChromecast() { 18 | window.__onGCastApiAvailable = (isAvailable) => { 19 | init(isAvailable); 20 | }; 21 | 22 | // add script if doesnt exist yet 23 | const exists = !!document.getElementById("chromecast-script"); 24 | if (!exists) { 25 | const script = document.createElement("script"); 26 | script.setAttribute("src", CHROMECAST_SENDER_SDK); 27 | script.setAttribute("id", "chromecast-script"); 28 | document.body.appendChild(script); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/stores/player/store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { immer } from "zustand/middleware/immer"; 3 | 4 | import { createCastingSlice } from "@/stores/player/slices/casting"; 5 | import { createDisplaySlice } from "@/stores/player/slices/display"; 6 | import { createInterfaceSlice } from "@/stores/player/slices/interface"; 7 | import { createPlayingSlice } from "@/stores/player/slices/playing"; 8 | import { createProgressSlice } from "@/stores/player/slices/progress"; 9 | import { createSourceSlice } from "@/stores/player/slices/source"; 10 | import { createThumbnailSlice } from "@/stores/player/slices/thumbnails"; 11 | import { AllSlices } from "@/stores/player/slices/types"; 12 | 13 | export const usePlayerStore = create( 14 | immer((...a) => ({ 15 | ...createInterfaceSlice(...a), 16 | ...createProgressSlice(...a), 17 | ...createPlayingSlice(...a), 18 | ...createSourceSlice(...a), 19 | ...createDisplaySlice(...a), 20 | ...createCastingSlice(...a), 21 | ...createThumbnailSlice(...a), 22 | })), 23 | ); 24 | -------------------------------------------------------------------------------- /src/stores/quality/index.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | import { immer } from "zustand/middleware/immer"; 4 | 5 | import { SourceQuality } from "@/stores/player/utils/qualities"; 6 | 7 | export interface QualityStore { 8 | quality: { 9 | lastChosenQuality: SourceQuality | null; 10 | automaticQuality: boolean; 11 | }; 12 | setLastChosenQuality(v: SourceQuality | null): void; 13 | setAutomaticQuality(v: boolean): void; 14 | } 15 | 16 | export const useQualityStore = create( 17 | persist( 18 | immer((set) => ({ 19 | quality: { 20 | automaticQuality: true, 21 | lastChosenQuality: null, 22 | }, 23 | setLastChosenQuality(v) { 24 | set((s) => { 25 | s.quality.lastChosenQuality = v; 26 | }); 27 | }, 28 | setAutomaticQuality(v) { 29 | set((s) => { 30 | s.quality.automaticQuality = v; 31 | }); 32 | }, 33 | })), 34 | { 35 | name: "__MW::quality", 36 | }, 37 | ), 38 | ); 39 | -------------------------------------------------------------------------------- /src/components/text-inputs/AuthInputBox.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { TextInputControl } from "./TextInputControl"; 4 | 5 | export function AuthInputBox(props: { 6 | value?: string; 7 | label?: string; 8 | name?: string; 9 | autoComplete?: string; 10 | placeholder?: string; 11 | onChange?: (data: string) => void; 12 | passwordToggleable?: boolean; 13 | className?: string; 14 | }) { 15 | return ( 16 |
17 | {props.label ? ( 18 |

{props.label}

19 | ) : null} 20 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/player/hooks/useVolume.ts: -------------------------------------------------------------------------------- 1 | import { usePlayerStore } from "@/stores/player/store"; 2 | import { useVolumeStore } from "@/stores/volume"; 3 | 4 | export function useVolume() { 5 | const volume = usePlayerStore((s) => s.mediaPlaying.volume); 6 | const lastVolume = usePlayerStore((s) => s.interface.lastVolume); 7 | const setLastVolume = usePlayerStore((s) => s.setLastVolume); 8 | const display = usePlayerStore((s) => s.display); 9 | const setStoredVolume = useVolumeStore((s) => s.setVolume); 10 | 11 | const toggleVolume = () => { 12 | let newVolume = 0; 13 | 14 | if (volume > 0) { 15 | newVolume = 0; 16 | setLastVolume(volume); 17 | } else if (lastVolume > 0) newVolume = lastVolume; 18 | else newVolume = 1; 19 | 20 | display?.setVolume(newVolume); 21 | setStoredVolume(newVolume); 22 | }; 23 | 24 | return { 25 | toggleMute() { 26 | toggleVolume(); 27 | }, 28 | setVolume(vol: number) { 29 | setStoredVolume(vol); 30 | setLastVolume(vol); 31 | display?.setVolume(vol); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/pages/Dmca.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { Icon, Icons } from "@/components/Icon"; 4 | import { ThinContainer } from "@/components/layout/ThinContainer"; 5 | import { Heading1, Paragraph } from "@/components/utils/Text"; 6 | import { PageTitle } from "@/pages/parts/util/PageTitle"; 7 | import { conf } from "@/setup/config"; 8 | 9 | import { SubPageLayout } from "./layouts/SubPageLayout"; 10 | 11 | export function shouldHaveDmcaPage() { 12 | return !!conf().DMCA_EMAIL; 13 | } 14 | 15 | export function DmcaPage() { 16 | const { t } = useTranslation(); 17 | 18 | return ( 19 | 20 | 21 | 22 | {t("screens.dmca.title")} 23 | {t("screens.dmca.text")} 24 | 25 | 26 | {conf().DMCA_EMAIL ?? ""} 27 | 28 | 29 | 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/hooks/useRandomTranslation.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | // 10% chance of getting a joke title 5 | const shouldGiveJokeTitle = () => Math.floor(Math.random() * 10) === 0; 6 | 7 | export function useRandomTranslation() { 8 | const { t } = useTranslation(); 9 | const shouldJoke = useMemo(() => shouldGiveJokeTitle(), []); 10 | const seed = useMemo(() => Math.random(), []); 11 | 12 | const getRandomTranslation = useCallback( 13 | (key: string): string => { 14 | const defaultTitle = t(`${key}.default`) ?? ""; 15 | if (!shouldJoke) return defaultTitle; 16 | 17 | const keys = t(`${key}.extra`, { 18 | returnObjects: true, 19 | defaultValue: defaultTitle, 20 | }); 21 | if (Array.isArray(keys)) { 22 | if (keys.length === 0) return defaultTitle; 23 | return keys[Math.floor(seed * keys.length)]; 24 | } 25 | 26 | return typeof keys === "string" ? keys : defaultTitle; 27 | }, 28 | [t, seed, shouldJoke], 29 | ); 30 | 31 | return { t: getRandomTranslation }; 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/onboarding/onboardingHooks.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | 4 | import { useQueryParam } from "@/hooks/useQueryParams"; 5 | import { useOnboardingStore } from "@/stores/onboarding"; 6 | 7 | export function useRedirectBack() { 8 | const [url] = useQueryParam("redirect"); 9 | const navigate = useNavigate(); 10 | const setCompleted = useOnboardingStore((s) => s.setCompleted); 11 | 12 | const redirectBack = useCallback(() => { 13 | navigate(url ?? "/"); 14 | }, [navigate, url]); 15 | 16 | const completeAndRedirect = useCallback(() => { 17 | setCompleted(true); 18 | redirectBack(); 19 | }, [redirectBack, setCompleted]); 20 | 21 | return { completeAndRedirect }; 22 | } 23 | 24 | export function useNavigateOnboarding() { 25 | const navigate = useNavigate(); 26 | const loc = useLocation(); 27 | const nav = useCallback( 28 | (path: string) => { 29 | navigate({ 30 | pathname: path, 31 | search: loc.search, 32 | }); 33 | }, 34 | [navigate, loc], 35 | ); 36 | return nav; 37 | } 38 | -------------------------------------------------------------------------------- /src/backend/accounts/login.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { SessionResponse } from "@/backend/accounts/auth"; 4 | 5 | export interface ChallengeTokenResponse { 6 | challenge: string; 7 | } 8 | 9 | export async function getLoginChallengeToken( 10 | url: string, 11 | publicKey: string, 12 | ): Promise { 13 | return ofetch("/auth/login/start", { 14 | method: "POST", 15 | body: { 16 | publicKey, 17 | }, 18 | baseURL: url, 19 | }); 20 | } 21 | 22 | export interface LoginResponse { 23 | session: SessionResponse; 24 | token: string; 25 | } 26 | 27 | export interface LoginInput { 28 | publicKey: string; 29 | challenge: { 30 | code: string; 31 | signature: string; 32 | }; 33 | device: string; 34 | } 35 | 36 | export async function loginAccount( 37 | url: string, 38 | data: LoginInput, 39 | ): Promise { 40 | return ofetch("/auth/login/complete", { 41 | method: "POST", 42 | body: { 43 | namespace: "movie-web", 44 | ...data, 45 | }, 46 | baseURL: url, 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /src/components/layout/BrandPill.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { Icon, Icons } from "@/components/Icon"; 5 | 6 | export function BrandPill(props: { 7 | clickable?: boolean; 8 | hideTextOnMobile?: boolean; 9 | backgroundClass?: string; 10 | }) { 11 | const { t } = useTranslation(); 12 | 13 | return ( 14 |
23 | 24 | 30 | {t("global.name")} 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/player/internals/BookmarkButton.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | import { Icons } from "@/components/Icon"; 4 | import { useBookmarkStore } from "@/stores/bookmarks"; 5 | import { usePlayerStore } from "@/stores/player/store"; 6 | 7 | import { VideoPlayerButton } from "./Button"; 8 | 9 | export function BookmarkButton() { 10 | const addBookmark = useBookmarkStore((s) => s.addBookmark); 11 | const removeBookmark = useBookmarkStore((s) => s.removeBookmark); 12 | const bookmarks = useBookmarkStore((s) => s.bookmarks); 13 | const meta = usePlayerStore((s) => s.meta); 14 | const isBookmarked = !!bookmarks[meta?.tmdbId ?? ""]; 15 | 16 | const toggleBookmark = useCallback(() => { 17 | if (!meta) return; 18 | if (isBookmarked) removeBookmark(meta.tmdbId); 19 | else addBookmark(meta); 20 | }, [isBookmarked, meta, addBookmark, removeBookmark]); 21 | 22 | return ( 23 | toggleBookmark()} 25 | icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE} 26 | iconSizeClass="text-base" 27 | className="p-3" 28 | /> 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Hawkins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/parts/settings/RegisterCalloutPart.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { useNavigate } from "react-router-dom"; 3 | 4 | import { Button } from "@/components/buttons/Button"; 5 | import { SolidSettingsCard } from "@/components/layout/SettingsCard"; 6 | import { Heading3 } from "@/components/utils/Text"; 7 | 8 | export function RegisterCalloutPart() { 9 | const navigate = useNavigate(); 10 | const { t } = useTranslation(); 11 | 12 | return ( 13 |
14 | 18 |
19 | {t("settings.account.register.title")} 20 |

21 | {t("settings.account.register.text")} 22 |

23 |
24 |
25 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/backend/metadata/types/mw.ts: -------------------------------------------------------------------------------- 1 | export enum MWMediaType { 2 | MOVIE = "movie", 3 | SERIES = "series", 4 | ANIME = "anime", 5 | } 6 | 7 | export type MWSeasonMeta = { 8 | id: string; 9 | number: number; 10 | title: string; 11 | }; 12 | 13 | export type MWSeasonWithEpisodeMeta = { 14 | id: string; 15 | number: number; 16 | title: string; 17 | episodes: { 18 | id: string; 19 | number: number; 20 | title: string; 21 | air_date: string; 22 | }[]; 23 | }; 24 | 25 | type MWMediaMetaBase = { 26 | title: string; 27 | id: string; 28 | year?: string; 29 | poster?: string; 30 | }; 31 | 32 | type MWMediaMetaSpecific = 33 | | { 34 | type: MWMediaType.MOVIE | MWMediaType.ANIME; 35 | seasons: undefined; 36 | } 37 | | { 38 | type: MWMediaType.SERIES; 39 | seasons: MWSeasonMeta[]; 40 | seasonData: MWSeasonWithEpisodeMeta; 41 | }; 42 | 43 | export type MWMediaMeta = MWMediaMetaBase & MWMediaMetaSpecific; 44 | 45 | export interface MWQuery { 46 | searchQuery: string; 47 | } 48 | 49 | export interface DetailedMeta { 50 | meta: MWMediaMeta; 51 | imdbId?: string; 52 | tmdbId?: string; 53 | } 54 | -------------------------------------------------------------------------------- /src/components/layout/ProgressRing.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | className?: string; 3 | radius?: number; 4 | percentage: number; 5 | backingRingClassname?: string; 6 | } 7 | 8 | export function ProgressRing(props: Props) { 9 | const radius = props.radius ?? 40; 10 | 11 | return ( 12 | 16 | 24 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useQueryParams.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | import { useLocation, useNavigate } from "react-router-dom"; 3 | 4 | export function useQueryParams() { 5 | const loc = useLocation(); 6 | 7 | const queryParams = useMemo(() => { 8 | const obj: Record = Object.fromEntries( 9 | new URLSearchParams(loc.search).entries(), 10 | ); 11 | 12 | return obj; 13 | }, [loc.search]); 14 | 15 | return queryParams; 16 | } 17 | 18 | export function useQueryParam( 19 | param: string, 20 | ): [string | null, (a: string | null) => void] { 21 | const params = useQueryParams(); 22 | const location = useLocation(); 23 | const navigate = useNavigate(); 24 | const currentValue = params[param] ?? null; 25 | 26 | const set = useCallback( 27 | (value: string | null) => { 28 | const parsed = new URLSearchParams(location.search); 29 | if (value) parsed.set(param, value); 30 | else parsed.delete(param); 31 | navigate({ 32 | search: parsed.toString(), 33 | }); 34 | }, 35 | [param, location.search, navigate], 36 | ); 37 | 38 | return [currentValue, set]; 39 | } 40 | -------------------------------------------------------------------------------- /src/pages/parts/admin/ConfigValuesPart.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | 3 | import { Divider } from "@/components/utils/Divider"; 4 | import { Heading2 } from "@/components/utils/Text"; 5 | import { conf } from "@/setup/config"; 6 | 7 | function ConfigValue(props: { name: string; children?: ReactNode }) { 8 | return ( 9 | <> 10 |
11 |

{props.name}

12 |

{props.children}

13 |
14 | 15 | 16 | ); 17 | } 18 | 19 | export function ConfigValuesPart() { 20 | const normalRouter = conf().NORMAL_ROUTER; 21 | const appVersion = conf().APP_VERSION; 22 | const backendUrl = conf().BACKEND_URL; 23 | 24 | return ( 25 | <> 26 | Configured values 27 | 28 | {normalRouter ? "Normal routing" : "Hash based routing"} 29 | 30 | v{appVersion} 31 | {backendUrl} 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/stores/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { Helmet } from "react-helmet-async"; 3 | import { create } from "zustand"; 4 | import { persist } from "zustand/middleware"; 5 | import { immer } from "zustand/middleware/immer"; 6 | 7 | export interface ThemeStore { 8 | theme: string | null; 9 | setTheme(v: string | null): void; 10 | } 11 | 12 | export const useThemeStore = create( 13 | persist( 14 | immer((set) => ({ 15 | theme: null, 16 | setTheme(v) { 17 | set((s) => { 18 | s.theme = v; 19 | }); 20 | }, 21 | })), 22 | { 23 | name: "__MW::theme", 24 | }, 25 | ), 26 | ); 27 | 28 | export function ThemeProvider(props: { 29 | children?: ReactNode; 30 | applyGlobal?: boolean; 31 | }) { 32 | const theme = useThemeStore((s) => s.theme); 33 | const themeSelector = theme ? `theme-${theme}` : undefined; 34 | 35 | return ( 36 |
37 | {props.applyGlobal ? ( 38 | 39 | 40 | 41 | ) : null} 42 | {props.children} 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/player/hooks/useInitializePlayer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef } from "react"; 2 | 3 | import { usePlayerStore } from "@/stores/player/store"; 4 | import { useVolumeStore } from "@/stores/volume"; 5 | 6 | import { useCaptions } from "./useCaptions"; 7 | 8 | export function useInitializePlayer() { 9 | const display = usePlayerStore((s) => s.display); 10 | const volume = useVolumeStore((s) => s.volume); 11 | 12 | const init = useCallback(() => { 13 | display?.setVolume(volume); 14 | }, [display, volume]); 15 | 16 | return { 17 | init, 18 | }; 19 | } 20 | 21 | export function useInitializeSource() { 22 | const source = usePlayerStore((s) => s.source); 23 | const sourceIdentifier = useMemo( 24 | () => (source ? JSON.stringify(source) : null), 25 | [source], 26 | ); 27 | const { selectLastUsedLanguageIfEnabled } = useCaptions(); 28 | 29 | const funRef = useRef(selectLastUsedLanguageIfEnabled); 30 | useEffect(() => { 31 | funRef.current = selectLastUsedLanguageIfEnabled; 32 | }, [selectLastUsedLanguageIfEnabled]); 33 | 34 | useEffect(() => { 35 | if (sourceIdentifier) funRef.current(); 36 | }, [sourceIdentifier]); 37 | } 38 | -------------------------------------------------------------------------------- /src/stores/player/slices/playing.ts: -------------------------------------------------------------------------------- 1 | import { MakeSlice } from "@/stores/player/slices/types"; 2 | 3 | export interface PlayingSlice { 4 | mediaPlaying: { 5 | isPlaying: boolean; 6 | isPaused: boolean; 7 | isSeeking: boolean; // seeking with progress bar 8 | isDragSeeking: boolean; // is seeking for our custom progress bar 9 | isLoading: boolean; // buffering or not 10 | hasPlayedOnce: boolean; // has the video played at all? 11 | volume: number; 12 | playbackRate: number; 13 | }; 14 | play(): void; 15 | pause(): void; 16 | } 17 | 18 | export const createPlayingSlice: MakeSlice = (set) => ({ 19 | mediaPlaying: { 20 | isPlaying: false, 21 | isPaused: true, 22 | isLoading: false, 23 | isSeeking: false, 24 | isDragSeeking: false, 25 | hasPlayedOnce: false, 26 | volume: 1, 27 | playbackRate: 1, 28 | }, 29 | play() { 30 | set((state) => { 31 | state.mediaPlaying.isPlaying = true; 32 | state.mediaPlaying.isPaused = false; 33 | }); 34 | }, 35 | pause() { 36 | set((state) => { 37 | state.mediaPlaying.isPlaying = false; 38 | state.mediaPlaying.isPaused = false; 39 | }); 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/backend/accounts/settings.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { getAuthHeaders } from "@/backend/accounts/auth"; 4 | import { AccountWithToken } from "@/stores/auth"; 5 | 6 | export interface SettingsInput { 7 | applicationLanguage?: string; 8 | applicationTheme?: string | null; 9 | defaultSubtitleLanguage?: string; 10 | proxyUrls?: string[] | null; 11 | } 12 | 13 | export interface SettingsResponse { 14 | applicationTheme?: string | null; 15 | applicationLanguage?: string | null; 16 | defaultSubtitleLanguage?: string | null; 17 | proxyUrls?: string[] | null; 18 | } 19 | 20 | export function updateSettings( 21 | url: string, 22 | account: AccountWithToken, 23 | settings: SettingsInput, 24 | ) { 25 | return ofetch(`/users/${account.userId}/settings`, { 26 | method: "PUT", 27 | body: settings, 28 | baseURL: url, 29 | headers: getAuthHeaders(account.token), 30 | }); 31 | } 32 | 33 | export function getSettings(url: string, account: AccountWithToken) { 34 | return ofetch(`/users/${account.userId}/settings`, { 35 | method: "GET", 36 | baseURL: url, 37 | headers: getAuthHeaders(account.token), 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/useSearchQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { generatePath, useNavigate, useParams } from "react-router-dom"; 3 | 4 | function decode(query: string | null | undefined) { 5 | return query ? decodeURIComponent(query) : ""; 6 | } 7 | 8 | export function useSearchQuery(): [ 9 | string, 10 | (inp: string, force?: boolean) => void, 11 | () => void, 12 | ] { 13 | const navigate = useNavigate(); 14 | const params = useParams<{ query: string }>(); 15 | const [search, setSearch] = useState(decode(params.query)); 16 | 17 | useEffect(() => { 18 | setSearch(decode(params.query)); 19 | }, [params.query]); 20 | 21 | const updateParams = (inp: string, commitToUrl = false) => { 22 | setSearch(inp); 23 | if (!commitToUrl) return; 24 | if (inp.length === 0) { 25 | navigate("/", { replace: true }); 26 | return; 27 | } 28 | navigate( 29 | generatePath("/browse/:query", { 30 | query: inp, 31 | }), 32 | { replace: true }, 33 | ); 34 | }; 35 | 36 | const onUnFocus = (newSearch?: string) => { 37 | updateParams(newSearch ?? search, true); 38 | }; 39 | 40 | return [search, updateParams, onUnFocus]; 41 | } 42 | -------------------------------------------------------------------------------- /plugins/handlebars.ts: -------------------------------------------------------------------------------- 1 | import { globSync } from "glob"; 2 | import { viteStaticCopy } from 'vite-plugin-static-copy' 3 | import { PluginOption } from "vite"; 4 | import Handlebars from "handlebars"; 5 | import path from "path"; 6 | 7 | export const handlebars = (options: { vars?: Record } = {}): PluginOption[] => { 8 | const files = globSync("src/assets/**/**.hbs"); 9 | 10 | function render(content: string): string { 11 | const template = Handlebars.compile(content); 12 | return template(options?.vars ?? {}); 13 | } 14 | 15 | return [ 16 | { 17 | name: 'hbs-templating', 18 | enforce: "pre", 19 | transformIndexHtml: { 20 | order: 'pre', 21 | handler(html) { 22 | return render(html); 23 | } 24 | }, 25 | }, 26 | viteStaticCopy({ 27 | silent: true, 28 | targets: files.map(file => ({ 29 | src: file, 30 | dest: '', 31 | rename: path.basename(file).slice(0, -4), // remove .hbs file extension 32 | transform: { 33 | encoding: 'utf8', 34 | handler(content: string) { 35 | return render(content); 36 | } 37 | } 38 | })) 39 | }) 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/backend/helpers/subs.ts: -------------------------------------------------------------------------------- 1 | import { list } from "subsrt-ts"; 2 | 3 | import { proxiedFetch } from "@/backend/helpers/fetch"; 4 | import { convertSubtitlesToSrt } from "@/components/player/utils/captions"; 5 | import { CaptionListItem } from "@/stores/player/slices/source"; 6 | import { SimpleCache } from "@/utils/cache"; 7 | 8 | export const subtitleTypeList = list().map((type) => `.${type}`); 9 | const downloadCache = new SimpleCache(); 10 | downloadCache.setCompare((a, b) => a === b); 11 | const expirySeconds = 24 * 60 * 60; 12 | 13 | /** 14 | * Always returns SRT 15 | */ 16 | export async function downloadCaption( 17 | caption: CaptionListItem, 18 | ): Promise { 19 | const cached = downloadCache.get(caption.url); 20 | if (cached) return cached; 21 | 22 | let data: string | undefined; 23 | if (caption.needsProxy) { 24 | data = await proxiedFetch(caption.url, { responseType: "text" }); 25 | } else { 26 | data = await fetch(caption.url).then((v) => v.text()); 27 | } 28 | if (!data) throw new Error("failed to get caption data"); 29 | 30 | const output = convertSubtitlesToSrt(data); 31 | downloadCache.set(caption.url, output, expirySeconds); 32 | return output; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/player/atoms/Skips.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | import { Icons } from "@/components/Icon"; 4 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 5 | import { usePlayerStore } from "@/stores/player/store"; 6 | 7 | export function SkipForward(props: { iconSizeClass?: string }) { 8 | const display = usePlayerStore((s) => s.display); 9 | const time = usePlayerStore((s) => s.progress.time); 10 | 11 | const commit = useCallback(() => { 12 | display?.setTime(time + 10); 13 | }, [display, time]); 14 | 15 | return ( 16 | 21 | ); 22 | } 23 | 24 | export function SkipBackward(props: { iconSizeClass?: string }) { 25 | const display = usePlayerStore((s) => s.display); 26 | const time = usePlayerStore((s) => s.progress.time); 27 | 28 | const commit = useCallback(() => { 29 | display?.setTime(time - 10); 30 | }, [display, time]); 31 | 32 | return ( 33 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/hooks/usePing.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from "react"; 2 | 3 | import { useBannerStore } from "@/stores/banner"; 4 | 5 | export function useOnlineListener() { 6 | const updateOnline = useBannerStore((s) => s.updateOnline); 7 | const ref = useRef(true); 8 | 9 | useEffect(() => { 10 | let counter = 0; 11 | 12 | let abort: null | AbortController = null; 13 | const interval = setInterval(() => { 14 | // if online try once every 10 iterations intead of every iteration 15 | counter += 1; 16 | if (ref.current) { 17 | if (counter < 10) return; 18 | } 19 | counter = 0; 20 | 21 | if (abort) abort.abort(); 22 | abort = new AbortController(); 23 | const signal = abort.signal; 24 | fetch("/ping.txt", { signal }) 25 | .then(() => { 26 | updateOnline(true); 27 | ref.current = true; 28 | }) 29 | .catch((err) => { 30 | if (err.name === "AbortError") return; 31 | updateOnline(false); 32 | ref.current = false; 33 | }); 34 | }, 5000); 35 | 36 | return () => { 37 | clearInterval(interval); 38 | if (abort) abort.abort(); 39 | }; 40 | }, [updateOnline]); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/player/internals/Button.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { forwardRef } from "react"; 3 | 4 | import { Icon, Icons } from "@/components/Icon"; 5 | 6 | export interface VideoPlayerButtonProps { 7 | children?: React.ReactNode; 8 | onClick?: (el: HTMLButtonElement) => void; 9 | icon?: Icons; 10 | iconSizeClass?: string; 11 | className?: string; 12 | activeClass?: string; 13 | } 14 | 15 | export const VideoPlayerButton = forwardRef< 16 | HTMLButtonElement, 17 | VideoPlayerButtonProps 18 | >((props, ref) => { 19 | return ( 20 | 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /src/backend/metadata/search.ts: -------------------------------------------------------------------------------- 1 | import { SimpleCache } from "@/utils/cache"; 2 | import { MediaItem } from "@/utils/mediaTypes"; 3 | 4 | import { 5 | formatTMDBMetaToMediaItem, 6 | formatTMDBSearchResult, 7 | multiSearch, 8 | } from "./tmdb"; 9 | import { MWQuery } from "./types/mw"; 10 | 11 | const cache = new SimpleCache(); 12 | cache.setCompare((a, b) => { 13 | return a.searchQuery.trim() === b.searchQuery.trim(); 14 | }); 15 | cache.initialize(); 16 | 17 | export async function searchForMedia(query: MWQuery): Promise { 18 | if (cache.has(query)) return cache.get(query) as MediaItem[]; 19 | const { searchQuery } = query; 20 | 21 | const data = await multiSearch(searchQuery); 22 | const results = data.map((v) => { 23 | const formattedResult = formatTMDBSearchResult(v, v.media_type); 24 | return formatTMDBMetaToMediaItem(formattedResult); 25 | }); 26 | 27 | const movieWithPosters = results.filter((movie) => movie.poster); 28 | const movieWithoutPosters = results.filter((movie) => !movie.poster); 29 | 30 | const sortedresult = movieWithPosters.concat(movieWithoutPosters); 31 | 32 | // cache results for 1 hour 33 | cache.set(query, sortedresult, 3600); 34 | return sortedresult; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/buttons/EditButton.tsx: -------------------------------------------------------------------------------- 1 | import { useAutoAnimate } from "@formkit/auto-animate/react"; 2 | import { useCallback } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import { Icon, Icons } from "@/components/Icon"; 6 | 7 | export interface EditButtonProps { 8 | editing: boolean; 9 | onEdit?: (editing: boolean) => void; 10 | } 11 | 12 | export function EditButton(props: EditButtonProps) { 13 | const { t } = useTranslation(); 14 | const [parent] = useAutoAnimate(); 15 | 16 | const onClick = useCallback(() => { 17 | props.onEdit?.(!props.editing); 18 | }, [props]); 19 | 20 | return ( 21 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /plugins/figmaTokensToThemeTokens.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This script turns output from the figma plugin "style to JSON" into a usuable theme. 3 | * It expects a format of "themes/{NAME}/anythinghere" 4 | */ 5 | 6 | import fs from "fs"; 7 | 8 | const fileLocation = "./figmaTokens.json"; 9 | const theme = "blue"; 10 | 11 | const fileContents = fs.readFileSync(fileLocation, { 12 | encoding: "utf-8" 13 | }); 14 | const tokens = JSON.parse(fileContents); 15 | 16 | const themeTokens = tokens.themes[theme]; 17 | const output = {}; 18 | 19 | function setKey(obj, key, defaultVal) { 20 | const realKey = key.match(/^\d+$/g) ? "c" + key : key; 21 | if (obj[key]) return obj[key]; 22 | obj[realKey] = defaultVal; 23 | return obj[realKey]; 24 | } 25 | 26 | function handleToken(token, path) { 27 | if (typeof token.name === "string" && typeof token.description === "string") { 28 | let ref = output; 29 | const lastKey = path.pop(); 30 | path.forEach((v) => { 31 | ref = setKey(ref, v, {}); 32 | }); 33 | setKey(ref, lastKey, token.hex); 34 | return; 35 | } 36 | 37 | for (let key in token) { 38 | handleToken(token[key], [...path, key]); 39 | } 40 | } 41 | 42 | handleToken(themeTokens, []); 43 | console.log(JSON.stringify(output, null, 2)); 44 | -------------------------------------------------------------------------------- /src/utils/events.ts: -------------------------------------------------------------------------------- 1 | export type EventMap = Record; 2 | type EventKey = string & keyof T; 3 | type EventReceiver = (params: T) => void; 4 | 5 | export interface Emitter { 6 | on>(eventName: K, fn: EventReceiver): void; 7 | off>(eventName: K, fn: EventReceiver): void; 8 | emit>(eventName: K, params: T[K]): void; 9 | } 10 | 11 | export interface Listener { 12 | on>(eventName: K, fn: EventReceiver): void; 13 | off>(eventName: K, fn: EventReceiver): void; 14 | } 15 | 16 | export function makeEmitter(): Emitter { 17 | const listeners: Partial< 18 | Record, ((...params: any[]) => void)[]> 19 | > = {}; 20 | 21 | return { 22 | on(eventName, fn) { 23 | if (!listeners[eventName]) listeners[eventName] = []; 24 | listeners[eventName]?.push(fn); 25 | }, 26 | off(eventName, fn) { 27 | listeners[eventName] = 28 | listeners[eventName]?.filter((v) => v !== fn) ?? []; 29 | }, 30 | emit(eventName, params) { 31 | (listeners[eventName] ?? []).forEach((fn) => fn(params)); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/components/player/hooks/useShouldShowControls.tsx: -------------------------------------------------------------------------------- 1 | import { PlayerHoverState } from "@/stores/player/slices/interface"; 2 | import { usePlayerStore } from "@/stores/player/store"; 3 | 4 | export function useShouldShowControls() { 5 | const hovering = usePlayerStore((s) => s.interface.hovering); 6 | const lastHoveringState = usePlayerStore( 7 | (s) => s.interface.lastHoveringState, 8 | ); 9 | const isPaused = usePlayerStore((s) => s.mediaPlaying.isPaused); 10 | const hasOpenOverlay = usePlayerStore((s) => s.interface.hasOpenOverlay); 11 | const isHoveringControls = usePlayerStore( 12 | (s) => s.interface.isHoveringControls, 13 | ); 14 | 15 | const isUsingTouch = lastHoveringState === PlayerHoverState.MOBILE_TAPPED; 16 | const isHovering = hovering !== PlayerHoverState.NOT_HOVERING; 17 | 18 | // when using touch, pause screens can be dismissed by tapping 19 | const showTargetsWithoutPause = 20 | isHovering || (isHoveringControls && !isUsingTouch) || hasOpenOverlay; 21 | const showTargetsIncludingPause = showTargetsWithoutPause || isPaused; 22 | const showTargets = isUsingTouch 23 | ? showTargetsWithoutPause 24 | : showTargetsIncludingPause; 25 | 26 | return { 27 | showTouchTargets: isUsingTouch && showTargets, 28 | showTargets, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/parts/auth/PassphraseGeneratePart.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { genMnemonic } from "@/backend/accounts/crypto"; 5 | import { Button } from "@/components/buttons/Button"; 6 | import { PassphraseDisplay } from "@/components/form/PassphraseDisplay"; 7 | import { Icon, Icons } from "@/components/Icon"; 8 | import { 9 | LargeCard, 10 | LargeCardButtons, 11 | LargeCardText, 12 | } from "@/components/layout/LargeCard"; 13 | 14 | interface PassphraseGeneratePartProps { 15 | onNext?: (mnemonic: string) => void; 16 | } 17 | 18 | export function PassphraseGeneratePart(props: PassphraseGeneratePartProps) { 19 | const mnemonic = useMemo(() => genMnemonic(), []); 20 | const { t } = useTranslation(); 21 | 22 | return ( 23 | 24 | } 27 | > 28 | {t("auth.generate.description")} 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/stores/overlay/store.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { immer } from "zustand/middleware/immer"; 3 | 4 | export interface OverlayTransition { 5 | from: string; 6 | to: string; 7 | } 8 | 9 | export interface OverlayRoute { 10 | id: string; 11 | height: number; 12 | width: number; 13 | } 14 | 15 | export interface ActiveAnchorPoint { 16 | x: number; 17 | y: number; 18 | w: number; 19 | h: number; 20 | } 21 | 22 | interface OverlayStore { 23 | transition: null | OverlayTransition; 24 | routes: Record; 25 | anchorPoint: ActiveAnchorPoint | null; 26 | setTransition(newTrans: OverlayTransition | null): void; 27 | registerRoute(route: OverlayRoute): void; 28 | setAnchorPoint(point: ActiveAnchorPoint | null): void; 29 | } 30 | 31 | export const useOverlayStore = create( 32 | immer((set) => ({ 33 | transition: null, 34 | routes: {}, 35 | anchorPoint: null, 36 | setTransition(newTrans) { 37 | set((s) => { 38 | s.transition = newTrans; 39 | }); 40 | }, 41 | registerRoute(route) { 42 | set((s) => { 43 | s.routes[route.id] = route; 44 | }); 45 | }, 46 | setAnchorPoint(point) { 47 | set((s) => { 48 | s.anchorPoint = point; 49 | }); 50 | }, 51 | })), 52 | ); 53 | -------------------------------------------------------------------------------- /src/components/buttons/IconPatch.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Icons } from "@/components/Icon"; 2 | 3 | export interface IconPatchProps { 4 | active?: boolean; 5 | onClick?: () => void; 6 | clickable?: boolean; 7 | className?: string; 8 | icon: Icons; 9 | transparent?: boolean; 10 | downsized?: boolean; 11 | } 12 | 13 | export function IconPatch(props: IconPatchProps) { 14 | const clickableClasses = props.clickable 15 | ? "cursor-pointer hover:scale-110 hover:bg-pill-backgroundHover hover:text-white active:scale-125" 16 | : ""; 17 | const transparentClasses = props.transparent 18 | ? "bg-opacity-0 hover:bg-opacity-50" 19 | : ""; 20 | const activeClasses = props.active 21 | ? "bg-pill-backgroundHover text-white" 22 | : ""; 23 | const sizeClasses = props.downsized ? "h-10 w-10" : "h-12 w-12"; 24 | 25 | return ( 26 |
27 |
30 | 31 |
32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/backend/accounts/sessions.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { getAuthHeaders } from "@/backend/accounts/auth"; 4 | import { AccountWithToken } from "@/stores/auth"; 5 | 6 | export interface SessionResponse { 7 | id: string; 8 | userId: string; 9 | createdAt: string; 10 | accessedAt: string; 11 | device: string; 12 | userAgent: string; 13 | } 14 | 15 | export interface SessionUpdate { 16 | deviceName: string; 17 | } 18 | 19 | export async function getSessions(url: string, account: AccountWithToken) { 20 | return ofetch(`/users/${account.userId}/sessions`, { 21 | headers: getAuthHeaders(account.token), 22 | baseURL: url, 23 | }); 24 | } 25 | 26 | export async function updateSession( 27 | url: string, 28 | account: AccountWithToken, 29 | update: SessionUpdate, 30 | ) { 31 | return ofetch(`/sessions/${account.sessionId}`, { 32 | method: "PATCH", 33 | headers: getAuthHeaders(account.token), 34 | body: update, 35 | baseURL: url, 36 | }); 37 | } 38 | 39 | export async function removeSession( 40 | url: string, 41 | token: string, 42 | sessionId: string, 43 | ) { 44 | return ofetch(`/sessions/${sessionId}`, { 45 | method: "DELETE", 46 | headers: getAuthHeaders(token), 47 | baseURL: url, 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /src/backend/accounts/register.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { SessionResponse } from "@/backend/accounts/auth"; 4 | import { UserResponse } from "@/backend/accounts/user"; 5 | 6 | export interface ChallengeTokenResponse { 7 | challenge: string; 8 | } 9 | 10 | export async function getRegisterChallengeToken( 11 | url: string, 12 | captchaToken?: string, 13 | ): Promise { 14 | return ofetch("/auth/register/start", { 15 | method: "POST", 16 | body: { 17 | captchaToken, 18 | }, 19 | baseURL: url, 20 | }); 21 | } 22 | 23 | export interface RegisterResponse { 24 | user: UserResponse; 25 | session: SessionResponse; 26 | token: string; 27 | } 28 | 29 | export interface RegisterInput { 30 | publicKey: string; 31 | challenge: { 32 | code: string; 33 | signature: string; 34 | }; 35 | device: string; 36 | profile: { 37 | colorA: string; 38 | colorB: string; 39 | icon: string; 40 | }; 41 | } 42 | 43 | export async function registerAccount( 44 | url: string, 45 | data: RegisterInput, 46 | ): Promise { 47 | return ofetch("/auth/register/complete", { 48 | method: "POST", 49 | body: { 50 | namespace: "movie-web", 51 | ...data, 52 | }, 53 | baseURL: url, 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /src/components/form/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { Icon, Icons } from "../Icon"; 4 | 5 | const colors = ["#0A54FF", "#CF2E68", "#F9DD7F", "#7652DD", "#2ECFA8"]; 6 | export const initialColor = colors[0]; 7 | 8 | export function ColorPicker(props: { 9 | label: string; 10 | value: string; 11 | onInput: (v: string) => void; 12 | }) { 13 | return ( 14 |
15 | {props.label ? ( 16 |

{props.label}

17 | ) : null} 18 | 19 |
20 | {colors.map((color) => { 21 | return ( 22 | 35 | ); 36 | })} 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/backend/extension/streams.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "@movie-web/providers"; 2 | 3 | import { setDomainRule } from "@/backend/extension/messaging"; 4 | 5 | function extractDomain(url: string): string | null { 6 | try { 7 | const u = new URL(url); 8 | return u.hostname; 9 | } catch { 10 | return null; 11 | } 12 | } 13 | 14 | function extractDomainsFromStream(stream: Stream): string[] { 15 | if (stream.type === "hls") { 16 | return [extractDomain(stream.playlist)].filter((v): v is string => !!v); 17 | } 18 | if (stream.type === "file") { 19 | return Object.values(stream.qualities) 20 | .map((v) => extractDomain(v.url)) 21 | .filter((v): v is string => !!v); 22 | } 23 | return []; 24 | } 25 | 26 | function buildHeadersFromStream(stream: Stream): Record { 27 | const headers: Record = {}; 28 | Object.entries(stream.headers ?? {}).forEach((entry) => { 29 | headers[entry[0]] = entry[1]; 30 | }); 31 | Object.entries(stream.preferredHeaders ?? {}).forEach((entry) => { 32 | headers[entry[0]] = entry[1]; 33 | }); 34 | return headers; 35 | } 36 | 37 | export async function prepareStream(stream: Stream) { 38 | await setDomainRule({ 39 | ruleId: 1, 40 | targetDomains: extractDomainsFromStream(stream), 41 | requestHeaders: buildHeadersFromStream(stream), 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/utils/Ol.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function Ol(props: { items: React.ReactNode[] }) { 4 | return ( 5 |
    6 | {props.items.map((child, i) => { 7 | return ( 8 |
  1. 14 |
    15 |
    16 | {i + 1} 17 |
    18 | {i !== props.items.length - 1 ? ( 19 |
    28 | ) : null} 29 |
    30 |
    {child}
    31 |
  2. 32 | ); 33 | })} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/stores/__old/watched/store.ts: -------------------------------------------------------------------------------- 1 | import { useProgressStore } from "@/stores/progress"; 2 | 3 | import { OldData, migrateV2Videos } from "./migrations/v2"; 4 | import { migrateV3Videos } from "./migrations/v3"; 5 | import { migrateV4Videos } from "./migrations/v4"; 6 | import { WatchedStoreData } from "./types"; 7 | import { createVersionedStore } from "../migrations"; 8 | 9 | export const VideoProgressStore = createVersionedStore() 10 | .setKey("video-progress") 11 | .addVersion({ 12 | version: 0, 13 | migrate() { 14 | return { 15 | items: [], // dont migrate from version 0 to version 1, unmigratable 16 | }; 17 | }, 18 | }) 19 | .addVersion({ 20 | version: 1, 21 | async migrate(old: OldData) { 22 | return migrateV2Videos(old); 23 | }, 24 | }) 25 | .addVersion({ 26 | version: 2, 27 | migrate(old: WatchedStoreData) { 28 | return migrateV3Videos(old); 29 | }, 30 | }) 31 | .addVersion({ 32 | version: 3, 33 | migrate(old: WatchedStoreData): WatchedStoreData { 34 | useProgressStore.getState().replaceItems(migrateV4Videos(old)); 35 | 36 | return { 37 | items: [], 38 | }; 39 | }, 40 | }) 41 | .addVersion({ 42 | version: 4, 43 | create() { 44 | return { 45 | items: [], 46 | }; 47 | }, 48 | }) 49 | .build(); 50 | -------------------------------------------------------------------------------- /src/stores/subtitles/SettingsSyncer.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { updateSettings } from "@/backend/accounts/settings"; 4 | import { useBackendUrl } from "@/hooks/auth/useBackendUrl"; 5 | import { useAuthStore } from "@/stores/auth"; 6 | import { useSubtitleStore } from "@/stores/subtitles"; 7 | 8 | const syncIntervalMs = 5 * 1000; 9 | 10 | export function SettingsSyncer() { 11 | const importSubtitleLanguage = useSubtitleStore( 12 | (s) => s.importSubtitleLanguage, 13 | ); 14 | const url = useBackendUrl(); 15 | 16 | useEffect(() => { 17 | const interval = setInterval(() => { 18 | (async () => { 19 | const state = useSubtitleStore.getState(); 20 | const user = useAuthStore.getState(); 21 | if (state.lastSync.lastSelectedLanguage === state.lastSelectedLanguage) 22 | return; // only sync if there is a difference 23 | if (!user.account) return; 24 | if (!state.lastSelectedLanguage) return; 25 | await updateSettings(url, user.account, { 26 | defaultSubtitleLanguage: state.lastSelectedLanguage, 27 | }); 28 | importSubtitleLanguage(state.lastSelectedLanguage); 29 | })(); 30 | }, syncIntervalMs); 31 | 32 | return () => { 33 | clearInterval(interval); 34 | }; 35 | }, [importSubtitleLanguage, url]); 36 | 37 | return null; 38 | } 39 | -------------------------------------------------------------------------------- /src/components/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { Icon, Icons } from "@/components/Icon"; 4 | 5 | export function SidebarSection(props: { 6 | title: string; 7 | children: React.ReactNode; 8 | className?: string; 9 | }) { 10 | return ( 11 |
12 |

13 | {props.title} 14 |

15 | {props.children} 16 |
17 | ); 18 | } 19 | 20 | export function SidebarLink(props: { 21 | children: React.ReactNode; 22 | icon: Icons; 23 | active?: boolean; 24 | onClick?: () => void; 25 | }) { 26 | return ( 27 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/player/base/BottomControls.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | import { Transition } from "@/components/utils/Transition"; 4 | import { usePlayerStore } from "@/stores/player/store"; 5 | 6 | export function BottomControls(props: { 7 | show?: boolean; 8 | children: React.ReactNode; 9 | }) { 10 | const setHoveringAnyControls = usePlayerStore( 11 | (s) => s.setHoveringAnyControls, 12 | ); 13 | 14 | useEffect(() => { 15 | return () => { 16 | setHoveringAnyControls(false); 17 | }; 18 | }, [setHoveringAnyControls]); 19 | 20 | return ( 21 |
22 | 27 |
setHoveringAnyControls(true)} 29 | onMouseOut={() => setHoveringAnyControls(false)} 30 | className="pointer-events-auto z-10 pl-[calc(2rem+env(safe-area-inset-left))] pr-[calc(2rem+env(safe-area-inset-right))] pb-3 mb-[env(safe-area-inset-bottom)] absolute bottom-0 w-full" 31 | > 32 | 33 | {props.children} 34 | 35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/components/player/README.md: -------------------------------------------------------------------------------- 1 | # Video player component 2 | 3 | Video player is quite a complex component, so here is a rundown of all the parts 4 | 5 | # Composable parts 6 | These parts can be used to build any shape of a video player. 7 | - `/atoms`- any ui element that controls the player. (Seekbar, Pause button, quality selection, etc) 8 | - `/base` - base components that are used to build a player. Like the main container 9 | 10 | # internal parts 11 | These parts are internally used, they aren't exported. Do not use them outside of player internals. 12 | 13 | ### `/display` 14 | The display interface, abstraction on how to actually play the content (e.g Video element, chrome casting, etc) 15 | - It must be completely separate from any react code 16 | - It must not interact with state, pass async data back with events 17 | 18 | ### `/internals` 19 | Internal components that are always rendered on every player. 20 | - Only components that are always present on the player instance, they must never unmount 21 | 22 | ### `/utils` 23 | miscellaneous logic, put anything that is unique to the video player internals. 24 | 25 | ### `/hooks` 26 | Hooks only used for video player. 27 | - only exception is usePlayer, as its used outside of the player to control the player 28 | 29 | ### `~/src/stores/player` 30 | State for the video player. 31 | - Only parts related to the video player may utilize the state 32 | -------------------------------------------------------------------------------- /src/components/player/atoms/AutoPlayStart.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | import { Icon, Icons } from "@/components/Icon"; 4 | import { playerStatus } from "@/stores/player/slices/source"; 5 | import { usePlayerStore } from "@/stores/player/store"; 6 | 7 | export function AutoPlayStart() { 8 | const display = usePlayerStore((s) => s.display); 9 | const isPlaying = usePlayerStore((s) => s.mediaPlaying.isPlaying); 10 | const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading); 11 | const hasPlayedOnce = usePlayerStore((s) => s.mediaPlaying.hasPlayedOnce); 12 | const status = usePlayerStore((s) => s.status); 13 | 14 | const handleClick = useCallback(() => { 15 | display?.play(); 16 | }, [display]); 17 | 18 | if (hasPlayedOnce) return null; 19 | if (isPlaying) return null; 20 | if (isLoading) return null; 21 | if (status !== playerStatus.PLAYING) return null; 22 | 23 | return ( 24 |
28 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/stores/player/slices/casting.ts: -------------------------------------------------------------------------------- 1 | import { MakeSlice } from "@/stores/player/slices/types"; 2 | 3 | export interface CastingSlice { 4 | casting: { 5 | instance: cast.framework.CastContext | null; 6 | player: cast.framework.RemotePlayer | null; 7 | controller: cast.framework.RemotePlayerController | null; 8 | setInstance(instance: cast.framework.CastContext): void; 9 | setPlayer(player: cast.framework.RemotePlayer): void; 10 | setController(controller: cast.framework.RemotePlayerController): void; 11 | setIsCasting(isCasting: boolean): void; 12 | clear(): void; 13 | }; 14 | } 15 | 16 | export const createCastingSlice: MakeSlice = (set) => ({ 17 | casting: { 18 | instance: null, 19 | player: null, 20 | controller: null, 21 | setInstance(instance) { 22 | set((s) => { 23 | s.casting.instance = instance; 24 | }); 25 | }, 26 | setPlayer(player) { 27 | set((s) => { 28 | s.casting.player = player; 29 | }); 30 | }, 31 | setController(controller) { 32 | set((s) => { 33 | s.casting.controller = controller; 34 | }); 35 | }, 36 | setIsCasting(isCasting) { 37 | set((s) => { 38 | s.interface.isCasting = isCasting; 39 | }); 40 | }, 41 | clear() { 42 | set((s) => { 43 | s.casting.instance = null; 44 | }); 45 | }, 46 | }, 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/overlays/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useCallback } from "react"; 2 | import { Helmet } from "react-helmet-async"; 3 | 4 | import { OverlayPortal } from "@/components/overlays/OverlayDisplay"; 5 | import { useQueryParam } from "@/hooks/useQueryParams"; 6 | 7 | export function useModal(id: string) { 8 | const [currentModal, setCurrentModal] = useQueryParam("m"); 9 | const show = useCallback(() => setCurrentModal(id), [id, setCurrentModal]); 10 | const hide = useCallback(() => setCurrentModal(null), [setCurrentModal]); 11 | return { 12 | id, 13 | isShown: currentModal === id, 14 | show, 15 | hide, 16 | }; 17 | } 18 | 19 | export function ModalCard(props: { children?: ReactNode }) { 20 | return ( 21 |
22 |
23 | {props.children} 24 |
25 |
26 | ); 27 | } 28 | 29 | export function Modal(props: { id: string; children?: ReactNode }) { 30 | const modal = useModal(props.id); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 |
38 | {props.children} 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { allThemes, defaultTheme, safeThemeList } from "./themes"; 2 | import type { Config } from "tailwindcss"; 3 | import plugin from "tailwindcss/plugin"; 4 | 5 | const themer = require("tailwindcss-themer"); 6 | 7 | const config: Config = { 8 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 9 | safelist: safeThemeList, 10 | theme: { 11 | extend: { 12 | /* breakpoints */ 13 | screens: { 14 | ssm: "400px", 15 | }, 16 | 17 | /* fonts */ 18 | fontFamily: { 19 | "open-sans": "'Open Sans'", 20 | }, 21 | 22 | /* animations */ 23 | keyframes: { 24 | "loading-pin": { 25 | "0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" }, 26 | "20%": { height: "1em", "background-color": "white" }, 27 | }, 28 | }, 29 | animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }, 30 | }, 31 | }, 32 | plugins: [ 33 | require("tailwind-scrollbar"), 34 | themer({ 35 | defaultTheme: defaultTheme, 36 | themes: [ 37 | { 38 | name: "default", 39 | selectors: [".theme-default"], 40 | ...defaultTheme, 41 | }, 42 | ...allThemes, 43 | ], 44 | }), 45 | plugin(({ addVariant }) => { 46 | addVariant("dir-neutral", "[dir] &"); 47 | }), 48 | ], 49 | }; 50 | 51 | export default config; 52 | -------------------------------------------------------------------------------- /src/components/form/IconPicker.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { UserIcon, UserIcons } from "../UserIcon"; 4 | 5 | const icons = [ 6 | UserIcons.USER_GROUP, 7 | UserIcons.COUCH, 8 | UserIcons.MOBILE, 9 | UserIcons.TICKET, 10 | UserIcons.HANDCUFFS, 11 | ]; 12 | export const initialIcon = icons[0]; 13 | 14 | export function IconPicker(props: { 15 | label: string; 16 | value: UserIcons; 17 | onInput: (v: UserIcons) => void; 18 | }) { 19 | return ( 20 |
21 | {props.label ? ( 22 |

{props.label}

23 | ) : null} 24 | 25 |
26 | {icons.map((icon) => { 27 | return ( 28 | 42 | ); 43 | })} 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/stores/language/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Helmet } from "react-helmet-async"; 3 | import { create } from "zustand"; 4 | import { persist } from "zustand/middleware"; 5 | import { immer } from "zustand/middleware/immer"; 6 | 7 | import i18n from "@/setup/i18n"; 8 | import { getLocaleInfo } from "@/utils/language"; 9 | 10 | export interface LanguageStore { 11 | language: string; 12 | setLanguage(v: string): void; 13 | } 14 | 15 | export const useLanguageStore = create( 16 | persist( 17 | immer((set) => ({ 18 | language: "en", 19 | setLanguage(v) { 20 | set((s) => { 21 | s.language = v; 22 | }); 23 | }, 24 | })), 25 | { name: "__MW::locale" }, 26 | ), 27 | ); 28 | 29 | export function changeAppLanguage(language: string) { 30 | const lang = getLocaleInfo(language); 31 | if (lang) i18n.changeLanguage(lang.code); 32 | } 33 | 34 | export function isRightToLeft(language: string) { 35 | const lang = getLocaleInfo(language); 36 | if (!lang) return false; 37 | return lang.isRtl; 38 | } 39 | 40 | export function LanguageProvider() { 41 | const language = useLanguageStore((s) => s.language); 42 | 43 | useEffect(() => { 44 | changeAppLanguage(language); 45 | }, [language]); 46 | 47 | const isRtl = isRightToLeft(language); 48 | 49 | return ( 50 | 51 | 52 | 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/pages/layouts/SubPageLayout.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | import { FooterView } from "@/components/layout/Footer"; 4 | import { Navigation } from "@/components/layout/Navigation"; 5 | 6 | export function BlurEllipsis(props: { positionClass?: string }) { 7 | return ( 8 | <> 9 | {/* Blur elipsis */} 10 |
16 |
22 | 23 | ); 24 | } 25 | 26 | export function SubPageLayout(props: { children: React.ReactNode }) { 27 | return ( 28 |
35 | 36 | {/* Main page */} 37 | 38 | 39 |
{props.children}
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/backend/metadata/types/justwatch.ts: -------------------------------------------------------------------------------- 1 | export type JWContentTypes = "movie" | "show"; 2 | 3 | export type JWSearchQuery = { 4 | content_types: JWContentTypes[]; 5 | page: number; 6 | page_size: number; 7 | query: string; 8 | }; 9 | 10 | export type JWPage = { 11 | items: T[]; 12 | page: number; 13 | page_size: number; 14 | total_pages: number; 15 | total_results: number; 16 | }; 17 | 18 | export const JW_API_BASE = "https://apis.justwatch.com"; 19 | export const JW_IMAGE_BASE = "https://images.justwatch.com"; 20 | 21 | export type JWSeasonShort = { 22 | title: string; 23 | id: number; 24 | season_number: number; 25 | }; 26 | 27 | export type JWEpisodeShort = { 28 | title: string; 29 | id: number; 30 | episode_number: number; 31 | }; 32 | 33 | export type JWMediaResult = { 34 | title: string; 35 | poster?: string; 36 | id: number; 37 | original_release_year?: number; 38 | jw_entity_id: string; 39 | object_type: JWContentTypes; 40 | seasons?: JWSeasonShort[]; 41 | }; 42 | 43 | export type JWSeasonMetaResult = { 44 | title: string; 45 | id: string; 46 | season_number: number; 47 | episodes: JWEpisodeShort[]; 48 | }; 49 | 50 | export type JWExternalIdType = 51 | | "eidr" 52 | | "imdb_latest" 53 | | "imdb" 54 | | "tmdb_latest" 55 | | "tmdb" 56 | | "tms"; 57 | 58 | export interface JWExternalId { 59 | provider: JWExternalIdType; 60 | external_id: string; 61 | } 62 | 63 | export interface JWDetailedMeta extends JWMediaResult { 64 | external_ids: JWExternalId[]; 65 | } 66 | -------------------------------------------------------------------------------- /src/pages/parts/errors/NotFoundPart.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "react-helmet-async"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { Button } from "@/components/buttons/Button"; 5 | import { Icons } from "@/components/Icon"; 6 | import { IconPill } from "@/components/layout/IconPill"; 7 | import { Navigation } from "@/components/layout/Navigation"; 8 | import { Title } from "@/components/text/Title"; 9 | import { Paragraph } from "@/components/utils/Text"; 10 | import { ErrorContainer, ErrorLayout } from "@/pages/layouts/ErrorLayout"; 11 | 12 | export function NotFoundPart() { 13 | const { t } = useTranslation(); 14 | 15 | return ( 16 |
17 | 18 | {t("notFound.badge")} 19 | 20 | 21 |
22 | 23 | 24 | {t("notFound.badge")} 25 | {t("notFound.title")} 26 | {t("notFound.message")} 27 | 35 | 36 | 37 |
38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | 3 | import { ThinContainer } from "@/components/layout/ThinContainer"; 4 | import { Ol } from "@/components/utils/Ol"; 5 | import { Heading1, Heading2, Paragraph } from "@/components/utils/Text"; 6 | import { PageTitle } from "@/pages/parts/util/PageTitle"; 7 | 8 | import { SubPageLayout } from "./layouts/SubPageLayout"; 9 | 10 | function Question(props: { title: string; children: React.ReactNode }) { 11 | return ( 12 | <> 13 |

{props.title}

14 |
{props.children}
15 | 16 | ); 17 | } 18 | 19 | export function AboutPage() { 20 | const { t } = useTranslation(); 21 | return ( 22 | 23 | 24 | 25 | {t("about.title")} 26 | {t("about.description")} 27 | {t("about.faqTitle")} 28 |
    31 | {t("about.q1.body")} 32 | , 33 | 34 | {t("about.q2.body")} 35 | , 36 | 37 | {t("about.q3.body")} 38 | , 39 | ]} 40 | /> 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: ["bug", "awaiting-approval"] 5 | assignees: [] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | Thanks for taking the time to fill out this bug report! 11 | 12 | Please fill out with as much detail as possible 13 | - type: textarea 14 | id: what-happened 15 | attributes: 16 | label: What happened? 17 | description: Also tell us, what did you expect to happen? 18 | placeholder: Tell us what you see! 19 | validations: 20 | required: true 21 | - type: dropdown 22 | id: browsers 23 | attributes: 24 | label: What browsers are you seeing the problem on? 25 | multiple: true 26 | options: 27 | - Firefox 28 | - Chrome 29 | - Safari 30 | - Microsoft Edge 31 | - Other (tell us in input box below) 32 | - type: textarea 33 | id: reproduce 34 | attributes: 35 | label: Steps to reproduce? 36 | description: What steps have you taken to see the bug? (OPTIONAL) 37 | placeholder: 1. ... 38 | validations: 39 | required: false 40 | - type: textarea 41 | id: other-info 42 | attributes: 43 | label: Other relevant information 44 | description: | 45 | Feel free to give us any more information that doesn't fit the above text boxes. 46 | 47 | Tip: You can attach files by clicking this textbox and dragging in files 48 | validations: 49 | required: false 50 | 51 | -------------------------------------------------------------------------------- /src/components/utils/Text.tsx: -------------------------------------------------------------------------------- 1 | interface TextProps { 2 | className?: string; 3 | children: React.ReactNode; 4 | border?: boolean; 5 | } 6 | 7 | const borderClass = "pb-4 border-b border-utils-divider border-opacity-50"; 8 | 9 | export function Heading1(props: TextProps) { 10 | return ( 11 |

    18 | {props.children} 19 |

    20 | ); 21 | } 22 | 23 | export function Heading2(props: TextProps) { 24 | return ( 25 |

    32 | {props.children} 33 |

    34 | ); 35 | } 36 | 37 | export function Heading3(props: TextProps) { 38 | return ( 39 |

    46 | {props.children} 47 |

    48 | ); 49 | } 50 | 51 | export function Paragraph(props: TextProps) { 52 | return ( 53 |

    60 | {props.children} 61 |

    62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/media/WatchedMediaCard.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from "react"; 2 | 3 | import { useProgressStore } from "@/stores/progress"; 4 | import { 5 | ShowProgressResult, 6 | shouldShowProgress, 7 | } from "@/stores/progress/utils"; 8 | import { MediaItem } from "@/utils/mediaTypes"; 9 | 10 | import { MediaCard } from "./MediaCard"; 11 | 12 | function formatSeries(series?: ShowProgressResult | null) { 13 | if (!series || !series.episode || !series.season) return undefined; 14 | return { 15 | episode: series.episode?.number, 16 | season: series.season?.number, 17 | episodeId: series.episode?.id, 18 | seasonId: series.season?.id, 19 | }; 20 | } 21 | 22 | export interface WatchedMediaCardProps { 23 | media: MediaItem; 24 | closable?: boolean; 25 | onClose?: () => void; 26 | } 27 | 28 | export function WatchedMediaCard(props: WatchedMediaCardProps) { 29 | const progressItems = useProgressStore((s) => s.items); 30 | const item = useMemo(() => { 31 | return progressItems[props.media.id]; 32 | }, [progressItems, props.media]); 33 | const itemToDisplay = useMemo( 34 | () => (item ? shouldShowProgress(item) : null), 35 | [item], 36 | ); 37 | const percentage = itemToDisplay?.show 38 | ? (itemToDisplay.progress.watched / itemToDisplay.progress.duration) * 100 39 | : undefined; 40 | 41 | return ( 42 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/backend/accounts/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { ofetch } from "ofetch"; 2 | 3 | import { getAuthHeaders } from "@/backend/accounts/auth"; 4 | import { BookmarkResponse } from "@/backend/accounts/user"; 5 | import { AccountWithToken } from "@/stores/auth"; 6 | import { BookmarkMediaItem } from "@/stores/bookmarks"; 7 | 8 | export interface BookmarkMetaInput { 9 | title: string; 10 | year: number; 11 | poster?: string; 12 | type: string; 13 | } 14 | 15 | export interface BookmarkInput { 16 | tmdbId: string; 17 | meta: BookmarkMetaInput; 18 | } 19 | 20 | export function bookmarkMediaToInput( 21 | tmdbId: string, 22 | item: BookmarkMediaItem, 23 | ): BookmarkInput { 24 | return { 25 | meta: { 26 | title: item.title, 27 | type: item.type, 28 | poster: item.poster, 29 | year: item.year ?? 0, 30 | }, 31 | tmdbId, 32 | }; 33 | } 34 | 35 | export async function addBookmark( 36 | url: string, 37 | account: AccountWithToken, 38 | input: BookmarkInput, 39 | ) { 40 | return ofetch( 41 | `/users/${account.userId}/bookmarks/${input.tmdbId}`, 42 | { 43 | method: "POST", 44 | headers: getAuthHeaders(account.token), 45 | baseURL: url, 46 | body: input, 47 | }, 48 | ); 49 | } 50 | 51 | export async function removeBookmark( 52 | url: string, 53 | account: AccountWithToken, 54 | id: string, 55 | ) { 56 | return ofetch<{ tmdbId: string }>( 57 | `/users/${account.userId}/bookmarks/${id}`, 58 | { 59 | method: "DELETE", 60 | headers: getAuthHeaders(account.token), 61 | baseURL: url, 62 | }, 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/components/overlays/positions/OverlayMobilePosition.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter"; 6 | 7 | interface MobilePositionProps { 8 | children?: ReactNode; 9 | className?: string; 10 | } 11 | 12 | export function OverlayMobilePosition(props: MobilePositionProps) { 13 | const router = useInternalOverlayRouter("hello world :)"); 14 | const { t } = useTranslation(); 15 | 16 | return ( 17 |
    23 | {props.children} 24 | 25 | {/* Close button */} 26 | 33 | {/* Gradient to hide the progress */} 34 |
    35 |
    36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/linting_testing.yml: -------------------------------------------------------------------------------- 1 | name: Linting and Testing 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - dev 8 | pull_request: 9 | 10 | jobs: 11 | linting: 12 | name: Run Linters 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - uses: pnpm/action-setup@v2 20 | with: 21 | version: 8 22 | 23 | - name: Install Node.js 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: 'pnpm' 28 | 29 | - name: Install pnpm packages 30 | run: pnpm install 31 | 32 | - name: Run ESLint 33 | run: pnpm run lint 34 | 35 | building: 36 | name: Build project 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - name: Checkout code 41 | uses: actions/checkout@v4 42 | 43 | - uses: pnpm/action-setup@v2 44 | with: 45 | version: 8 46 | 47 | - name: Install Node.js 48 | uses: actions/setup-node@v4 49 | with: 50 | node-version: 20 51 | cache: 'pnpm' 52 | 53 | - name: Install pnpm packages 54 | run: pnpm install 55 | 56 | - name: Build Project 57 | run: pnpm run build 58 | 59 | docker: 60 | name: Build Docker 61 | runs-on: ubuntu-latest 62 | 63 | steps: 64 | - name: Checkout repository 65 | uses: actions/checkout@v4 66 | 67 | - name: Setup Docker buildx 68 | uses: docker/setup-buildx-action@v3 69 | 70 | - name: Build Docker image 71 | uses: docker/build-push-action@v5 72 | with: 73 | push: false 74 | context: . 75 | -------------------------------------------------------------------------------- /src/components/player/utils/convertRunoutputToSource.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "@movie-web/providers"; 2 | 3 | import { 4 | SourceFileStream, 5 | SourceQuality, 6 | SourceSliceSource, 7 | } from "@/stores/player/utils/qualities"; 8 | 9 | const allowedQualitiesMap: Record = { 10 | "4k": "4k", 11 | "1080": "1080", 12 | "480": "480", 13 | "360": "360", 14 | "720": "720", 15 | unknown: "unknown", 16 | }; 17 | const allowedQualities = Object.keys(allowedQualitiesMap); 18 | const allowedFileTypes = ["mp4"]; 19 | 20 | function isAllowedQuality(inp: string): inp is SourceQuality { 21 | return allowedQualities.includes(inp); 22 | } 23 | 24 | export function convertRunoutputToSource(out: { 25 | stream: Stream; 26 | }): SourceSliceSource { 27 | if (out.stream.type === "hls") { 28 | return { 29 | type: "hls", 30 | url: out.stream.playlist, 31 | preferredHeaders: out.stream.preferredHeaders, 32 | }; 33 | } 34 | if (out.stream.type === "file") { 35 | const qualities: Partial> = {}; 36 | Object.entries(out.stream.qualities).forEach((entry) => { 37 | if (!isAllowedQuality(entry[0])) { 38 | console.warn(`unrecognized quality: ${entry[0]}`); 39 | return; 40 | } 41 | if (!allowedFileTypes.includes(entry[1].type)) { 42 | console.warn(`unrecognized file type: ${entry[1].type}`); 43 | return; 44 | } 45 | qualities[entry[0]] = { 46 | type: entry[1].type, 47 | url: entry[1].url, 48 | }; 49 | }); 50 | return { 51 | type: "file", 52 | qualities, 53 | preferredHeaders: out.stream.preferredHeaders, 54 | }; 55 | } 56 | throw new Error("unrecognized type"); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/player/internals/ContextMenu/Sections.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | export function SectionTitle(props: { 5 | children: React.ReactNode; 6 | className?: string; 7 | }) { 8 | return ( 9 |

    15 | {props.children} 16 |

    17 | ); 18 | } 19 | 20 | export function Section(props: { 21 | children: React.ReactNode; 22 | className?: string; 23 | }) { 24 | return ( 25 |
    26 | {props.children} 27 |
    28 | ); 29 | } 30 | 31 | export function ScrollToActiveSection(props: { 32 | children: React.ReactNode; 33 | className?: string; 34 | loaded?: boolean; 35 | }) { 36 | const scrollingContainer = useRef(null); 37 | 38 | useEffect(() => { 39 | const active = 40 | scrollingContainer.current?.querySelector("[data-active-link]"); 41 | 42 | const boxRect = scrollingContainer.current?.getBoundingClientRect(); 43 | const activeLinkRect = active?.getBoundingClientRect(); 44 | if (!activeLinkRect || !boxRect) return; 45 | 46 | const activeYPos = activeLinkRect.top - boxRect.top; 47 | 48 | scrollingContainer.current?.scrollTo( 49 | 0, 50 | activeYPos - boxRect.height / 2 + activeLinkRect.height / 2, 51 | ); 52 | }, [props.loaded]); 53 | 54 | return ( 55 |
    59 | {props.children} 60 |
    61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/stores/history/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from "react"; 2 | import { useLocation } from "react-router-dom"; 3 | import { useEffectOnce } from "react-use"; 4 | import { create } from "zustand"; 5 | import { immer } from "zustand/middleware/immer"; 6 | 7 | interface HistoryRoute { 8 | path: string; 9 | } 10 | 11 | interface HistoryStore { 12 | routes: HistoryRoute[]; 13 | registerRoute(route: HistoryRoute): void; 14 | } 15 | 16 | export const useHistoryStore = create( 17 | immer((set) => ({ 18 | routes: [], 19 | registerRoute(route) { 20 | set((s) => { 21 | s.routes.push(route); 22 | }); 23 | }, 24 | })), 25 | ); 26 | 27 | export function useHistoryListener() { 28 | const location = useLocation(); 29 | const registerRoute = useHistoryStore((s) => s.registerRoute); 30 | useEffect(() => { 31 | registerRoute({ path: location.pathname }); 32 | }, [location.pathname, registerRoute]); 33 | 34 | useEffectOnce(() => { 35 | registerRoute({ path: location.pathname }); 36 | }); 37 | } 38 | 39 | export function useLastNonPlayerLink() { 40 | const routes = useHistoryStore((s) => s.routes); 41 | const location = useLocation(); 42 | const lastNonPlayerLink = useMemo(() => { 43 | const reversedRoutes = [...routes]; 44 | reversedRoutes.reverse(); 45 | const route = reversedRoutes.find( 46 | (v) => 47 | !v.path.startsWith("/media") && // cannot be a player link 48 | location.pathname !== v.path && // cannot be current link 49 | !v.path.startsWith("/s/") && // cannot be a quick search link 50 | !v.path.startsWith("/onboarding"), // cannot be an onboarding link 51 | ); 52 | return route?.path ?? "/"; 53 | }, [routes, location]); 54 | return lastNonPlayerLink; 55 | } 56 | -------------------------------------------------------------------------------- /src/components/layout/LargeCard.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | 3 | export function LargeCard(props: { 4 | children: React.ReactNode; 5 | top?: React.ReactNode; 6 | }) { 7 | return ( 8 |
    9 | {props.top ? ( 10 |
    11 | {props.top} 12 |
    13 | ) : null} 14 |
    15 | {props.children} 16 |
    17 |
    18 | ); 19 | } 20 | 21 | export function LargeCardText(props: { 22 | title: string; 23 | children?: React.ReactNode; 24 | icon?: React.ReactNode; 25 | }) { 26 | return ( 27 |
    28 |
    29 | {props.icon ? ( 30 |
    {props.icon}
    31 | ) : null} 32 |

    {props.title}

    33 | {props.children ? ( 34 |
    {props.children}
    35 | ) : null} 36 |
    37 |
    38 | ); 39 | } 40 | 41 | export function LargeCardButtons(props: { 42 | children: React.ReactNode; 43 | splitAlign?: boolean; 44 | }) { 45 | return ( 46 |
    47 |
    54 | {props.children} 55 |
    56 |
    57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/stores/banner/BannerLocation.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | import { Icon, Icons } from "@/components/Icon"; 5 | import { useBannerStore, useRegisterBanner } from "@/stores/banner"; 6 | 7 | export function Banner(props: { 8 | children: React.ReactNode; 9 | type: "error"; 10 | id: string; 11 | }) { 12 | const [ref] = useRegisterBanner(props.id); 13 | const styles = { 14 | error: "bg-[#C93957] text-white", 15 | }; 16 | const icons = { 17 | error: Icons.CIRCLE_EXCLAMATION, 18 | }; 19 | 20 | return ( 21 |
    22 |
    28 |
    29 | 30 |
    {props.children}
    31 |
    32 |
    33 |
    34 | ); 35 | } 36 | 37 | export function BannerLocation(props: { location?: string }) { 38 | const { t } = useTranslation(); 39 | const isOnline = useBannerStore((s) => s.isOnline); 40 | const setLocation = useBannerStore((s) => s.setLocation); 41 | const currentLocation = useBannerStore((s) => s.location); 42 | const loc = props.location ?? null; 43 | 44 | useEffect(() => { 45 | if (!loc) return; 46 | setLocation(loc); 47 | return () => { 48 | setLocation(null); 49 | }; 50 | }, [setLocation, loc]); 51 | 52 | if (currentLocation !== loc) return null; 53 | 54 | return ( 55 |
    56 | {!isOnline ? ( 57 | 58 | {t("navigation.banner.offline")} 59 | 60 | ) : null} 61 |
    62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/player/atoms/Chromecast.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from "react"; 2 | 3 | import { Icons } from "@/components/Icon"; 4 | import { VideoPlayerButton } from "@/components/player/internals/Button"; 5 | import { usePlayerStore } from "@/stores/player/store"; 6 | 7 | export interface ChromecastProps { 8 | className?: string; 9 | } 10 | 11 | export function Chromecast(props: ChromecastProps) { 12 | const [hidden, setHidden] = useState(false); 13 | const isCasting = usePlayerStore((s) => s.interface.isCasting); 14 | const ref = useRef(null); 15 | 16 | const setButtonVisibility = useCallback( 17 | (tag: HTMLElement) => { 18 | const isVisible = (tag.getAttribute("style") ?? "").includes("inline"); 19 | setHidden(!isVisible); 20 | }, 21 | [setHidden], 22 | ); 23 | 24 | useEffect(() => { 25 | const tag = ref.current?.querySelector("google-cast-launcher"); 26 | if (!tag) return; 27 | 28 | const observer = new MutationObserver(() => { 29 | setButtonVisibility(tag); 30 | }); 31 | 32 | observer.observe(tag, { attributes: true, attributeFilter: ["style"] }); 33 | setButtonVisibility(tag); 34 | 35 | return () => { 36 | observer.disconnect(); 37 | }; 38 | }, [setButtonVisibility]); 39 | 40 | return ( 41 |