Compare commits

...

27 Commits

Author SHA1 Message Date
William Oldham
51e9c4d758 Add import and export functions for settings to JSON 2024-03-16 17:57:41 +00:00
William Oldham
bcfadc8f60 Add zod - for validation of uploaded settings 2024-03-16 17:51:10 +00:00
William Oldham
a642abc783 Add methods to directly set progress and bookmark items 2024-03-16 17:50:24 +00:00
William Oldham
558c6431fd Merge remote-tracking branch 'origin/dev' into settings-migration 2024-03-16 15:22:29 +00:00
William Oldham
2a0e46a97d Fix #1016 - Capitalisation of zh-Hant broke traditional Chinese 2024-03-16 12:54:59 +00:00
William Oldham
227defd713 Merge pull request #1017 from qtchaos/fix/setPositionState
Add check for setPositionState to avoid TypeError
2024-03-16 08:36:09 +00:00
William Oldham
f1a8ff4bf8 Merge pull request #1021 from Honkertonken/fix-vercel
Fix mixed-routing-properties error
2024-03-16 08:35:22 +00:00
Honkertonken
162da3b22b Update vercel.json 2024-03-16 13:12:34 +05:30
William Oldham
35113ed522 Merge pull request #1019 from movie-web/feature/vercel-headers
Add vercel.json headers
2024-03-15 22:38:28 +00:00
William Oldham
94a003bce6 Add vercel.json headers 2024-03-15 22:25:34 +00:00
qtchaos
e0fec7ffa3 fix: add check for setPositionState to avoid TypeError 2024-03-15 17:30:37 +02:00
Jorrin
6f3c700dcb Merge pull request #1015 from movie-web/dev
Version 4.6.2
2024-03-14 23:52:56 +01:00
Jorrin
c3fec6c522 Merge pull request #1014 from gh-movie-web/weblate-movie-web-website
Translations update from movie-web weblate
2024-03-14 23:36:54 +01:00
jan Kukisulasu
2ac0f2304f Translated using Weblate (Toki Pona)
Currently translated at 82.9% (272 of 328 strings)

Translation: movie-web/website
Translate-URL: https://weblate.476328473.xyz/projects/movie-web/website/tok/
Author: jan Kukisulasu <iam.mcken@gmail.com>
2024-03-14 22:27:48 +00:00
Jamie Poznanski
524e3f7358 Translated using Weblate (Polish)
Currently translated at 97.2% (319 of 328 strings)

