├── .dockerignore ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ └── feature-request.yml ├── SECURITY.md ├── logo-dark.svg ├── logo-light.svg ├── pull_request_template.md └── workflows │ ├── deploying.yml │ ├── linting_testing.yml │ └── sync.yml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── docker-compose.yaml ├── example.env ├── index.html ├── package.json ├── plugins ├── .gitignore ├── figmaTokensToThemeTokens.mjs └── handlebars.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── prettierrc.js ├── public ├── _headers ├── _redirects ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── config.js ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── flags │ ├── galicia.svg │ ├── skull.svg │ └── tokiPona.svg ├── lightbar-images │ ├── fishie.png │ ├── santa.png │ └── snowflake.svg ├── mstile-150x150.png ├── ping.txt ├── robots.txt ├── safari-pinned-tab.svg └── splash_screens │ ├── 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 │ ├── 11__iPad_Pro__10.5__iPad_Pro_landscape.png │ ├── 11__iPad_Pro__10.5__iPad_Pro_portrait.png │ ├── 12.9__iPad_Pro_landscape.png │ ├── 12.9__iPad_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 │ ├── 8.3__iPad_Mini_landscape.png │ ├── 8.3__iPad_Mini_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_11_Pro_Max__iPhone_XS_Max_landscape.png │ ├── iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png │ ├── iPhone_11__iPhone_XR_landscape.png │ ├── iPhone_11__iPhone_XR_portrait.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png │ ├── iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_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_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png │ ├── iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_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 │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png │ ├── iPhone_15_Pro__iPhone_15__iPhone_14_Pro_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_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 │ └── icon.png ├── src ├── @types │ └── country-language.d.ts ├── assets │ ├── README.md │ ├── css │ │ └── index.css │ ├── languages.ts │ ├── locales │ │ ├── ar.json │ │ ├── bg.json │ │ ├── bn.json │ │ ├── ca.json │ │ ├── ca@valencia.json │ │ ├── cs.json │ │ ├── de.json │ │ ├── el.json │ │ ├── en.json │ │ ├── es.json │ │ ├── et.json │ │ ├── fa.json │ │ ├── fi-FI.json │ │ ├── fr.json │ │ ├── gl.json │ │ ├── gu.json │ │ ├── he.json │ │ ├── hi.json │ │ ├── id.json │ │ ├── is-IS.json │ │ ├── it.json │ │ ├── ja.json │ │ ├── km.json │ │ ├── ko.json │ │ ├── lv.json │ │ ├── minion.json │ │ ├── ne.json │ │ ├── nl.json │ │ ├── nv.json │ │ ├── pa.json │ │ ├── pirate.json │ │ ├── pl.json │ │ ├── pt-BR.json │ │ ├── pt-PT.json │ │ ├── ro.json │ │ ├── ru.json │ │ ├── sl.json │ │ ├── sv.json │ │ ├── ta.json │ │ ├── th.json │ │ ├── tok.json │ │ ├── tr.json │ │ ├── uk.json │ │ ├── vi.json │ │ ├── zh-Hant.json │ │ └── zh.json │ └── templates │ │ └── opensearch.xml.hbs ├── backend │ ├── accounts │ │ ├── auth.ts │ │ ├── bookmarks.ts │ │ ├── crypto.ts │ │ ├── import.ts │ │ ├── login.ts │ │ ├── meta.ts │ │ ├── progress.ts │ │ ├── register.ts │ │ ├── sessions.ts │ │ ├── settings.ts │ │ └── user.ts │ ├── extension │ │ ├── compatibility.ts │ │ ├── messaging.ts │ │ ├── plasmo.ts │ │ ├── request.ts │ │ └── streams.ts │ ├── helpers │ │ ├── fetch.ts │ │ ├── providerApi.ts │ │ ├── report.ts │ │ └── subs.ts │ ├── metadata │ │ ├── getmeta.ts │ │ ├── justwatch.ts │ │ ├── search.ts │ │ ├── tmdb.ts │ │ └── types │ │ │ ├── justwatch.ts │ │ │ ├── mw.ts │ │ │ └── tmdb.ts │ └── providers │ │ ├── fetchers.ts │ │ └── providers.ts ├── components │ ├── Avatar.tsx │ ├── DropFile.tsx │ ├── FlagIcon.tsx │ ├── Icon.tsx │ ├── LinksDropdown.tsx │ ├── UserIcon.tsx │ ├── buttons │ │ ├── Button.tsx │ │ ├── EditButton.tsx │ │ ├── IconPatch.tsx │ │ └── Toggle.tsx │ ├── form │ │ ├── ColorPicker.tsx │ │ ├── Dropdown.tsx │ │ ├── IconPicker.tsx │ │ ├── PassphraseDisplay.tsx │ │ └── SearchBar.tsx │ ├── layout │ │ ├── Box.tsx │ │ ├── BrandPill.tsx │ │ ├── Footer.tsx │ │ ├── IconPill.tsx │ │ ├── LargeCard.tsx │ │ ├── Loading.tsx │ │ ├── Navigation.tsx │ │ ├── ProgressRing.tsx │ │ ├── SectionHeading.tsx │ │ ├── SettingsCard.tsx │ │ ├── Sidebar.tsx │ │ ├── Spinner.css │ │ ├── Spinner.tsx │ │ ├── Stepper.tsx │ │ ├── ThinContainer.tsx │ │ └── WideContainer.tsx │ ├── media │ │ ├── MediaCard.tsx │ │ ├── MediaGrid.tsx │ │ └── WatchedMediaCard.tsx │ ├── overlays │ │ ├── Modal.tsx │ │ ├── OverlayAnchor.tsx │ │ ├── OverlayDisplay.tsx │ │ ├── OverlayPage.tsx │ │ ├── OverlayRouter.tsx │ │ └── positions │ │ │ ├── OverlayAnchorPosition.tsx │ │ │ └── OverlayMobilePosition.tsx │ ├── player │ │ ├── Player.tsx │ │ ├── README.md │ │ ├── atoms │ │ │ ├── Airplay.tsx │ │ │ ├── AutoPlayStart.tsx │ │ │ ├── CastingNotification.tsx │ │ │ ├── Chromecast.tsx │ │ │ ├── EpisodeTitle.tsx │ │ │ ├── Episodes.tsx │ │ │ ├── Fullscreen.tsx │ │ │ ├── LoadingSpinner.tsx │ │ │ ├── NextEpisodeButton.tsx │ │ │ ├── Pause.tsx │ │ │ ├── Pip.tsx │ │ │ ├── ProgressBar.tsx │ │ │ ├── Settings.tsx │ │ │ ├── Skips.tsx │ │ │ ├── Time.tsx │ │ │ ├── Title.tsx │ │ │ ├── Volume.tsx │ │ │ ├── VolumeChangedPopout.tsx │ │ │ ├── index.ts │ │ │ └── settings │ │ │ │ ├── AudioView.tsx │ │ │ │ ├── CaptionSettingsView.tsx │ │ │ │ ├── CaptionsView.tsx │ │ │ │ ├── Downloads.tsx │ │ │ │ ├── PlaybackSettingsView.tsx │ │ │ │ ├── QualityView.tsx │ │ │ │ ├── SettingsMenu.tsx │ │ │ │ └── SourceSelectingView.tsx │ │ ├── base │ │ │ ├── BackLink.tsx │ │ │ ├── BlackOverlay.tsx │ │ │ ├── BottomControls.tsx │ │ │ ├── CenterControls.tsx │ │ │ ├── CenterMobileControls.tsx │ │ │ ├── Container.tsx │ │ │ ├── LeftSideControls.tsx │ │ │ ├── SubtitleView.tsx │ │ │ └── TopControls.tsx │ │ ├── display │ │ │ ├── base.ts │ │ │ ├── chromecast.ts │ │ │ └── displayInterface.ts │ │ ├── hooks │ │ │ ├── useCaptions.ts │ │ │ ├── useInitializePlayer.ts │ │ │ ├── usePlayer.ts │ │ │ ├── usePlayerMeta.ts │ │ │ ├── useShouldShowControls.tsx │ │ │ ├── useSlashFocus.ts │ │ │ ├── useSourceSelection.ts │ │ │ └── useVolume.ts │ │ ├── index.tsx │ │ ├── internals │ │ │ ├── BookmarkButton.tsx │ │ │ ├── Button.tsx │ │ │ ├── CastingInternal.tsx │ │ │ ├── ContextMenu │ │ │ │ ├── Cards.tsx │ │ │ │ ├── Input.tsx │ │ │ │ ├── Links.tsx │ │ │ │ ├── Misc.tsx │ │ │ │ ├── Sections.tsx │ │ │ │ └── index.ts │ │ │ ├── HeadUpdater.tsx │ │ │ ├── KeyboardEvents.tsx │ │ │ ├── MediaSession.tsx │ │ │ ├── MetaReporter.tsx │ │ │ ├── ProgressSaver.tsx │ │ │ ├── ScrapeCard.tsx │ │ │ ├── StatusCircle.tsx │ │ │ ├── ThumbnailScraper.tsx │ │ │ ├── VideoClickTarget.tsx │ │ │ └── VideoContainer.tsx │ │ └── utils │ │ │ ├── aired.ts │ │ │ ├── captions.ts │ │ │ ├── convertRunoutputToSource.ts │ │ │ ├── handleBuffered.ts │ │ │ ├── mediaErrorDetails.ts │ │ │ └── videoTracks.ts │ ├── text-inputs │ │ ├── AuthInputBox.tsx │ │ └── TextInputControl.tsx │ ├── text │ │ ├── ArrowLink.tsx │ │ ├── DotList.tsx │ │ ├── HeroTitle.tsx │ │ ├── Link.tsx │ │ ├── Paragraph.tsx │ │ ├── SecondaryLabel.tsx │ │ └── Title.tsx │ └── utils │ │ ├── Divider.tsx │ │ ├── ErrorLine.tsx │ │ ├── Flare.css │ │ ├── Flare.tsx │ │ ├── Lightbar.css │ │ ├── Lightbar.tsx │ │ ├── Ol.tsx │ │ ├── Text.tsx │ │ └── Transition.tsx ├── hooks │ ├── auth │ │ ├── useAuth.ts │ │ ├── useAuthData.ts │ │ ├── useAuthRestore.ts │ │ └── useBackendUrl.ts │ ├── useChromecastAvailable.ts │ ├── useDebounce.ts │ ├── useIsMobile.ts │ ├── useOverlayRouter.ts │ ├── usePing.ts │ ├── useProgressBar.ts │ ├── useProviderScrape.tsx │ ├── useQueryParams.ts │ ├── useRandomTranslation.ts │ ├── useSearchQuery.ts │ └── useSettingsState.ts ├── index.tsx ├── pages │ ├── About.tsx │ ├── DeveloperPage.tsx │ ├── Dmca.tsx │ ├── HomePage.tsx │ ├── Login.tsx │ ├── PlayerView.tsx │ ├── Register.tsx │ ├── Settings.tsx │ ├── admin │ │ └── AdminPage.tsx │ ├── developer │ │ ├── TestView.tsx │ │ └── VideoTesterView.tsx │ ├── errors │ │ ├── ErrorBoundary.tsx │ │ └── NotFoundPage.tsx │ ├── layouts │ │ ├── ErrorLayout.tsx │ │ ├── HomeLayout.tsx │ │ ├── MinimalPageLayout.tsx │ │ ├── PageLayout.tsx │ │ └── SubPageLayout.tsx │ ├── onboarding │ │ ├── Onboarding.tsx │ │ ├── OnboardingExtension.tsx │ │ ├── OnboardingProxy.tsx │ │ ├── onboardingHooks.ts │ │ └── utils.tsx │ └── parts │ │ ├── admin │ │ ├── BackendTestPart.tsx │ │ ├── ConfigValuesPart.tsx │ │ ├── TMDBTestPart.tsx │ │ └── WorkerTestPart.tsx │ │ ├── auth │ │ ├── AccountCreatePart.tsx │ │ ├── LoginFormPart.tsx │ │ ├── PassphraseGeneratePart.tsx │ │ ├── TrustBackendPart.tsx │ │ └── VerifyPassphrasePart.tsx │ │ ├── errors │ │ ├── ErrorCard.tsx │ │ ├── ErrorPart.tsx │ │ └── NotFoundPart.tsx │ │ ├── home │ │ ├── BookmarksPart.tsx │ │ ├── HeroPart.tsx │ │ └── WatchingPart.tsx │ │ ├── migrations │ │ └── MigrationPart.tsx │ │ ├── player │ │ ├── MetaPart.tsx │ │ ├── PlaybackErrorPart.tsx │ │ ├── PlayerPart.tsx │ │ ├── ScrapeErrorPart.tsx │ │ └── ScrapingPart.tsx │ │ ├── search │ │ ├── SearchListPart.tsx │ │ └── SearchLoadingPart.tsx │ │ ├── settings │ │ ├── AccountActionsPart.tsx │ │ ├── AccountEditPart.tsx │ │ ├── CaptionsPart.tsx │ │ ├── ConnectionsPart.tsx │ │ ├── DeviceListPart.tsx │ │ ├── PreferencesPart.tsx │ │ ├── ProfileEditModal.tsx │ │ ├── RegisterCalloutPart.tsx │ │ ├── SetupPart.tsx │ │ ├── SidebarPart.tsx │ │ └── ThemePart.tsx │ │ └── util │ │ ├── LargeTextPart.tsx │ │ ├── PageTitle.tsx │ │ └── WarningPart.tsx ├── setup │ ├── App.tsx │ ├── Layout.tsx │ ├── chromecast.ts │ ├── config.ts │ ├── constants.ts │ ├── ga.ts │ ├── i18n.ts │ └── pwa.ts ├── stores │ ├── __old │ │ ├── DONT_TOUCH_THIS_FOLDER │ │ ├── bookmark │ │ │ ├── store.ts │ │ │ └── types.ts │ │ ├── imports.ts │ │ ├── migrations.ts │ │ ├── settings │ │ │ ├── store.ts │ │ │ └── types.ts │ │ ├── utils.ts │ │ ├── volume │ │ │ └── store.ts │ │ └── watched │ │ │ ├── migrations │ │ │ ├── v2.ts │ │ │ ├── v3.ts │ │ │ └── v4.ts │ │ │ ├── store.ts │ │ │ └── types.ts │ ├── auth │ │ └── index.ts │ ├── banner │ │ ├── BannerLocation.tsx │ │ └── index.ts │ ├── bookmarks │ │ ├── BookmarkSyncer.tsx │ │ └── index.ts │ ├── history │ │ └── index.ts │ ├── language │ │ └── index.tsx │ ├── onboarding │ │ └── index.tsx │ ├── overlay │ │ └── store.ts │ ├── player │ │ ├── slices │ │ │ ├── casting.ts │ │ │ ├── display.ts │ │ │ ├── interface.ts │ │ │ ├── playing.ts │ │ │ ├── progress.ts │ │ │ ├── source.ts │ │ │ ├── thumbnails.ts │ │ │ └── types.ts │ │ ├── store.ts │ │ ├── types.ts │ │ └── utils │ │ │ └── qualities.ts │ ├── preferences │ │ └── index.tsx │ ├── progress │ │ ├── ProgressSyncer.tsx │ │ ├── index.ts │ │ └── utils.ts │ ├── quality │ │ └── index.ts │ ├── subtitles │ │ ├── SettingsSyncer.tsx │ │ └── index.ts │ ├── theme │ │ └── index.tsx │ ├── turnstile │ │ └── index.tsx │ └── volume │ │ └── index.ts └── utils │ ├── autoplay.ts │ ├── cache.ts │ ├── cdn.ts │ ├── detectFeatures.ts │ ├── events.ts │ ├── extension.ts │ ├── formatSeconds.ts │ ├── language.ts │ ├── mediaTypes.ts │ ├── onboarding.ts │ ├── proxyUrls.ts │ ├── timestamp.ts │ └── typeguard.ts ├── tailwind.config.ts ├── themes ├── all.ts ├── default.ts ├── index.ts ├── list │ ├── blue.ts │ ├── gray.ts │ ├── red.ts │ └── teal.ts └── types.ts ├── tsconfig.json ├── vercel.json └── vite.config.mts /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | build 4 | .env.local 5 | .github 6 | .vscode 7 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @movie-web/project-leads 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: Any other details to share? 25 | validations: 26 | required: false 27 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The latest version of movie-web is the only version that is supported, as it is the only version that is being actively developed. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | You can contact the movie-web maintainers to report a vulnerability: 10 | - Report the vulnerability in the [movie-web Discord server](https://movie-web.github.io/links/discord) 11 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/sync.yml: -------------------------------------------------------------------------------- 1 | name: Sync fork 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | schedule: 8 | - cron: "0 0 * * *" 9 | workflow_dispatch: 10 | 11 | jobs: 12 | sync: 13 | name: Sync fork 14 | runs-on: ubuntu-latest 15 | if: ${{ github.event.repository.fork }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Sync fork 22 | run: gh repo sync ${{ github.repository }} 23 | env: 24 | GH_TOKEN: ${{ github.token }} 25 | 26 | - uses: gautamkrishnar/keepalive-workflow@v1 27 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "editorconfig.editorconfig" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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 | ARG PWA_ENABLED="false" 12 | ARG GA_ID 13 | ARG APP_DOMAIN 14 | ARG OPENSEARCH_ENABLED="false" 15 | ARG TMDB_READ_API_KEY 16 | ARG CORS_PROXY_URL 17 | ARG DMCA_EMAIL 18 | ARG NORMAL_ROUTER="false" 19 | ARG BACKEND_URL 20 | ARG HAS_ONBOARDING="false" 21 | ARG ONBOARDING_CHROME_EXTENSION_INSTALL_LINK 22 | ARG ONBOARDING_PROXY_INSTALL_LINK 23 | ARG DISALLOWED_IDS 24 | ARG CDN_REPLACEMENTS 25 | ARG TURNSTILE_KEY 26 | ARG ALLOW_AUTOPLAY="false" 27 | 28 | ENV VITE_PWA_ENABLED=${PWA_ENABLED} 29 | ENV VITE_GA_ID=${GA_ID} 30 | ENV VITE_APP_DOMAIN=${APP_DOMAIN} 31 | ENV VITE_OPENSEARCH_ENABLED=${OPENSEARCH_ENABLED} 32 | ENV VITE_TMDB_READ_API_KEY=${TMDB_READ_API_KEY} 33 | ENV VITE_CORS_PROXY_URL=${CORS_PROXY_URL} 34 | ENV VITE_DMCA_EMAIL=${DMCA_EMAIL} 35 | ENV VITE_NORMAL_ROUTER=${NORMAL_ROUTER} 36 | ENV VITE_BACKEND_URL=${BACKEND_URL} 37 | ENV VITE_HAS_ONBOARDING=${HAS_ONBOARDING} 38 | ENV VITE_ONBOARDING_CHROME_EXTENSION_INSTALL_LINK=${ONBOARDING_CHROME_EXTENSION_INSTALL_LINK} 39 | ENV VITE_ONBOARDING_PROXY_INSTALL_LINK=${ONBOARDING_PROXY_INSTALL_LINK} 40 | ENV VITE_DISALLOWED_IDS=${DISALLOWED_IDS} 41 | ENV VITE_CDN_REPLACEMENTS=${CDN_REPLACEMENTS} 42 | ENV VITE_TURNSTILE_KEY=${TURNSTILE_KEY} 43 | ENV VITE_ALLOW_AUTOPLAY=${ALLOW_AUTOPLAY} 44 | 45 | COPY . ./ 46 | RUN pnpm run build 47 | 48 | # production environment 49 | FROM nginx:stable-alpine 50 | COPY --from=build /app/dist /usr/share/nginx/html 51 | EXPOSE 80 52 | CMD ["nginx", "-g", "daemon off;"] 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | 5 | movieweb: 6 | build: 7 | context: . 8 | # args: 9 | # PWA_ENABLED: "false" 10 | # GA_ID: "" 11 | # APP_DOMAIN: "" 12 | # OPENSEARCH_ENABLED: "false" 13 | # TMDB_READ_API_KEY: "" 14 | # CORS_PROXY_URL: "" 15 | # DMCA_EMAIL: "" 16 | # NORMAL_ROUTER: "false" 17 | # BACKEND_URL: "" 18 | # HAS_ONBOARDING: "false" 19 | # ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: "" 20 | # ONBOARDING_PROXY_INSTALL_LINK: "" 21 | # DISALLOWED_IDS: "" 22 | # CDN_REPLACEMENTS: "" 23 | # TURNSTILE_KEY: "" 24 | ports: 25 | - "80:80" 26 | restart: unless-stopped 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /plugins/.gitignore: -------------------------------------------------------------------------------- 1 | figmaTokens.json 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "all", 3 | singleQuote: true 4 | }; 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /assets/* /assets/:splat 200 2 | /* /index.html 200 3 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #120f1d 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/config.js: -------------------------------------------------------------------------------- 1 | window.__CONFIG__ = { 2 | // The URL for the CORS proxy, the URL must NOT end with a slash! 3 | // If not specified, the onboarding will not allow a "default setup". The user will have to use the extension or set up a proxy themselves 4 | VITE_CORS_PROXY_URL: "", 5 | 6 | // The READ API key to access TMDB 7 | VITE_TMDB_READ_API_KEY: "", 8 | 9 | // The DMCA email displayed in the footer, null to hide the DMCA link 10 | VITE_DMCA_EMAIL: null, 11 | 12 | // Whether to disable hash-based routing, leave this as false if you don't know what this is 13 | VITE_NORMAL_ROUTER: false, 14 | 15 | // The backend URL to communicate with 16 | VITE_BACKEND_URL: null, 17 | 18 | // A comma separated list of disallowed IDs in the case of a DMCA claim - in the format "series-" and "movie-" 19 | VITE_DISALLOWED_IDS: "", 20 | }; 21 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/favicon.ico -------------------------------------------------------------------------------- /public/flags/skull.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/flags/tokiPona.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/lightbar-images/fishie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/lightbar-images/fishie.png -------------------------------------------------------------------------------- /public/lightbar-images/santa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/lightbar-images/santa.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/ping.txt: -------------------------------------------------------------------------------- 1 | pong 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | Created by potrace 1.14, written by Peter Selinger 2001-2017 -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.2__iPad_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.2__iPad_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.2__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.5__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.5__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.5__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.9__iPad_Air_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/10.9__iPad_Air_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/10.9__iPad_Air_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/11__iPad_Pro__10.5__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/12.9__iPad_Pro_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/12.9__iPad_Pro_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/12.9__iPad_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/4__iPhone_SE__iPod_touch_5th_generation_and_later_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/8.3__iPad_Mini_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/8.3__iPad_Mini_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/8.3__iPad_Mini_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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/9.7__iPad_Pro__7.9__iPad_mini__9.7__iPad_Air__9.7__iPad_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_11_Pro_Max__iPhone_XS_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_11__iPhone_XR_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_11__iPhone_XR_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_11__iPhone_XR_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_13_mini__iPhone_12_mini__iPhone_11_Pro__iPhone_XS__iPhone_X_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_14_Plus__iPhone_13_Pro_Max__iPhone_12_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_landscape.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_14__iPhone_13_Pro__iPhone_13__iPhone_12_Pro__iPhone_12_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_15_Pro__iPhone_15__iPhone_14_Pro_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_8_Plus__iPhone_7_Plus__iPhone_6s_Plus__iPhone_6_Plus_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/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/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/iPhone_8__iPhone_7__iPhone_6s__iPhone_6__4.7__iPhone_SE_portrait.png -------------------------------------------------------------------------------- /public/splash_screens/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ligmajohn/mw/f3dd80f42b0c2185b4133749f36a9fc78280c8d7/public/splash_screens/icon.png -------------------------------------------------------------------------------- /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/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/assets/locales/km.json: -------------------------------------------------------------------------------- 1 | { 2 | "about": { 3 | "description": "movie-web គឺ​ជា​កម្មវិធី​បណ្ដាញវែបសាយ​ដែល​ស្វែងរក​អ៊ីនធឺណិត​សម្រាប់​ការ​ផ្សាយ។ ក្រុមនេះមានគោលបំណងសម្រាប់វិធីសាស្រ្តតិចតួចបំផុតក្នុងការប្រើប្រាស់មាតិកា។", 4 | "faqTitle": "សំណួរទូទៅ", 5 | "q1": { 6 | "body": "movie-web មិនផ្ទុកមាតិកាណាមួយទេ។ នៅពេលអ្នកចុចលើអ្វីមួយដើម្បីមើល អ៊ីនធឺណិតត្រូវបានស្វែងរកសម្រាប់មេឌៀដែលបានជ្រើសរើស (នៅលើអេក្រង់ផ្ទុក និងក្នុងផ្ទាំង 'ប្រភពវីដេអូ' អ្នកអាចឃើញប្រភពណាមួយដែលអ្នកកំពុងប្រើ)។ ប្រព័ន្ធផ្សព្វផ្សាយមិនដែលត្រូវបានបង្ហោះដោយគេហទំព័រភាពយន្តនោះទេ អ្វីគ្រប់យ៉ាងគឺតាមរយៈយន្តការស្វែងរកនេះ។", 7 | "title": "តើមាតិកាបានមកពីណា?" 8 | }, 9 | "q2": { 10 | "title": "តើខ្ញុំអាចស្នើសុំកម្មវិធី ឬ ភាពយន្តបាននៅឯណា?" 11 | }, 12 | "q3": { 13 | "body": "លទ្ធផលស្វែងរករបស់យើងត្រូវបានដំណើរការដោយ The Movie Database (TMDB) ហើយបង្ហាញដោយមិនខ្វល់ពីប្រភពរបស់យើងមានខ្លឹមសារឬ​អត់ទេ។", 14 | "title": "លទ្ធផលស្វែងរកបង្ហាញកម្មវិធី ឬ ភាពយន្ត ហេតុអ្វីខ្ញុំមិនអាចមើលបាន?" 15 | }, 16 | "title": "អំពី movie-web" 17 | }, 18 | "actions": { 19 | "copied": "បានចម្លង", 20 | "copy": "ចម្លង" 21 | }, 22 | "auth": { 23 | "createAccount": "មិនទាន់មានគណនីមែនទេ? <0>បង្កើតគណនី", 24 | "deviceNameLabel": "ឈ្មោះឧបករណ៍", 25 | "deviceNamePlaceholder": "ទូរស័ព្ទផ្ទាល់ខ្លួន", 26 | "generate": { 27 | "description": "ឃ្លាសម្ងាត់របស់អ្នកដើរតួជាឈ្មោះអ្នកប្រើប្រាស់ និងពាក្យសម្ងាត់របស់អ្នក។ ត្រូវប្រាកដថារក្សាវាឱ្យមានសុវត្ថិភាព ព្រោះអ្នកនឹងត្រូវបញ្ចូលវាដើម្បីចូលគណនីរបស់អ្នក", 28 | "next": "ខ្ញុំបានរក្សាទុកឃ្លាសម្ងាត់របស់ខ្ញុំរួចហើយ", 29 | "passphraseFrameLabel": "ឃ្លាសម្ងាត់", 30 | "title": "ឃ្លាសម្ងាត់របស់អ្នក" 31 | }, 32 | "hasAccount": "មានគណនីរួចហើយ? <0>ចូលទីនេះ", 33 | "login": { 34 | "description": "សូមបញ្ចូលឃ្លាសម្ងាត់របស់អ្នក ដើម្បីចូលគណនីរបស់អ្នក", 35 | "deviceLengthError": "សូមបញ្ចូលឈ្មោះឧបករណ៍" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /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/assets/templates/opensearch.xml.hbs: -------------------------------------------------------------------------------- 1 | 2 | movie-web 3 | The place for your favorite movies & shows 4 | UTF-8 5 | 6 | 7 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/backend/extension/plasmo.ts: -------------------------------------------------------------------------------- 1 | export interface ExtensionBaseRequest {} 2 | 3 | export type ExtensionBaseResponse = 4 | | ({ 5 | success: true; 6 | } & T) 7 | | { 8 | success: false; 9 | error: string; 10 | }; 11 | 12 | export type ExtensionHelloResponse = ExtensionBaseResponse<{ 13 | version: string; 14 | allowed: boolean; 15 | hasPermission: boolean; 16 | }>; 17 | 18 | export interface ExtensionMakeRequest extends ExtensionBaseRequest { 19 | url: string; 20 | method: string; 21 | headers?: Record; 22 | body?: string | Record; 23 | bodyType?: "string" | "FormData" | "URLSearchParams" | "object"; 24 | } 25 | 26 | export type ExtensionMakeRequestBodyType = ExtensionMakeRequest["bodyType"]; 27 | 28 | export type ExtensionMakeRequestResponse = ExtensionBaseResponse<{ 29 | response: { 30 | statusCode: number; 31 | headers: Record; 32 | finalUrl: string; 33 | body: T; 34 | }; 35 | }>; 36 | 37 | export interface ExtensionPrepareStreamRequest extends ExtensionBaseRequest { 38 | ruleId: number; 39 | targetDomains: string[]; 40 | requestHeaders?: Record; 41 | responseHeaders?: Record; 42 | } 43 | 44 | export interface MmMetadata { 45 | hello: { 46 | req: ExtensionBaseRequest; 47 | res: ExtensionHelloResponse; 48 | }; 49 | makeRequest: { 50 | req: ExtensionMakeRequest; 51 | res: ExtensionMakeRequestResponse; 52 | }; 53 | prepareStream: { 54 | req: ExtensionPrepareStreamRequest; 55 | res: ExtensionBaseResponse; 56 | }; 57 | openPage: { 58 | req: ExtensionBaseRequest & { 59 | page: string; 60 | redirectUrl: string; 61 | }; 62 | res: ExtensionBaseResponse; 63 | }; 64 | } 65 | 66 | interface MpMetadata {} 67 | 68 | declare module "@plasmohq/messaging" { 69 | interface MessagesMetadata extends MmMetadata {} 70 | interface PortsMetadata extends MpMetadata {} 71 | } 72 | -------------------------------------------------------------------------------- /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/backend/extension/streams.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from "@movie-web/providers"; 2 | 3 | import { RULE_IDS, 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: RULE_IDS.PREPARE_STREAM, 40 | targetDomains: extractDomainsFromStream(stream), 41 | requestHeaders: buildHeadersFromStream(stream), 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /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/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/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/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/DropFile.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { DragEvent, ReactNode } from "react"; 3 | 4 | interface FileDropHandlerProps { 5 | children: ReactNode; 6 | className: string; 7 | onDrop: (event: DragEvent) => void; 8 | onDraggingChange: (isDragging: boolean) => void; 9 | } 10 | 11 | export function FileDropHandler(props: FileDropHandlerProps) { 12 | const [dragging, setDragging] = useState(false); 13 | 14 | const handleDragEnter = (event: DragEvent) => { 15 | event.preventDefault(); 16 | setDragging(true); 17 | }; 18 | 19 | const handleDragLeave = (event: DragEvent) => { 20 | if (!event.currentTarget.contains(event.relatedTarget as Node)) { 21 | setDragging(false); 22 | } 23 | }; 24 | 25 | const handleDragOver = (event: DragEvent) => { 26 | event.preventDefault(); 27 | }; 28 | 29 | const handleDrop = (event: DragEvent) => { 30 | event.preventDefault(); 31 | setDragging(false); 32 | 33 | props.onDrop(event); 34 | }; 35 | 36 | useEffect(() => { 37 | props.onDraggingChange(dragging); 38 | }, [dragging, props]); 39 | 40 | return ( 41 |
48 | {props.children} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/components/overlays/OverlayPage.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { ReactNode, useEffect, useMemo } from "react"; 3 | 4 | import { 5 | Transition, 6 | TransitionAnimations, 7 | } from "@/components/utils/Transition"; 8 | import { useIsMobile } from "@/hooks/useIsMobile"; 9 | import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter"; 10 | import { useOverlayStore } from "@/stores/overlay/store"; 11 | 12 | interface Props { 13 | id: string; 14 | path: string; 15 | children?: ReactNode; 16 | className?: string; 17 | height: number; 18 | width: number; 19 | } 20 | 21 | export function OverlayPage(props: Props) { 22 | const router = useInternalOverlayRouter(props.id); 23 | const backwards = router.showBackwardsTransition(props.path); 24 | const show = router.isCurrentPage(props.path); 25 | const registerRoute = useOverlayStore((s) => s.registerRoute); 26 | const path = useMemo(() => router.makePath(props.path), [props.path, router]); 27 | const { isMobile } = useIsMobile(); 28 | 29 | useEffect(() => { 30 | registerRoute({ 31 | id: path, 32 | width: props.width, 33 | height: props.height, 34 | }); 35 | }, [props.height, props.width, path, registerRoute]); 36 | 37 | const width = !isMobile ? `${props.width}px` : "100%"; 38 | let animation: TransitionAnimations = "none"; 39 | if (backwards === "yes" || backwards === "no") 40 | animation = backwards === "yes" ? "slide-full-left" : "slide-full-right"; 41 | 42 | return ( 43 | 49 |
56 | {props.children} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 |