Translation: movie-web/website
Translate-URL: https://weblate.476328473.xyz/projects/movie-web/website/pl/
Author: Jamie Poznanski <enby_jamie@users.noreply.weblate.movie-web.app>
2024-03-14 22:27:48 +00:00
Jorrin
135feab14c bump providers and version 2024-03-14 23:27:42 +01:00
Jorrin
41949b0ab3 Merge pull request #1007 from ssssobek/embedded-subtitles-support
Add embedded subtitles support
2024-03-14 21:21:20 +01:00
ssssobek
9f025bd12b Move filterDuplicateCaptionCues to a different file 2024-03-14 20:34:32 +01:00
ssssobek
810a12a097 Apply requested changes 2024-03-14 01:01:24 +01:00
ssssobek
c1f9382f50 Add embedded subtitles support 2024-03-12 23:45:34 +01:00
Jorrin
8ccca76573 Set default onboarding to true 2024-03-11 23:48:23 +01:00
William Oldham
fc76a84bc8 Set default extension links 2024-03-11 22:41:56 +00:00
William Oldham
ee047327a1 Start adding migration pages 2024-03-11 20:35:21 +00:00
William Oldham
8e73751f48 Translate onboarding "or" text 2024-03-11 20:35:21 +00:00
William Oldham
8420bedb84 Create migration hook to register and import data 2024-03-11 20:35:21 +00:00
William Oldham
ba2f3fd359 Add method to get keys from seed directly 2024-03-11 20:35:21 +00:00
William Oldham
852e6ff324 Make VerticleLine a general component 2024-03-11 20:35:21 +00:00
30 changed files with 1016 additions and 77 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "movie-web",
"version": "4.6.1",
"version": "4.6.2",
"private": true,
"homepage": "https://github.com/movie-web/movie-web",
"scripts": {
@@ -29,7 +29,7 @@
"@formkit/auto-animate": "^0.8.1",
"@headlessui/react": "^1.7.17",
"@ladjs/country-language": "^1.0.3",
"@movie-web/providers": "^2.2.2",
"@movie-web/providers": "^2.2.3",
"@noble/hashes": "^1.3.3",
"@plasmohq/messaging": "^0.6.1",
"@react-spring/web": "^9.7.3",
@@ -44,7 +44,7 @@
"focus-trap-react": "^10.2.3",
"fscreen": "^1.2.0",
"fuse.js": "^7.0.0",
"hls.js": "^1.4.14",
"hls.js": "^1.5.7",
"i18next": "^23.7.11",
"immer": "^10.0.3",
"jwt-decode": "^4.0.0",
@@ -68,6 +68,7 @@
"semver": "^7.5.4",
"slugify": "^1.6.6",
"subsrt-ts": "^2.1.2",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {

73
pnpm-lock.yaml generated
View File

@@ -22,8 +22,8 @@ dependencies:
specifier: ^1.0.3
version: 1.0.3
'@movie-web/providers':
specifier: ^2.2.2
version: 2.2.2
specifier: ^2.2.3
version: 2.2.3
'@noble/hashes':
specifier: ^1.3.3
version: 1.3.3
@@ -67,8 +67,8 @@ dependencies:
specifier: ^7.0.0
version: 7.0.0
hls.js:
specifier: ^1.4.14
version: 1.4.14
specifier: ^1.5.7
version: 1.5.7
i18next:
specifier: ^23.7.11
version: 23.7.11
@@ -138,6 +138,9 @@ dependencies:
subsrt-ts:
specifier: ^2.1.2
version: 2.1.2
zod:
specifier: ^3.22.4
version: 3.22.4
zustand:
specifier: ^4.4.7
version: 4.4.7(@types/react@18.2.45)(immer@10.0.3)(react@18.2.0)
@@ -274,7 +277,7 @@ devDependencies:
version: 0.5.9(prettier@3.1.1)
rollup-plugin-visualizer:
specifier: ^5.11.0
version: 5.11.0(@rollup/wasm-node@4.12.1)
version: 5.11.0(@rollup/wasm-node@4.13.0)
tailwind-scrollbar:
specifier: ^3.0.5
version: 3.0.5(tailwindcss@3.4.0)
@@ -1942,8 +1945,8 @@ packages:
engines: {node: '>= 14'}
dev: false
/@movie-web/providers@2.2.2:
resolution: {integrity: sha512-pTlErE5bdu+b68mUW2YAKOJKz2hwSx63auGAfTkGQ+0SHDMlCV9QQ8S8O9IoSsvdXps7/YlWJWOMX8pmKuYbPQ==}
/@movie-web/providers@2.2.3:
resolution: {integrity: sha512-0axy02Zzlk7Tvtalc/Ebv9u5vzUPVWmQm0Ts5+6l6KPU41JUdLnFgmOl0yf0lbNeHRNSTx5SDlvWcYNL8rgpyA==}
dependencies:
cheerio: 1.0.0-rc.12
cookie: 0.6.0
@@ -2068,7 +2071,7 @@ packages:
engines: {node: '>=14.0.0'}
dev: false
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.6)(@rollup/wasm-node@4.12.1):
/@rollup/plugin-babel@5.3.1(@babel/core@7.23.6)(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==}
engines: {node: '>= 10.0.0'}
peerDependencies:
@@ -2081,36 +2084,36 @@ packages:
dependencies:
'@babel/core': 7.23.6
'@babel/helper-module-imports': 7.22.15
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.12.1)
rollup: /@rollup/wasm-node@4.12.1
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.13.0)
rollup: /@rollup/wasm-node@4.13.0
dev: true
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.12.1):
/@rollup/plugin-node-resolve@11.2.1(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-yc2n43jcqVyGE2sqV5/YCmocy9ArjVAP/BeXyTtADTBBX6V0e5UMqwO8CdQ0kzjb6zu5P1qMzsScCMRvE9OlVg==}
engines: {node: '>= 10.0.0'}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.12.1)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.13.0)
'@types/resolve': 1.17.1
builtin-modules: 3.3.0
deepmerge: 4.3.1
is-module: 1.0.0
resolve: 1.22.4
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
dev: true
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.12.1):
/@rollup/plugin-replace@2.4.2(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==}
peerDependencies:
rollup: npm:@rollup/wasm-node
dependencies:
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.12.1)
'@rollup/pluginutils': 3.1.0(@rollup/wasm-node@4.13.0)
magic-string: 0.25.9
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
dev: true
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.12.1):
/@rollup/pluginutils@3.1.0(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==}
engines: {node: '>= 8.0.0'}
peerDependencies:
@@ -2119,11 +2122,11 @@ packages:
'@types/estree': 0.0.39
estree-walker: 1.0.1
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
dev: true
/@rollup/wasm-node@4.12.1:
resolution: {integrity: sha512-5j3BVQEccCzCb8fkl++IbDgAsnlsKBPz049C4C//j5s3pFKxKGlybl63QApdJKl1fNLr7HIwQEJcBImQtA3ZHg==}
/@rollup/wasm-node@4.13.0:
resolution: {integrity: sha512-oFX11wzU7RTaiW06WBtRpzIVN/oaG0I3XkevNO0brBklYnY9zpLhTfksN4b+TdBt6CfXV/KdVhdWLbb0fQIR7A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
dependencies:
@@ -4388,8 +4391,8 @@ packages:
function-bind: 1.1.2
dev: true
/hls.js@1.4.14:
resolution: {integrity: sha512-UppQjyvPVclg+6t2KY/Rv03h0+bA5u6zwqVoz4LAC/L0fgYmIaCD7ZCrwe8WI1Gv01be1XL0QFsRbSdIHV/Wbw==}
/hls.js@1.5.7:
resolution: {integrity: sha512-Hnyf7ojTBtXHeOW1/t6wCBJSiK1WpoKF9yg7juxldDx8u3iswrkPt2wbOA/1NiwU4j27DSIVoIEJRAhcdMef/A==}
dev: false
/hoist-non-react-statics@3.3.2:
@@ -5112,7 +5115,7 @@ packages:
'@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.23.6)
'@babel/types': 7.23.6
kleur: 4.1.5
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
unplugin: 1.5.1
transitivePeerDependencies:
- supports-color
@@ -6040,7 +6043,7 @@ packages:
glob: 7.2.3
dev: true
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.12.1):
/rollup-plugin-terser@7.0.2(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==}
deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-terser
peerDependencies:
@@ -6048,12 +6051,12 @@ packages:
dependencies:
'@babel/code-frame': 7.23.5
jest-worker: 26.6.2
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
serialize-javascript: 4.0.0
terser: 5.19.3
dev: true
/rollup-plugin-visualizer@5.11.0(@rollup/wasm-node@4.12.1):
/rollup-plugin-visualizer@5.11.0(@rollup/wasm-node@4.13.0):
resolution: {integrity: sha512-exM0Ms2SN3AgTzMeW7y46neZQcyLY7eKwWAop1ZoRTCZwyrIRdMMJ6JjToAJbML77X/9N8ZEpmXG4Z/Clb9k8g==}
engines: {node: '>=14'}
hasBin: true
@@ -6065,7 +6068,7 @@ packages:
dependencies:
open: 8.4.2
picomatch: 2.3.1
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
source-map: 0.7.4
yargs: 17.7.2
dev: true
@@ -7051,7 +7054,7 @@ packages:
'@types/node': 20.10.5
esbuild: 0.19.10
postcss: 8.4.32
rollup: /@rollup/wasm-node@4.12.1
rollup: /@rollup/wasm-node@4.13.0
optionalDependencies:
fsevents: 2.3.3
dev: true
@@ -7313,9 +7316,9 @@ packages:
'@babel/core': 7.23.6
'@babel/preset-env': 7.23.6(@babel/core@7.23.6)
'@babel/runtime': 7.23.6
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.6)(@rollup/wasm-node@4.12.1)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.12.1)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.12.1)
'@rollup/plugin-babel': 5.3.1(@babel/core@7.23.6)(@rollup/wasm-node@4.13.0)
'@rollup/plugin-node-resolve': 11.2.1(@rollup/wasm-node@4.13.0)
'@rollup/plugin-replace': 2.4.2(@rollup/wasm-node@4.13.0)
'@surma/rollup-plugin-off-main-thread': 2.2.3
ajv: 8.12.0
common-tags: 1.8.2
@@ -7324,8 +7327,8 @@ packages:
glob: 7.2.3
lodash: 4.17.21
pretty-bytes: 5.6.0
rollup: /@rollup/wasm-node@4.12.1
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.12.1)
rollup: /@rollup/wasm-node@4.13.0
rollup-plugin-terser: 7.0.2(@rollup/wasm-node@4.13.0)
source-map: 0.8.0-beta.0
stringify-object: 3.3.0
strip-comments: 2.0.1
@@ -7526,6 +7529,10 @@ packages:
engines: {node: '>=12.20'}
dev: true
/zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
dev: false
/zustand@4.4.7(@types/react@18.2.45)(immer@10.0.3)(react@18.2.0):
resolution: {integrity: sha512-QFJWJMdlETcI69paJwhSMJz7PPWjVP8Sjhclxmxmxv/RYI7ZOvR5BHX+ktH0we9gTWQMxcne8q1OY8xxz604gw==}
engines: {node: '>=12.7.0'}

View File

@@ -82,7 +82,7 @@ export const locales = {
ko,
sl,
ta,
"zh-HANT": zhhant,
"zh-Hant": zhhant,
is,
ru,
gl,

View File

@@ -100,7 +100,8 @@
"onboarding": "Setup",
"pagetitle": "{{title}} - movie-web",
"register": "Register",
"settings": "Settings"
"settings": "Settings",
"migration": "Migration"
}
},
"home": {
@@ -153,6 +154,27 @@
"show": "Show"
}
},
"migration": {
"start": {
"title": "Migrate your data",
"explainer": "If you wish to migrate or backup your data, you can do so using the options below. This will allow you to keep your data when you switch backend servers.",
"options": {
"or": "or",
"direct": {
"description": "This will directly migrate your data to the new server. This is the fastest option. <br /><br />This option allows you to keep your passphrase the same!",
"title": "Direct migration",
"quality": "Easiest and fastest",
"action": "Transfer data"
},
"download": {
"description": "This will download your data to your device. You can then upload it to the new server or just keep it for safekeeping.",
"title": "Download data",
"quality": "More technical",
"action": "Download data"
}
}
}
},
"navigation": {
"banner": {
"offline": "Check your internet connection"
@@ -216,6 +238,7 @@
"start": {
"explainer": "To get the best streams possible, you will need to choose which streaming method you want to use.",
"options": {
"or": "or",
"default": {
"text": "I don't want good quality streams,<0 /> <1>use the default setup</1>"
},

View File

@@ -117,8 +117,7 @@
"loading": "Wczytywanie...",
"noResults": "Nie mogliśmy niczego znaleźć!",
"placeholder": {
"default": "Co chciałbyś obejrzeć?",
"extra": []
"default": "Co chciałbyś obejrzeć?"
},
"sectionTitle": "Wyniki wyszukiwania"
},
@@ -131,11 +130,15 @@
},
"morning": {
"default": "Co chciałbyś obejrzeć dziś rano?",
"extra": ["Słyszałem że „Przed wschodem słońca” jest dobre"]
"extra": [
"Słyszałem że „Przed wschodem słońca” jest dobre"
]
},
"night": {
"default": "Co chciałbyś obejrzeć dziś wieczorem?",
"extra": ["Zmęczony? Słyszałem że „Egzorcysta” jest dobry."]
"extra": [
"Zmęczony? Słyszałem że „Egzorcysta” jest dobry."
]
}
}
},
@@ -176,7 +179,7 @@
"back": "Wstecz",
"explainer": "Korzystając z rozszerzenia przeglądarki, możesz uzyskać najlepsze strumienie. Wystarczy prosta instalacja.",
"explainerIos": "Niestety, rozszerzenie przeglądarki nie jest obsługiwane w systemie iOS, naciśnij <bold>Wstecz</bold>, aby wybrać inną opcję.",
"extensionHelp": "Jeżeli zainstalowałeś rozszerzenie, ale nie zostało ono wykryte. <bold>Otwórz rozszerzenie za pomocą menu rozszerzeń przeglądarki</bold> i postępuj zgodnie z instrukcjami wyświetlanymi na ekranie.",
"extensionHelp": "Jeżeli zainstalowałeś rozszerzenie, ale nie zostało ono wykryte, <bold>otwórz rozszerzenie za pomocą menu rozszerzeń przeglądarki</bold> i postępuj zgodnie z instrukcjami wyświetlanymi na ekranie.",
"linkChrome": "Zainstaluj rozszerzenie na Chrome",
"linkFirefox": "Zainstaluj rozszerzenie na Firefox",
"notDetecting": "Zainstalowano na Chrome, ale się nie wyświetla? Spróbuj odświeżyć stronę!",
@@ -207,7 +210,7 @@
"title": "Stwórzmy nowe proxy"
},
"start": {
"explainer": "Aby uzyskać najlepsze transmisje strumieniowe. Będziesz musiał wybrać metodę strumieniowania, której chcesz użyć.",
"explainer": "Aby uzyskać najlepsze transmisje strumieniowe, będziesz musiał wybrać metodę strumieniowania której chcesz użyć.",
"options": {
"default": {
"text": "Nie chcę dobrej jakości strumieni, <0 /> <1>użyj domyślnej konfiguracji</1>"
@@ -524,8 +527,8 @@
}
},
"subtitles": {
"backgroundLabel": "Krycie tła",
"backgroundBlurLabel": "Rozmycie tła",
"backgroundLabel": "Krycie tła",
"colorLabel": "Kolor",
"previewQuote": "Nie wolno mi się bać. Strach zabija myślenie.",
"textSizeLabel": "Rozmiar czcionki",

View File

@@ -57,6 +57,8 @@
},
"host": "lawa ilo sina li <0>{{hostname}}</0> - ona li pona tawa sina la sina ken pali e lipu open",
"no": "o weka",
"noHost": "lawa ilo ni li open ala li nasin ala la, sina ken ala pali e lipu open",
"noHostTitle": "lawa ilo li open ala a!",
"title": "lawa ilo ni li pona tawa sina anu seme?",
"yes": "lawa ilo ni li pona"
},
@@ -79,7 +81,8 @@
},
"footer": {
"legal": {
"disclaimer": "o sona e ni:"
"disclaimer": "o sona e ni:",
"disclaimerText": "ilo Muwi-We li mama ala e ijo sitelen. ona li toki taso tawa ilo ante. utala nasin li lon la o toki tawa ona pi ilo ante. sitelen ale li tan ala ilo Muwi-We"
},
"links": {
"discord": "kulupu Siko",
@@ -117,22 +120,33 @@
"noResults": "ijo li lon ala a!",
"placeholder": {
"default": "sina wile lukin e seme?",
"extra": []
"extra": [
"sina wile alasa e seme?",
"sina wile lukin e seme?",
"sitelen nanpa wan sina li seme?",
"sitelen nanpa wan sina li seme?"
]
},
"sectionTitle": "mi lukin e ni:"
},
"titles": {
"day": {
"default": "tenpo suno ni la sina wile lukin e seme?",
"extra": ["sina pilin alasa la o lukin e sitelen Jurassic Park"]
"extra": [
"sina pilin alasa la o lukin e sitelen Jurassic Park"
]
},
"morning": {
"default": "tenpo sin ni la sina wile lukin e seme?",
"extra": ["ken la sitelen Before Sunrise li pona"]
"extra": [
"ken la sitelen Before Sunrise li pona"
]
},
"night": {
"default": "tenpo pimeja ni la sina wile lukin e seme?",
"extra": ["sina pilin lape anu seme? o alasa lukin e sitelen Exorcist"]
"extra": [
"sina pilin lape anu seme? o alasa lukin e sitelen Exorcist"
]
}
}
},
@@ -163,6 +177,9 @@
"title": "mi ken ala lukin e lipu ona"
},
"onboarding": {
"defaultConfirm": {
"cancel": "ala"
},
"start": {
"title": "o open e ilo Muwi-We"
}

View File

@@ -21,9 +21,7 @@ export function verifyValidMnemonic(mnemonic: string) {
return validateMnemonic(mnemonic, wordlist);
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
export async function keysFromSeed(seed: Uint8Array): Promise<Keys> {
const { privateKey, publicKey } = forge.pki.ed25519.generateKeyPair({
seed,
});
@@ -35,6 +33,12 @@ export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
};
}
export async function keysFromMnemonic(mnemonic: string): Promise<Keys> {
const seed = await seedFromMnemonic(mnemonic);
return keysFromSeed(seed);
}
export function genMnemonic(): string {
return generateMnemonic(wordlist);
}

View File

@@ -51,3 +51,15 @@ export async function downloadCaption(
downloadCache.set(caption.url, output, expirySeconds);
return output;
}
/**
* Downloads the WebVTT content. No different than a simple
* get request with a cache.
*/
export async function downloadWebVTT(url: string): Promise<string> {
const cached = downloadCache.get(url);
if (cached) return cached;
const data = await fetch(url).then((v) => v.text());
return data;
}

View File

@@ -64,6 +64,8 @@ export enum Icons {
DONATION = "donation",
CIRCLE_QUESTION = "circle_question",
BRUSH = "brush",
CLOUD_ARROW_UP = "cloud_arrow_up",
FILE_ARROW_DOWN = "file_arrow_down",
}
export interface IconProps {
@@ -134,6 +136,8 @@ const iconList: Record<Icons, string> = {
donation: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 576 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path opacity="1" fill="currentColor" d="M163.9 136.9c-29.4-29.8-29.4-78.2 0-108s77-29.8 106.4 0l17.7 18 17.7-18c29.4-29.8 77-29.8 106.4 0s29.4 78.2 0 108L310.5 240.1c-6.2 6.3-14.3 9.4-22.5 9.4s-16.3-3.1-22.5-9.4L163.9 136.9zM568.2 336.3c13.1 17.8 9.3 42.8-8.5 55.9L433.1 485.5c-23.4 17.2-51.6 26.5-80.7 26.5H192 32c-17.7 0-32-14.3-32-32V416c0-17.7 14.3-32 32-32H68.8l44.9-36c22.7-18.2 50.9-28 80-28H272h16 64c17.7 0 32 14.3 32 32s-14.3 32-32 32H288 272c-8.8 0-16 7.2-16 16s7.2 16 16 16H392.6l119.7-88.2c17.8-13.1 42.8-9.3 55.9 8.5zM193.6 384l0 0-.9 0c.3 0 .6 0 .9 0z"/></svg>`,
circle_question: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path opacity="1" fill="currentColor" d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm169.8-90.7c7.9-22.3 29.1-37.3 52.8-37.3h58.3c34.9 0 63.1 28.3 63.1 63.1c0 22.6-12.1 43.5-31.7 54.8L280 264.4c-.2 13-10.9 23.6-24 23.6c-13.3 0-24-10.7-24-24V250.5c0-8.6 4.6-16.5 12.1-20.8l44.3-25.4c4.7-2.7 7.6-7.7 7.6-13.1c0-8.4-6.8-15.1-15.1-15.1H222.6c-3.4 0-6.4 2.1-7.5 5.3l-.4 1.2c-4.4 12.5-18.2 19-30.6 14.6s-19-18.2-14.6-30.6l.4-1.2zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>`,
brush: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path d="M162.4 6c-1.5-3.6-5-6-8.9-6h-19c-3.9 0-7.5 2.4-8.9 6L104.9 57.7c-3.2 8-14.6 8-17.8 0L66.4 6c-1.5-3.6-5-6-8.9-6H48C21.5 0 0 21.5 0 48V224v22.4V256H9.6 374.4 384v-9.6V224 48c0-26.5-21.5-48-48-48H230.5c-3.9 0-7.5 2.4-8.9 6L200.9 57.7c-3.2 8-14.6 8-17.8 0L162.4 6zM0 288v32c0 35.3 28.7 64 64 64h64v64c0 35.3 28.7 64 64 64s64-28.7 64-64V384h64c35.3 0 64-28.7 64-64V288H0zM192 432a16 16 0 1 1 0 32 16 16 0 1 1 0-32z" fill="currentColor"/></svg>`,
cloud_arrow_up: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M144 480C64.5 480 0 415.5 0 336c0-62.8 40.2-116.2 96.2-135.9c-.1-2.7-.2-5.4-.2-8.1c0-88.4 71.6-160 160-160c59.3 0 111 32.2 138.7 80.2C409.9 102 428.3 96 448 96c53 0 96 43 96 96c0 12.2-2.3 23.8-6.4 34.6C596 238.4 640 290.1 640 352c0 70.7-57.3 128-128 128H144zm79-217c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l39-39V392c0 13.3 10.7 24 24 24s24-10.7 24-24V257.9l39 39c9.4 9.4 24.6 9.4 33.9 0s9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0l-80 80z" fill="currentColor"/></svg>`,
file_arrow_down: `<svg xmlns="http://www.w3.org/2000/svg" height="1em" width="1em" viewBox="0 0 384 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 0C28.7 0 0 28.7 0 64V448c0 35.3 28.7 64 64 64H320c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0H64zM256 0V128H384L256 0zM216 232V334.1l31-31c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-72 72c-9.4 9.4-24.6 9.4-33.9 0l-72-72c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l31 31V232c0-13.3 10.7-24 24-24s24 10.7 24 24z" fill="currentColor"/></svg>`,
};
function ChromeCastButton() {

View File

@@ -0,0 +1,9 @@
import classNames from "classnames";
export function VerticalLine(props: { className?: string }) {
return (
<div className={classNames("w-full grid justify-center", props.className)}>
<div className="w-px h-10 bg-onboarding-divider" />
</div>
);
}

View File

@@ -122,9 +122,16 @@ export function CaptionsView({ id }: { id: string }) {
>(null);
const { selectCaptionById, disable } = useCaptions();
const captionList = usePlayerStore((s) => s.captionList);
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
const captions = useMemo(
() =>
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
[captionList, getHlsCaptionList],
);
const [searchQuery, setSearchQuery] = useState("");
const subtitleList = useSubtitleList(captionList, searchQuery);
const subtitleList = useSubtitleList(captions, searchQuery);
const [downloadReq, startDownload] = useAsyncFn(
async (captionId: string) => {

View File

@@ -67,6 +67,11 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
let preferenceQuality: SourceQuality | null = null;
let lastVolume = 1;
const languagePromises = new Map<
string,
(value: void | PromiseLike<void>) => void
>();
function reportLevels() {
if (!hls) return;
const levels = hls.levels;
@@ -133,6 +138,7 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
},
},
},
renderTextTracksNatively: false,
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error("HLS error", data);
@@ -173,6 +179,16 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
const quality = hlsLevelToQuality(hls.levels[hls.currentLevel]);
emit("changedquality", quality);
});
hls.on(Hls.Events.SUBTITLE_TRACK_LOADED, () => {
for (const [lang, resolve] of languagePromises) {
const track = hls?.subtitleTracks.find((t) => t.lang === lang);
if (track) {
resolve();
languagePromises.delete(lang);
break;
}
}
});
}
hls.attachMedia(vid);
@@ -413,5 +429,40 @@ export function makeVideoElementDisplayInterface(): DisplayInterface {
setPlaybackRate(rate) {
if (videoElement) videoElement.playbackRate = rate;
},
getCaptionList() {
return (
hls?.subtitleTracks.map((track) => {
return {
id: track.id.toString(),
language: track.lang ?? "unknown",
url: track.url,
needsProxy: false,
hls: true,
};
}) ?? []
);
},
getSubtitleTracks() {
return hls?.subtitleTracks ?? [];
},
async setSubtitlePreference(lang) {
// default subtitles are already loaded by hls.js
const track = hls?.subtitleTracks.find((t) => t.lang === lang);
if (track?.details !== undefined) return Promise.resolve();
// need to wait a moment before hls loads the subtitles
const promise = new Promise<void>((resolve, reject) => {
languagePromises.set(lang, resolve);
// reject after some time, if hls.js fails to load the subtitles
// for any reason
setTimeout(() => {
reject();
languagePromises.delete(lang);
}, 5000);
});
hls?.setSubtitleOption({ lang });
return promise;
},
};
}

View File

@@ -274,5 +274,14 @@ export function makeChromecastDisplayInterface(
playbackRate = rate;
setSource();
},
getCaptionList() {
return [];
},
getSubtitleTracks() {
return [];
},
async setSubtitlePreference() {
return Promise.resolve();
},
};
}

View File

@@ -1,4 +1,7 @@
import { MediaPlaylist } from "hls.js";
import { MWMediaType } from "@/backend/metadata/types/mw";
import { CaptionListItem } from "@/stores/player/slices/source";
import { LoadableSource, SourceQuality } from "@/stores/player/utils/qualities";
import { Listener } from "@/utils/events";
@@ -70,4 +73,7 @@ export interface DisplayInterface extends Listener<DisplayInterfaceEvents> {
setMeta(meta: DisplayMeta): void;
setCaption(caption: DisplayCaption | null): void;
getType(): DisplayType;
getCaptionList(): CaptionListItem[];
getSubtitleTracks(): MediaPlaylist[];
setSubtitlePreference(lang: string): Promise<void>;
}

View File

@@ -1,9 +1,16 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import subsrt from "subsrt-ts";
import { downloadCaption } from "@/backend/helpers/subs";
import { downloadCaption, downloadWebVTT } from "@/backend/helpers/subs";
import { Caption } from "@/stores/player/slices/source";
import { usePlayerStore } from "@/stores/player/store";
import { useSubtitleStore } from "@/stores/subtitles";
import {
filterDuplicateCaptionCues,
parseVttSubtitles,
} from "../utils/captions";
export function useCaptions() {
const setLanguage = useSubtitleStore((s) => s.setLanguage);
const enabled = useSubtitleStore((s) => s.enabled);
@@ -12,32 +19,85 @@ export function useCaptions() {
);
const setCaption = usePlayerStore((s) => s.setCaption);
const lastSelectedLanguage = useSubtitleStore((s) => s.lastSelectedLanguage);
const captionList = usePlayerStore((s) => s.captionList);
const getHlsCaptionList = usePlayerStore((s) => s.display?.getCaptionList);
const getSubtitleTracks = usePlayerStore((s) => s.display?.getSubtitleTracks);
const setSubtitlePreference = usePlayerStore(
(s) => s.display?.setSubtitlePreference,
);
const captions = useMemo(
() =>
captionList.length !== 0 ? captionList : getHlsCaptionList?.() ?? [],
[captionList, getHlsCaptionList],
);
const selectCaptionById = useCallback(
async (captionId: string) => {
const caption = captionList.find((v) => v.id === captionId);
const caption = captions.find((v) => v.id === captionId);
if (!caption) return;
const srtData = await downloadCaption(caption);
setCaption({
const captionToSet: Caption = {
id: caption.id,
language: caption.language,
srtData,
url: caption.url,
});
srtData: "",
};
if (!caption.hls) {
const srtData = await downloadCaption(caption);
captionToSet.srtData = srtData;
} else {
// request a language change to hls, so it can load the subtitles
await setSubtitlePreference?.(caption.language);
const track = getSubtitleTracks?.().find(
(t) => t.id.toString() === caption.id && t.details !== undefined,
);
if (!track) return;
const fragments =
track.details?.fragments?.filter(
(frag) => frag !== null && frag.url !== null,
) ?? [];
const vttCaptions = (
await Promise.all(
fragments.map(async (frag) => {
const vtt = await downloadWebVTT(frag.url);
return parseVttSubtitles(vtt);
}),
)
).flat();
const filtered = filterDuplicateCaptionCues(vttCaptions);
const srtData = subsrt.build(filtered, { format: "srt" });
captionToSet.srtData = srtData;
}
setCaption(captionToSet);
resetSubtitleSpecificSettings();
setLanguage(caption.language);
},
[setLanguage, captionList, setCaption, resetSubtitleSpecificSettings],
[
setLanguage,
captions,
setCaption,
resetSubtitleSpecificSettings,
getSubtitleTracks,
setSubtitlePreference,
],
);
const selectLanguage = useCallback(
async (language: string) => {
const caption = captionList.find((v) => v.language === language);
const caption = captions.find((v) => v.language === language);
if (!caption) return;
return selectCaptionById(caption.id);
},
[captionList, selectCaptionById],
[captions, selectCaptionById],
);
const disable = useCallback(async () => {

View File

@@ -32,6 +32,9 @@ export function MediaSession() {
const updatePositionState = useCallback(
(position: number) => {
// If the browser doesn't support setPositionState, return
if (typeof navigator.mediaSession.setPositionState !== "function") return;
// If the updated position needs to be buffered, queue an update
if (position > data.progress.buffered) {
shouldUpdatePositionState.current = true;

View File

@@ -50,12 +50,30 @@ export function convertSubtitlesToSrt(text: string): string {
return srt;
}
export function filterDuplicateCaptionCues(cues: ContentCaption[]) {
return cues.reduce((acc: ContentCaption[], cap: ContentCaption) => {
const lastCap = acc[acc.length - 1];
const isSameAsLast =
lastCap?.start === cap.start &&
lastCap?.end === cap.end &&
lastCap?.content === cap.content;
if (lastCap === undefined || !isSameAsLast) {
acc.push(cap);
}
return acc;
}, []);
}
export function parseVttSubtitles(vtt: string) {
return parse(vtt).filter((cue) => cue.type === "caption") as CaptionCueType[];
}
export function parseSubtitles(
text: string,
_language?: string,
): CaptionCueType[] {
const vtt = convertSubtitlesToVtt(text);
return parse(vtt).filter((cue) => cue.type === "caption") as CaptionCueType[];
return parseVttSubtitles(vtt);
}
function stringToBase64(input: string): string {

View File

@@ -0,0 +1,125 @@
import { useCallback } from "react";
import { SessionResponse } from "@/backend/accounts/auth";
import { bookmarkMediaToInput } from "@/backend/accounts/bookmarks";
import {
base64ToBuffer,
bytesToBase64,
bytesToBase64Url,
encryptData,
keysFromMnemonic,
keysFromSeed,
signChallenge,
} from "@/backend/accounts/crypto";
import { importBookmarks, importProgress } from "@/backend/accounts/import";
import { getLoginChallengeToken, loginAccount } from "@/backend/accounts/login";
import { progressMediaItemToInputs } from "@/backend/accounts/progress";
import {
getRegisterChallengeToken,
registerAccount,
} from "@/backend/accounts/register";
import { removeSession } from "@/backend/accounts/sessions";
import { getSettings } from "@/backend/accounts/settings";
import {
UserResponse,
getBookmarks,
getProgress,
getUser,
} from "@/backend/accounts/user";
import { useAuthData } from "@/hooks/auth/useAuthData";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { AccountWithToken, useAuthStore } from "@/stores/auth";
import { BookmarkMediaItem, useBookmarkStore } from "@/stores/bookmarks";
import { ProgressMediaItem, useProgressStore } from "@/stores/progress";
export interface RegistrationData {
recaptchaToken?: string;
mnemonic: string;
userData: {
device: string;
profile: {
colorA: string;
colorB: string;
icon: string;
};
};
}
export interface LoginData {
mnemonic: string;
userData: {
device: string;
};
}
export function useMigration() {
const currentAccount = useAuthStore((s) => s.account);
const progress = useProgressStore((s) => s.items);
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const { login: userDataLogin } = useAuthData();
const importData = async (
backendUrl: string,
account: AccountWithToken,
progressItems: Record<string, ProgressMediaItem>,
bookmarkItems: Record<string, BookmarkMediaItem>,
) => {
if (
Object.keys(progressItems).length === 0 &&
Object.keys(bookmarkItems).length === 0
) {
return;
}
const progressInputs = Object.entries(progressItems).flatMap(
([tmdbId, item]) => progressMediaItemToInputs(tmdbId, item),
);
const bookmarkInputs = Object.entries(bookmarkItems).map(([tmdbId, item]) =>
bookmarkMediaToInput(tmdbId, item),
);
await Promise.all([
importProgress(backendUrl, account, progressInputs),
importBookmarks(backendUrl, account, bookmarkInputs),
]);
};
const migrate = useCallback(
async (backendUrl: string, recaptchaToken: string) => {
if (!currentAccount) return;
const { challenge } = await getRegisterChallengeToken(
backendUrl,
recaptchaToken,
);
const keys = await keysFromSeed(base64ToBuffer(currentAccount.seed));
const signature = await signChallenge(keys, challenge);
const registerResult = await registerAccount(backendUrl, {
challenge: {
code: challenge,
signature,
},
publicKey: bytesToBase64Url(keys.publicKey),
device: await encryptData(currentAccount.deviceName, keys.seed),
profile: currentAccount.profile,
});
const account = await userDataLogin(
registerResult,
registerResult.user,
registerResult.session,
bytesToBase64(keys.seed),
);
await importData(backendUrl, account, progress, bookmarks);
return account;
},
[currentAccount, userDataLogin, bookmarks, progress],
);
return {
migrate,
};
}

View File

@@ -0,0 +1,103 @@
import { useCallback } from "react";
import { Settings } from "@/hooks/useSettingsImport";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
import { useQualityStore } from "@/stores/quality";
import { useSubtitleStore } from "@/stores/subtitles";
import { useThemeStore } from "@/stores/theme";
import { useVolumeStore } from "@/stores/volume";
export function useSettingsExport() {
const authStore = useAuthStore();
const bookmarksStore = useBookmarkStore();
const languageStore = useLanguageStore();
const preferencesStore = usePreferencesStore();
const progressStore = useProgressStore();
const qualityStore = useQualityStore();
const subtitleStore = useSubtitleStore();
const themeStore = useThemeStore();
const volumeStore = useVolumeStore();
const collect = useCallback(
(includeAuth: boolean): Settings => {
return {
auth: {
account: includeAuth ? authStore.account : undefined,
backendUrl: authStore.backendUrl,
proxySet: authStore.proxySet,
},
bookmarks: {
bookmarks: bookmarksStore.bookmarks,
},
language: {
language: languageStore.language,
},
preferences: {
enableThumbnails: preferencesStore.enableThumbnails,
},
progress: {
items: progressStore.items,
},
quality: {
quality: {
automaticQuality: qualityStore.quality.automaticQuality,
lastChosenQuality: qualityStore.quality.lastChosenQuality,
},
},
subtitles: {
lastSelectedLanguage: subtitleStore.lastSelectedLanguage,
styling: {
backgroundBlur: subtitleStore.styling.backgroundBlur,
backgroundOpacity: subtitleStore.styling.backgroundOpacity,
color: subtitleStore.styling.color,
size: subtitleStore.styling.size,
},
overrideCasing: subtitleStore.overrideCasing,
delay: subtitleStore.delay,
},
theme: {
theme: themeStore.theme,
},
volume: {
volume: volumeStore.volume,
},
};
},
[
authStore,
bookmarksStore,
languageStore,
preferencesStore,
progressStore,
qualityStore,
subtitleStore,
themeStore,
volumeStore,
],
);
const exportSettings = useCallback(
(includeAuth: boolean) => {
const output = JSON.stringify(collect(includeAuth), null, 2);
const blob = new Blob([output], { type: "application/json" });
const elem = window.document.createElement("a");
elem.href = window.URL.createObjectURL(blob);
const date = new Date();
elem.download = `movie-web settings - ${
date.toISOString().split("T")[0]
}.json`;
document.body.appendChild(elem);
elem.click();
document.body.removeChild(elem);
},
[collect],
);
return exportSettings;
}

View File

@@ -0,0 +1,234 @@
import { useCallback } from "react";
import { z } from "zod";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useLanguageStore } from "@/stores/language";
import { usePreferencesStore } from "@/stores/preferences";
import { useProgressStore } from "@/stores/progress";
import { useQualityStore } from "@/stores/quality";
import { useSubtitleStore } from "@/stores/subtitles";
import { useThemeStore } from "@/stores/theme";
import { useVolumeStore } from "@/stores/volume";
const settingsSchema = z.object({
auth: z.object({
account: z
.object({
profile: z.object({
colorA: z.string(),
colorB: z.string(),
icon: z.string(),
}),
sessionId: z.string(),
userId: z.string(),
token: z.string(),
seed: z.string(),
deviceName: z.string(),
})
.nullish(),
backendUrl: z.string().nullable(),
proxySet: z.array(z.string()).nullable(),
}),
bookmarks: z.object({
bookmarks: z.record(
z.object({
title: z.string(),
year: z.number().optional(),
poster: z.string().optional(),
type: z.enum(["show", "movie"]),
updatedAt: z.number(),
}),
),
}),
language: z.object({
language: z.string(),
}),
preferences: z.object({
enableThumbnails: z.boolean(),
}),
progress: z.object({
items: z.record(
z.object({
title: z.string(),
year: z.number().optional(),
poster: z.string().optional(),
type: z.enum(["show", "movie"]),
updatedAt: z.number(),
progress: z
.object({
watched: z.number(),
duration: z.number(),
})
.optional(),
seasons: z.record(
z.object({
title: z.string(),
number: z.number(),
id: z.string(),
}),
),
episodes: z.record(
z.object({
title: z.string(),
number: z.number(),
id: z.string(),
seasonId: z.string(),
updatedAt: z.number(),
progress: z.object({
watched: z.number(),
duration: z.number(),
}),
}),
),
}),
),
}),
quality: z.object({
quality: z.object({
automaticQuality: z.boolean(),
lastChosenQuality: z
.enum(["unknown", "360", "480", "720", "1080", "4k"])
.nullable(),
}),
}),
subtitles: z.object({
lastSelectedLanguage: z.string().nullable(),
styling: z.object({
backgroundBlur: z.number(),
backgroundOpacity: z.number(),
color: z.string(),
size: z.number(),
}),
overrideCasing: z.boolean(),
delay: z.number(),
}),
theme: z.object({
theme: z.string().nullable(),
}),
volume: z.object({
volume: z.number(),
}),
});
const settingsPartialSchema = settingsSchema.partial();
export type Settings = z.infer<typeof settingsSchema>;
export function useSettingsImport() {
const authStore = useAuthStore();
const bookmarksStore = useBookmarkStore();
const languageStore = useLanguageStore();
const preferencesStore = usePreferencesStore();
const progressStore = useProgressStore();
const qualityStore = useQualityStore();
const subtitleStore = useSubtitleStore();
const themeStore = useThemeStore();
const volumeStore = useVolumeStore();
const importSettings = useCallback(
async (file: File) => {
const text = await file.text();
const data = settingsPartialSchema.parse(JSON.parse(text));
if (data.auth?.account) authStore.setAccount(data.auth.account);
if (data.auth?.backendUrl) authStore.setBackendUrl(data.auth.backendUrl);
if (data.auth?.proxySet) authStore.setProxySet(data.auth.proxySet);
if (data.bookmarks) {
for (const [id, item] of Object.entries(data.bookmarks.bookmarks)) {
bookmarksStore.setBookmark(id, {
title: item.title,
type: item.type,
year: item.year,
poster: item.poster,
updatedAt: item.updatedAt,
});
}
}
if (data.language) languageStore.setLanguage(data.language.language);
if (data.preferences) {
preferencesStore.setEnableThumbnails(data.preferences.enableThumbnails);
}
if (data.quality) {
qualityStore.setAutomaticQuality(data.quality.quality.automaticQuality);
qualityStore.setLastChosenQuality(
data.quality.quality.lastChosenQuality,
);
}
if (data.subtitles) {
subtitleStore.setLanguage(data.subtitles.lastSelectedLanguage);
subtitleStore.updateStyling(data.subtitles.styling);
subtitleStore.setOverrideCasing(data.subtitles.overrideCasing);
subtitleStore.setDelay(data.subtitles.delay);
}
if (data.theme) themeStore.setTheme(data.theme.theme);
if (data.volume) volumeStore.setVolume(data.volume.volume);
if (data.progress) {
for (const [id, item] of Object.entries(data.progress.items)) {
if (!progressStore.items[id]) {
progressStore.setItem(id, item);
}
// We want to preserve existing progress so we take the max of the updatedAt and the progress
const storeItem = progressStore.items[id];
storeItem.updatedAt = Math.max(storeItem.updatedAt, item.updatedAt);
storeItem.title = item.title;
storeItem.year = item.year;
storeItem.poster = item.poster;
storeItem.type = item.type;
storeItem.progress = item.progress
? {
duration: item.progress.duration,
watched: Math.max(
storeItem.progress?.watched ?? 0,
item.progress.watched,
),
}
: undefined;
for (const [seasonId, season] of Object.entries(item.seasons)) {
storeItem.seasons[seasonId] = season;
}
for (const [episodeId, episode] of Object.entries(item.episodes)) {
if (!storeItem.episodes[episodeId]) {
storeItem.episodes[episodeId] = episode;
}
const storeEpisode = storeItem.episodes[episodeId];
storeEpisode.updatedAt = Math.max(
storeEpisode.updatedAt,
episode.updatedAt,
);
storeEpisode.title = episode.title;
storeEpisode.number = episode.number;
storeEpisode.seasonId = episode.seasonId;
storeEpisode.progress = {
duration: episode.progress.duration,
watched: Math.max(
storeEpisode.progress.watched,
episode.progress.watched,
),
};
}
progressStore.setItem(id, storeItem);
}
}
},
[
authStore,
bookmarksStore,
languageStore,
preferencesStore,
progressStore,
qualityStore,
subtitleStore,
themeStore,
volumeStore,
],
);
return importSettings;
}

View File

@@ -0,0 +1,65 @@
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Icons } from "@/components/Icon";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { VerticalLine } from "@/components/layout/VerticalLine";
import { Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { Card, CardContent, Link } from "@/pages/migration/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function MigrationPage() {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.migration" />
<CenterContainer>
<Stepper steps={2} current={1} className="mb-12" />
<Heading2 className="!mt-0 !text-3xl max-w-[435px]">
{t("migration.start.title")}
</Heading2>
<Paragraph className="max-w-[320px]">
{t("migration.start.explainer")}
</Paragraph>
<div className="w-full flex flex-col md:flex-row gap-3">
<Card onClick={() => navigate("/migration/direct")}>
<CardContent
colorClass="!text-onboarding-best"
title={t("migration.start.options.direct.title")}
subtitle={t("migration.start.options.direct.quality")}
description={
<Trans i18nKey="migration.start.options.direct.description" />
}
icon={Icons.CLOUD_ARROW_UP}
>
<Link>{t("migration.start.options.direct.action")}</Link>
</CardContent>
</Card>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">
{t("migration.start.options.or")}
</span>
<VerticalLine />
</div>
<Card onClick={() => navigate("/migration/download")}>
<CardContent
colorClass="!text-migration-good"
title={t("migration.start.options.download.title")}
subtitle={t("migration.start.options.download.quality")}
description={t("migration.start.options.download.description")}
icon={Icons.FILE_ARROW_DOWN}
>
<Link>{t("migration.start.options.download.action")}</Link>
</CardContent>
</Card>
</div>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { useSettingsExport } from "@/hooks/useSettingsExport";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
import { PageTitle } from "@/pages/parts/util/PageTitle";
export function MigrationDirectPage() {
const exportSettings = useSettingsExport();
const doDownload = useCallback(() => {
const data = exportSettings(false);
console.log(data);
}, [exportSettings]);
return (
<MinimalPageLayout>
<PageTitle subpage k="global.pages.migration" />
<CenterContainer>
<button onClick={doDownload} type="button">
Hello
</button>
</CenterContainer>
</MinimalPageLayout>
);
}

View File

@@ -0,0 +1,92 @@
import classNames from "classnames";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { Heading2, Heading3, Paragraph } from "@/components/utils/Text";
export function Card(props: {
children?: React.ReactNode;
className?: string;
onClick?: () => void;
}) {
return (
<div
className={classNames(
{
"bg-onboarding-card duration-300 border border-onboarding-border rounded-lg p-7":
true,
"hover:bg-onboarding-cardHover transition-colors cursor-pointer":
!!props.onClick,
},
props.className,
)}
onClick={props.onClick}
>
{props.children}
</div>
);
}
export function CardContent(props: {
title: ReactNode;
description: ReactNode;
subtitle: ReactNode;
colorClass: string;
children?: React.ReactNode;
icon: Icons;
}) {
return (
<div className="grid grid-rows-[1fr,auto] h-full">
<div>
<Icon
icon={props.icon}
className={classNames("text-4xl mb-8 block", props.colorClass)}
/>
<Heading3
className={classNames(
"!mt-0 !mb-0 !text-xs uppercase",
props.colorClass,
)}
>
{props.subtitle}
</Heading3>
<Heading2 className="!mb-0 !mt-1 !text-base">{props.title}</Heading2>
<Paragraph className="max-w-[320px] !my-4">
{props.description}
</Paragraph>
</div>
<div>{props.children}</div>
</div>
);
}
export function Link(props: {
children?: React.ReactNode;
to?: string;
href?: string;
className?: string;
target?: "_blank";
}) {
const navigate = useNavigate();
return (
<a
onClick={() => {
if (props.to) navigate(props.to);
}}
href={props.href}
target={props.target}
className={classNames(
"text-onboarding-link cursor-pointer inline-flex gap-2 items-center group hover:opacity-75 transition-opacity",
props.className,
)}
rel="noreferrer"
>
{props.children}
<Icon
icon={Icons.ARROW_RIGHT}
className="group-hover:translate-x-0.5 transition-transform text-xl group-active:translate-x-0"
/>
</a>
);
}

View File

@@ -1,9 +1,9 @@
import classNames from "classnames";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Stepper } from "@/components/layout/Stepper";
import { CenterContainer } from "@/components/layout/ThinContainer";
import { VerticalLine } from "@/components/layout/VerticalLine";
import { Modal, ModalCard, useModal } from "@/components/overlays/Modal";
import { Heading1, Heading2, Paragraph } from "@/components/utils/Text";
import { MinimalPageLayout } from "@/pages/layouts/MinimalPageLayout";
@@ -15,14 +15,6 @@ import { Card, CardContent, Link } from "@/pages/onboarding/utils";
import { PageTitle } from "@/pages/parts/util/PageTitle";
import { getProxyUrls } from "@/utils/proxyUrls";
function VerticalLine(props: { className?: string }) {
return (
<div className={classNames("w-full grid justify-center", props.className)}>
<div className="w-px h-10 bg-onboarding-divider" />
</div>
);
}
export function OnboardingPage() {
const navigate = useNavigateOnboarding();
const skipModal = useModal("skip");
@@ -73,7 +65,9 @@ export function OnboardingPage() {
</Card>
<div className="hidden md:grid grid-rows-[1fr,auto,1fr] justify-center gap-4">
<VerticalLine className="items-end" />
<span className="text-xs uppercase font-bold">or</span>
<span className="text-xs uppercase font-bold">
{t("onboarding.start.options.or")}
</span>
<VerticalLine />
</div>
<Card onClick={() => navigate("/onboarding/proxy")}>

View File

@@ -19,6 +19,8 @@ import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/Login";
import { MigrationPage } from "@/pages/migration/Migration";
import { MigrationDirectPage } from "@/pages/migration/MigrationDirect";
import { OnboardingPage } from "@/pages/onboarding/Onboarding";
import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension";
import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy";
@@ -129,6 +131,9 @@ function App() {
/>
<Route path="/onboarding/proxy" element={<OnboardingProxyPage />} />
<Route path="/migration" element={<MigrationPage />} />
<Route path="/migration/direct" element={<MigrationDirectPage />} />
{shouldHaveDmcaPage() ? (
<Route path="/dmca" element={<DmcaPage />} />
) : null}

View File

@@ -94,9 +94,11 @@ export function conf(): RuntimeConfig {
DMCA_EMAIL: getKey("DMCA_EMAIL"),
ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: getKey(
"ONBOARDING_CHROME_EXTENSION_INSTALL_LINK",
"https://chromewebstore.google.com/detail/movie-web-extension/hoffoikpiofojilgpofjhnkkamfnnhmm",
),
ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: getKey(
"ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK",
"https://addons.mozilla.org/en-GB/firefox/addon/movie-web-extension",
),
ONBOARDING_PROXY_INSTALL_LINK: getKey("ONBOARDING_PROXY_INSTALL_LINK"),
BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL),
@@ -106,7 +108,7 @@ export function conf(): RuntimeConfig {
.map((v) => v.trim())
.filter((v) => v.length > 0),
NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true",
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true",
HAS_ONBOARDING: getKey("HAS_ONBOARDING", "true") === "true",
TURNSTILE_KEY: getKey("TURNSTILE_KEY"),
DISALLOWED_IDS: getKey("DISALLOWED_IDS", "")
.split(",")

View File

@@ -26,6 +26,7 @@ export interface BookmarkStore {
bookmarks: Record<string, BookmarkMediaItem>;
updateQueue: BookmarkUpdateItem[];
addBookmark(meta: PlayerMeta): void;
setBookmark(id: string, item: BookmarkMediaItem): void;
removeBookmark(id: string): void;
replaceBookmarks(items: Record<string, BookmarkMediaItem>): void;
clear(): void;
@@ -94,6 +95,11 @@ export const useBookmarkStore = create(
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
});
},
setBookmark(id, item) {
set((s) => {
s.bookmarks[id] = item;
});
},
})),
{
name: "__MW::bookmarks",

View File

@@ -53,6 +53,7 @@ export interface CaptionListItem {
language: string;
url: string;
needsProxy: boolean;
hls?: boolean;
}
export interface SourceSlice {

View File

@@ -64,6 +64,7 @@ export interface ProgressStore {
clear(): void;
clearUpdateQueue(): void;
removeUpdateItem(id: string): void;
setItem(id: string, item: ProgressMediaItem): void;
}
let updateId = 0;
@@ -173,6 +174,11 @@ export const useProgressStore = create(
s.updateQueue = [...s.updateQueue.filter((v) => v.id !== id)];
});
},
setItem(id, item) {
set((s) => {
s.items[id] = item;
});
},
})),
{
name: "__MW::progress",

View File

@@ -1,3 +1,49 @@
{
"routes": [{ "src": "/[^.]+", "dest": "/", "status": 200 }]
"rewrites": [
{
"source": "/(.*)",
"destination": "/"
}
],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Cache-Control",
"value": "public, max-age=0, s-maxage=0, must-revalidate"
}
]
},
{
"source": "/manifest.webmanifest",
"headers": [
{
"key": "Content-Type",
"value": "application/manifest+json"
}
]
},
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, s-maxage=31536000, immutable"
}
]
}
]
}