diff --git a/_scripts/webpack.main.config.js b/_scripts/webpack.main.config.js
index 24560c32ecc85..70bbe93d4094a 100644
--- a/_scripts/webpack.main.config.js
+++ b/_scripts/webpack.main.config.js
@@ -55,6 +55,11 @@ const config = {
new webpack.DefinePlugin({
'process.platform': `'${process.platform}'`,
'process.env.IS_ELECTRON_MAIN': true
+ }),
+ // `ws` conditionally requires these optional native performance addons and handles
+ // them missing at runtime, but webpack can't resolve them at bundle time.
+ new webpack.IgnorePlugin({
+ resourceRegExp: /^(bufferutil|utf-8-validate)$/
})
],
output: {
diff --git a/package.json b/package.json
index 998e381eb09b3..cb888659da5dc 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"googlevideo": "^4.0.4",
"marked": "^18.0.6",
"process": "^0.11.10",
+ "qrcode": "^1.5.4",
"shaka-player": "^5.1.12",
"swiper": "^14.0.5",
"terser": "^5.49.0",
@@ -71,6 +72,7 @@
"vue-observe-visibility": "^2.0.0-alpha.1",
"vue-router": "^5.1.0",
"vuex": "^4.1.0",
+ "ws": "^8.21.0",
"youtubei.js": "^17.2.0"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2779c5e62a72b..b9e901cd302fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
process:
specifier: ^0.11.10
version: 0.11.10
+ qrcode:
+ specifier: ^1.5.4
+ version: 1.5.4
shaka-player:
specifier: ^5.1.12
version: 5.1.12
@@ -74,6 +77,9 @@ importers:
vuex:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.39(typescript@6.0.3))
+ ws:
+ specifier: ^8.21.0
+ version: 8.21.0
youtubei.js:
specifier: ^17.2.0
version: 17.2.0
@@ -1214,36 +1220,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -1520,51 +1532,61 @@ packages:
resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.12.2':
resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-loong64-gnu@1.12.2':
resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-loong64-musl@1.12.2':
resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.12.2':
resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.12.2':
resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.12.2':
resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.12.2':
resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.12.2':
resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.12.2':
resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@unrs/resolver-binding-openharmony-arm64@1.12.2':
resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==}
@@ -1967,6 +1989,10 @@ packages:
camel-case@4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
+ camelcase@5.3.1:
+ resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
+ engines: {node: '>=6'}
+
caniuse-api@3.0.0:
resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==}
@@ -2014,6 +2040,9 @@ packages:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
+ cliui@6.0.0:
+ resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
+
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -2262,6 +2291,10 @@ packages:
supports-color:
optional: true
+ decamelize@1.2.0:
+ resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
+ engines: {node: '>=0.10.0'}
+
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -2324,6 +2357,9 @@ packages:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
+ dijkstrajs@1.0.3:
+ resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
+
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@@ -2834,10 +2870,6 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
- fs-extra@11.3.5:
- resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
- engines: {node: '>=14.14'}
-
fs-extra@11.3.6:
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
engines: {node: '>=14.14'}
@@ -3943,6 +3975,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
+ pngjs@5.0.0:
+ resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
+ engines: {node: '>=10.13.0'}
+
possible-typed-array-names@1.0.0:
resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==}
engines: {node: '>= 0.4'}
@@ -4234,6 +4270,11 @@ packages:
resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
engines: {node: '>=20'}
+ qrcode@1.5.4:
+ resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
@@ -4311,6 +4352,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
+ require-main-filename@2.0.0:
+ resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+
resedit@1.7.2:
resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==}
engines: {node: '>=12', npm: '>=6'}
@@ -4474,6 +4518,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
+ set-blocking@2.0.0:
+ resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -5072,6 +5119,9 @@ packages:
webpack-cli:
optional: true
+ which-module@2.0.1:
+ resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+
which-typed-array@1.1.19:
resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==}
engines: {node: '>= 0.4'}
@@ -5102,6 +5152,10 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@6.2.0:
+ resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
+ engines: {node: '>=8'}
+
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5141,6 +5195,9 @@ packages:
resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
engines: {node: '>=8.0'}
+ y18n@4.0.3:
+ resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -5161,10 +5218,18 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
+ yargs-parser@18.1.3:
+ resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
+ engines: {node: '>=6'}
+
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yargs@15.4.1:
+ resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
+ engines: {node: '>=8'}
+
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
@@ -5895,7 +5960,7 @@ snapshots:
'@malept/cross-spawn-promise': 2.0.0
debug: 4.4.3
dir-compare: 4.2.0
- fs-extra: 11.3.5
+ fs-extra: 11.3.6
minimatch: 9.0.9
plist: 3.1.0
transitivePeerDependencies:
@@ -7224,6 +7289,8 @@ snapshots:
pascal-case: 3.1.2
tslib: 2.8.1
+ camelcase@5.3.1: {}
+
caniuse-api@3.0.0:
dependencies:
browserslist: 4.28.5
@@ -7265,6 +7332,12 @@ snapshots:
slice-ansi: 5.0.0
string-width: 7.2.0
+ cliui@6.0.0:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 6.2.0
+
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -7503,6 +7576,8 @@ snapshots:
dependencies:
ms: 2.1.3
+ decamelize@1.2.0: {}
+
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -7551,6 +7626,8 @@ snapshots:
diff-sequences@29.6.3: {}
+ dijkstrajs@1.0.3: {}
+
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.5
@@ -8187,18 +8264,11 @@ snapshots:
jsonfile: 6.2.1
universalify: 2.0.1
- fs-extra@11.3.5:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.1
- universalify: 2.0.1
-
fs-extra@11.3.6:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.1
universalify: 2.0.1
- optional: true
fs-extra@7.0.1:
dependencies:
@@ -8295,7 +8365,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
- semver: 7.7.4
+ semver: 7.8.5
serialize-error: 7.0.1
optional: true
@@ -8991,14 +9061,14 @@ snapshots:
node-abi@4.31.0:
dependencies:
- semver: 7.7.4
+ semver: 7.8.5
node-addon-api@7.1.1:
optional: true
node-api-version@0.2.1:
dependencies:
- semver: 7.7.4
+ semver: 7.8.5
node-gyp@12.4.0:
dependencies:
@@ -9007,7 +9077,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
- semver: 7.7.4
+ semver: 7.8.5
tar: 7.5.16
tinyglobby: 0.2.17
undici: 6.27.0
@@ -9197,6 +9267,8 @@ snapshots:
pluralize@8.0.0: {}
+ pngjs@5.0.0: {}
+
possible-typed-array-names@1.0.0: {}
postcss-calc@10.1.1(postcss@8.5.16):
@@ -9463,6 +9535,12 @@ snapshots:
dependencies:
hookified: 2.2.0
+ qrcode@1.5.4:
+ dependencies:
+ dijkstrajs: 1.0.3
+ pngjs: 5.0.0
+ yargs: 15.4.1
+
qs@6.15.3:
dependencies:
es-define-property: 1.0.1
@@ -9544,6 +9622,8 @@ snapshots:
require-from-string@2.0.2: {}
+ require-main-filename@2.0.0: {}
+
resedit@1.7.2:
dependencies:
pe-library: 0.4.1
@@ -9705,6 +9785,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ set-blocking@2.0.0: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -10191,7 +10273,7 @@ snapshots:
dependencies:
bluebird: 3.7.2
duplexer2: 0.1.4
- fs-extra: 11.3.5
+ fs-extra: 11.3.6
graceful-fs: 4.2.11
node-int64: 0.4.0
@@ -10432,6 +10514,8 @@ snapshots:
- postcss
- uglify-js
+ which-module@2.0.1: {}
+
which-typed-array@1.1.19:
dependencies:
available-typed-arrays: 1.0.7
@@ -10462,6 +10546,12 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@6.2.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -10491,6 +10581,8 @@ snapshots:
xmlbuilder@15.1.1: {}
+ y18n@4.0.3: {}
+
y18n@5.0.8: {}
yallist@4.0.0: {}
@@ -10504,8 +10596,27 @@ snapshots:
yaml@2.9.0: {}
+ yargs-parser@18.1.3:
+ dependencies:
+ camelcase: 5.3.1
+ decamelize: 1.2.0
+
yargs-parser@21.1.1: {}
+ yargs@15.4.1:
+ dependencies:
+ cliui: 6.0.0
+ decamelize: 1.2.0
+ find-up: 4.1.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ require-main-filename: 2.0.0
+ set-blocking: 2.0.0
+ string-width: 4.2.3
+ which-module: 2.0.1
+ y18n: 4.0.3
+ yargs-parser: 18.1.3
+
yargs@17.7.2:
dependencies:
cliui: 8.0.1
diff --git a/src/constants.js b/src/constants.js
index 030b7a2da926f..a3eeb6d7c44f9 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -47,7 +47,14 @@ const IpcChannels = {
WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder',
OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player',
- OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result'
+ OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result',
+
+ REMOTE_CONTROL_START: 'remote-control-start',
+ REMOTE_CONTROL_STOP: 'remote-control-stop',
+ REMOTE_CONTROL_COMMAND: 'remote-control-command',
+ REMOTE_CONTROL_SEARCH_REQUEST: 'remote-control-search-request',
+ REMOTE_CONTROL_SEARCH_RESULT: 'remote-control-search-result',
+ REMOTE_CONTROL_STATE: 'remote-control-state'
}
const DBActions = {
diff --git a/src/main/index.js b/src/main/index.js
index a02effdb9a2cb..a2ac801a6de2b 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -28,6 +28,7 @@ import packageDetails from '../../package.json'
import { handleOpenInExternalPlayer } from './externalPlayer'
import { generatePoToken } from './poTokenGenerator'
import { isFreeTubeUrl } from './utils'
+import * as remoteControlServer from './remoteControlServer'
const brotliDecompressAsync = promisify(brotliDecompress)
@@ -1299,6 +1300,44 @@ function runApp() {
session.defaultSession.closeAllConnections()
})
+ // #region remote control
+
+ ipcMain.handle(IpcChannels.REMOTE_CONTROL_START, (event) => {
+ if (!isFreeTubeUrl(event.senderFrame.url)) {
+ return
+ }
+
+ return remoteControlServer.start(event.sender)
+ })
+
+ ipcMain.on(IpcChannels.REMOTE_CONTROL_STOP, (event) => {
+ if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) {
+ return
+ }
+
+ remoteControlServer.stop()
+ })
+
+ // Only the window that started the server may report state or resolve searches for it,
+ // so other (possibly private) windows' playback never gets broadcast to connected remotes.
+ ipcMain.on(IpcChannels.REMOTE_CONTROL_STATE, (event, state) => {
+ if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) {
+ return
+ }
+
+ remoteControlServer.broadcastState(state)
+ })
+
+ ipcMain.on(IpcChannels.REMOTE_CONTROL_SEARCH_RESULT, (event, payload) => {
+ if (!isFreeTubeUrl(event.senderFrame.url) || !remoteControlServer.isOwner(event.sender)) {
+ return
+ }
+
+ remoteControlServer.resolveSearch(payload)
+ })
+
+ // #endregion remote control
+
// #region navigation history
const NAV_HISTORY_DISPLAY_LIMIT = 15
@@ -2141,6 +2180,8 @@ function runApp() {
return
}
+ remoteControlServer.stop()
+
await Promise.allSettled([
baseHandlers.compactAllDatastores(),
session.defaultSession.clearCache(),
diff --git a/src/main/remoteControlPage.js b/src/main/remoteControlPage.js
new file mode 100644
index 0000000000000..abb1d87433f09
--- /dev/null
+++ b/src/main/remoteControlPage.js
@@ -0,0 +1,537 @@
+/**
+ * Self-contained mobile remote-control page for FreeTube.
+ * No external resources are loaded (no CDN scripts/styles/fonts), so it
+ * works fully offline over the LAN and satisfies a strict same-origin CSP.
+ */
+export function getRemoteControlPageHtml(nonce) {
+ return `
+
+
+
+
+FreeTube Remote
+
+
+
+
+ FreeTube Remote
+ Connecting…
+
+
+Missing or invalid access link. Open FreeTube > Settings > Remote Control and scan the QR code again.
+
+
+
+
+
![]()
+
Nothing playing
+
+
+
+
+
+
+
+
+
+
+
+
+ 0:00
+
+ 0:00
+
+
+
+
+
+
+
+
+`
+}
diff --git a/src/main/remoteControlServer.js b/src/main/remoteControlServer.js
new file mode 100644
index 0000000000000..eecf3bb771db4
--- /dev/null
+++ b/src/main/remoteControlServer.js
@@ -0,0 +1,450 @@
+import { createServer } from 'http'
+import { randomBytes, randomUUID, timingSafeEqual } from 'crypto'
+import { networkInterfaces } from 'os'
+import { WebSocketServer } from 'ws'
+
+import { IpcChannels } from '../constants'
+import { getRemoteControlPageHtml } from './remoteControlPage'
+
+const DEFAULT_PORT = 9482
+const REQUEST_TIMEOUT_MS = 15_000
+const HEARTBEAT_INTERVAL_MS = 30_000
+const MAX_CONCURRENT_SEARCHES_PER_CONNECTION = 3
+const MAX_TOTAL_CONNECTIONS = 8
+const MAX_TOTAL_PENDING_SEARCHES = 16
+const MAX_SEARCH_RESULTS = 20
+const VIDEO_ID_REGEX = /^[\w-]{6,32}$/
+
+let httpServer = null
+let wss = null
+let heartbeatInterval = null
+let currentToken = null
+let targetWebContents = null
+let currentPort = null
+/** promise for an in-flight start() call, used to make concurrent start() calls idempotent instead of racing */
+let startPromise = null
+/** @type {Map} */
+const pendingSearchRequests = new Map()
+
+/**
+ * Constant-time-ish token comparison to avoid leaking token contents via response timing.
+ * @param {string} a
+ * @param {string} b
+ */
+function tokensMatch(a, b) {
+ const bufA = Buffer.from(a)
+ const bufB = Buffer.from(b)
+
+ if (bufA.length !== bufB.length) {
+ // still perform a compare of equal length to avoid an early-return timing signal
+ timingSafeEqual(bufA, bufA)
+ return false
+ }
+
+ return timingSafeEqual(bufA, bufB)
+}
+
+/**
+ * @param {string} requestUrl
+ * @param {string} host
+ */
+function parseUrl(requestUrl, host) {
+ try {
+ return new URL(requestUrl, `http://${host}`)
+ } catch {
+ return null
+ }
+}
+
+/**
+ * @returns {string[]} the machine's non-internal IPv4 addresses, used to build a LAN-reachable URL
+ */
+function getLocalNetworkAddresses() {
+ const interfaces = networkInterfaces()
+ const addresses = []
+
+ for (const ifaceList of Object.values(interfaces)) {
+ for (const iface of ifaceList ?? []) {
+ if (iface.family === 'IPv4' && !iface.internal) {
+ addresses.push(iface.address)
+ }
+ }
+ }
+
+ return addresses
+}
+
+/**
+ * @returns {boolean} whether the remote control server is currently listening
+ */
+export function isRunning() {
+ return httpServer !== null
+}
+
+/**
+ * Only the window that started the server is allowed to report state or resolve
+ * searches for it, so another window's playback is never exposed to connected remotes.
+ * @param {import('electron').WebContents} webContents
+ * @returns {boolean}
+ */
+export function isOwner(webContents) {
+ return targetWebContents !== null && targetWebContents === webContents
+}
+
+/**
+ * @param {import('electron').WebContents} webContents
+ * @param {number} [preferredPort]
+ * @returns {Promise<{ url: string, addresses: string[], port: number }>}
+ */
+export function start(webContents, preferredPort = DEFAULT_PORT) {
+ // Makes concurrent start() calls idempotent instead of racing two `listen()` attempts
+ // against each other and corrupting the module-level server/heartbeat state.
+ if (startPromise !== null) {
+ return startPromise
+ }
+
+ if (httpServer !== null) {
+ stop()
+ }
+
+ startPromise = new Promise((resolve, reject) => {
+ targetWebContents = webContents
+ currentToken = randomBytes(24).toString('hex')
+
+ const server = createServer((req, res) => handleHttpRequest(req, res))
+ httpServer = server
+
+ let started = false
+
+ server.on('error', (err) => {
+ if (!started) {
+ httpServer = null
+ currentToken = null
+ targetWebContents = null
+ reject(err)
+ } else {
+ console.error('[remote-control] server error', err)
+ stop()
+ }
+ })
+
+ server.listen(preferredPort, '0.0.0.0', () => {
+ started = true
+ currentPort = preferredPort
+
+ wss = new WebSocketServer({
+ server,
+ path: '/ws',
+ maxPayload: 8 * 1024,
+ verifyClient: ({ req }) => verifyWsClient(req)
+ })
+
+ wss.on('connection', (ws) => setupConnection(ws))
+
+ heartbeatInterval = setInterval(() => {
+ wss?.clients.forEach((ws) => {
+ if (ws.isAlive === false) {
+ ws.terminate()
+ return
+ }
+
+ ws.isAlive = false
+ ws.ping()
+ })
+ }, HEARTBEAT_INTERVAL_MS)
+
+ webContents.once('destroyed', () => stop())
+
+ resolve({
+ url: buildConnectionUrl(),
+ addresses: getLocalNetworkAddresses(),
+ port: currentPort
+ })
+ })
+ }).finally(() => {
+ startPromise = null
+ })
+
+ return startPromise
+}
+
+/**
+ * @returns {string} the URL a phone should open, including the auth token
+ */
+function buildConnectionUrl() {
+ const addresses = getLocalNetworkAddresses()
+ const primaryAddress = addresses[0] ?? '127.0.0.1'
+ return `http://${primaryAddress}:${currentPort}/?t=${currentToken}`
+}
+
+export function stop() {
+ if (heartbeatInterval) {
+ clearInterval(heartbeatInterval)
+ heartbeatInterval = null
+ }
+
+ for (const { timeout } of pendingSearchRequests.values()) {
+ clearTimeout(timeout)
+ }
+ pendingSearchRequests.clear()
+
+ if (wss) {
+ wss.clients.forEach((ws) => ws.terminate())
+ wss.close()
+ wss = null
+ }
+
+ if (httpServer) {
+ httpServer.close()
+ httpServer = null
+ }
+
+ currentToken = null
+ targetWebContents = null
+ currentPort = null
+}
+
+/**
+ * @param {import('http').IncomingMessage} req
+ */
+function verifyWsClient(req) {
+ if (!currentToken) {
+ return false
+ }
+
+ if ((wss?.clients.size ?? 0) >= MAX_TOTAL_CONNECTIONS) {
+ return false
+ }
+
+ const url = parseUrl(req.url, req.headers.host ?? 'localhost')
+ const token = url?.searchParams.get('t')
+
+ if (!token || !tokensMatch(token, currentToken)) {
+ return false
+ }
+
+ // Browser clients send `Origin`; reject cross-origin pages trying to reuse a leaked token.
+ // Non-browser clients (that don't send Origin) are still allowed through, gated by the token itself.
+ const origin = req.headers.origin
+ if (origin && origin !== `http://${req.headers.host}`) {
+ return false
+ }
+
+ return true
+}
+
+/**
+ * @param {import('http').IncomingMessage} req
+ * @param {import('http').ServerResponse} res
+ */
+function handleHttpRequest(req, res) {
+ if (req.method !== 'GET') {
+ res.writeHead(405).end()
+ return
+ }
+
+ const url = parseUrl(req.url, req.headers.host ?? 'localhost')
+
+ if (url === null || url.pathname !== '/') {
+ res.writeHead(404).end()
+ return
+ }
+
+ const token = url.searchParams.get('t')
+ if (!currentToken || !token || !tokensMatch(token, currentToken)) {
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Forbidden')
+ return
+ }
+
+ const nonce = randomBytes(16).toString('base64')
+
+ res.writeHead(200, {
+ 'Content-Type': 'text/html; charset=utf-8',
+ 'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; img-src https: data:; connect-src 'self'; frame-ancestors 'none'`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Referrer-Policy': 'no-referrer'
+ }).end(getRemoteControlPageHtml(nonce))
+}
+
+/**
+ * Wires up heartbeat tracking and message handling for a newly connected client.
+ * @param {import('ws').WebSocket} ws
+ */
+function setupConnection(ws) {
+ ws.isAlive = true
+ ws.pendingSearchCount = 0
+
+ ws.on('pong', () => {
+ ws.isAlive = true
+ })
+
+ ws.on('message', (data) => handleClientMessage(ws, data))
+
+ ws.on('close', () => {
+ for (const [requestId, entry] of pendingSearchRequests) {
+ if (entry.ws === ws) {
+ clearTimeout(entry.timeout)
+ pendingSearchRequests.delete(requestId)
+ }
+ }
+ })
+
+ send(ws, { type: 'connected' })
+}
+
+/**
+ * @param {import('ws').WebSocket} ws
+ * @param {object} payload
+ */
+function send(ws, payload) {
+ if (ws.readyState === ws.OPEN) {
+ ws.send(JSON.stringify(payload))
+ }
+}
+
+/**
+ * Parses and routes an incoming WS message to the matching command handler.
+ * @param {import('ws').WebSocket} ws
+ * @param {import('ws').RawData} data
+ */
+function handleClientMessage(ws, data) {
+ if (!targetWebContents || targetWebContents.isDestroyed()) {
+ return
+ }
+
+ let message
+ try {
+ message = JSON.parse(data.toString())
+ } catch {
+ return
+ }
+
+ if (typeof message?.type !== 'string') {
+ return
+ }
+
+ switch (message.type) {
+ case 'play':
+ case 'pause':
+ case 'fullscreen':
+ targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: message.type })
+ break
+
+ case 'volume': {
+ const value = Number(message.value)
+ if (Number.isFinite(value) && value >= 0 && value <= 100) {
+ targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'volume', value })
+ }
+ break
+ }
+
+ case 'seek': {
+ const value = Number(message.value)
+ if (Number.isFinite(value) && value >= 0) {
+ targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'seek', value })
+ }
+ break
+ }
+
+ case 'open': {
+ const videoId = message.videoId
+ if (typeof videoId === 'string' && VIDEO_ID_REGEX.test(videoId)) {
+ targetWebContents.send(IpcChannels.REMOTE_CONTROL_COMMAND, { action: 'open', value: videoId })
+ }
+ break
+ }
+
+ case 'search':
+ handleSearchMessage(ws, message)
+ break
+ }
+}
+
+/**
+ * @param {import('ws').WebSocket} ws
+ * @param {{ query?: string, requestId?: string }} message
+ */
+function handleSearchMessage(ws, message) {
+ const query = typeof message.query === 'string' ? message.query.trim().slice(0, 100) : ''
+ const clientRequestId = typeof message.requestId === 'string' ? message.requestId.slice(0, 100) : null
+
+ if (!query || !clientRequestId) {
+ return
+ }
+
+ if (ws.pendingSearchCount >= MAX_CONCURRENT_SEARCHES_PER_CONNECTION) {
+ send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Too many pending searches' })
+ return
+ }
+
+ if (pendingSearchRequests.size >= MAX_TOTAL_PENDING_SEARCHES) {
+ send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Server is busy, try again shortly' })
+ return
+ }
+
+ ws.pendingSearchCount++
+
+ const serverRequestId = randomUUID()
+
+ const timeout = setTimeout(() => {
+ pendingSearchRequests.delete(serverRequestId)
+ ws.pendingSearchCount = Math.max(0, ws.pendingSearchCount - 1)
+ send(ws, { type: 'searchError', requestId: clientRequestId, message: 'Search timed out' })
+ }, REQUEST_TIMEOUT_MS)
+
+ pendingSearchRequests.set(serverRequestId, { ws, clientRequestId, timeout })
+
+ targetWebContents.send(IpcChannels.REMOTE_CONTROL_SEARCH_REQUEST, { requestId: serverRequestId, query })
+}
+
+/**
+ * Called from the main process IPC handler once the renderer replies to a search request.
+ * @param {{ requestId: string, results?: any[], error?: string }} payload
+ */
+export function resolveSearch({ requestId, results, error }) {
+ const entry = pendingSearchRequests.get(requestId)
+ if (!entry) {
+ return
+ }
+
+ clearTimeout(entry.timeout)
+ pendingSearchRequests.delete(requestId)
+ entry.ws.pendingSearchCount = Math.max(0, entry.ws.pendingSearchCount - 1)
+
+ if (error) {
+ send(entry.ws, { type: 'searchError', requestId: entry.clientRequestId, message: String(error).slice(0, 200) })
+ } else {
+ send(entry.ws, { type: 'searchResults', requestId: entry.clientRequestId, results: sanitizeSearchResults(results) })
+ }
+}
+
+/**
+ * Defense-in-depth: even though only trusted renderer code calls this today, don't trust its
+ * shape blindly before forwarding it to a network client — cap the size and strip unknown fields.
+ * @param {any[]} results
+ */
+function sanitizeSearchResults(results) {
+ if (!Array.isArray(results)) {
+ return []
+ }
+
+ return results.slice(0, MAX_SEARCH_RESULTS).map(item => ({
+ videoId: typeof item?.videoId === 'string' ? item.videoId.slice(0, 32) : '',
+ title: typeof item?.title === 'string' ? item.title.slice(0, 300) : '',
+ author: typeof item?.author === 'string' ? item.author.slice(0, 200) : '',
+ thumbnail: typeof item?.thumbnail === 'string' && item.thumbnail.startsWith('https://') ? item.thumbnail.slice(0, 500) : ''
+ })).filter(item => VIDEO_ID_REGEX.test(item.videoId))
+}
+
+/**
+ * Called from the main process IPC handler whenever the renderer reports new playback state.
+ * @param {object} state
+ */
+export function broadcastState(state) {
+ if (!wss) {
+ return
+ }
+
+ const payload = { type: 'state', ...state }
+ wss.clients.forEach((ws) => send(ws, payload))
+}
diff --git a/src/preload/interface.js b/src/preload/interface.js
index 15107ec862afe..76df024a3ddad 100644
--- a/src/preload/interface.js
+++ b/src/preload/interface.js
@@ -321,5 +321,50 @@ export default {
ipcRenderer.on(IpcChannels.SYNC_SUBSCRIPTION_CACHE, (_, { event, data }) => {
handler(event, data)
})
+ },
+
+ /**
+ * @returns {Promise<{ url: string, addresses: string[], port: number }>}
+ */
+ startRemoteControlServer: () => {
+ return ipcRenderer.invoke(IpcChannels.REMOTE_CONTROL_START)
+ },
+
+ stopRemoteControlServer: () => {
+ ipcRenderer.send(IpcChannels.REMOTE_CONTROL_STOP)
+ },
+
+ /**
+ * @param {(command: { action: string, value?: string|number }) => void} handler
+ */
+ handleRemoteControlCommand: (handler) => {
+ ipcRenderer.on(IpcChannels.REMOTE_CONTROL_COMMAND, (_, command) => {
+ handler(command)
+ })
+ },
+
+ /**
+ * @param {(requestId: string, query: string) => void} handler
+ */
+ handleRemoteControlSearchRequest: (handler) => {
+ ipcRenderer.on(IpcChannels.REMOTE_CONTROL_SEARCH_REQUEST, (_, { requestId, query }) => {
+ handler(requestId, query)
+ })
+ },
+
+ /**
+ * @param {string} requestId
+ * @param {any[] | undefined} results
+ * @param {string | undefined} error
+ */
+ sendRemoteControlSearchResult: (requestId, results, error) => {
+ ipcRenderer.send(IpcChannels.REMOTE_CONTROL_SEARCH_RESULT, { requestId, results, error })
+ },
+
+ /**
+ * @param {object} state
+ */
+ sendRemoteControlState: (state) => {
+ ipcRenderer.send(IpcChannels.REMOTE_CONTROL_STATE, state)
}
}
diff --git a/src/renderer/App.vue b/src/renderer/App.vue
index e02bdffd7a389..ba1859769bd33 100644
--- a/src/renderer/App.vue
+++ b/src/renderer/App.vue
@@ -130,6 +130,7 @@ import packageDetails from '../../package.json'
import { openExternalLink, openInternalPath, showToast } from './helpers/utils'
import { translateWindowTitle } from './helpers/strings'
import { loadLocale } from './i18n/index'
+import { initRemoteControl } from './helpers/remote-control'
const route = useRoute()
const router = useRouter()
@@ -194,6 +195,7 @@ onMounted(async () => {
document.addEventListener('auxclick', handleAuxClick)
enableOpenUrl()
store.dispatch('getExternalPlayerCmdArgumentsData')
+ initRemoteControl(router)
}
dataReady.value = true
diff --git a/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css
new file mode 100644
index 0000000000000..e8bc8af6ff133
--- /dev/null
+++ b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.css
@@ -0,0 +1,33 @@
+.remoteControlWarning {
+ padding-block: 12px;
+ padding-inline: 4%;
+ background-color: var(--accent-color);
+ color: var(--text-with-accent-color);
+ font-weight: bold;
+}
+
+.warning-icon {
+ font-size: large;
+}
+
+.qrWrapper {
+ display: flex;
+ justify-content: center;
+ padding-block: 12px;
+}
+
+.qrImage {
+ inline-size: 220px;
+ block-size: 220px;
+ background-color: #fff;
+ border-radius: 4px;
+}
+
+.connectionUrl {
+ overflow-wrap: break-word;
+ font-family: monospace;
+}
+
+.errorMessage {
+ color: var(--error-color, #d92929);
+}
diff --git a/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue
new file mode 100644
index 0000000000000..702592877f030
--- /dev/null
+++ b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+ {{ $t('Settings.Remote Control Settings.Warning') }}
+
+
+
+
+
+
+
![]()
+
+
+ {{ connectionUrl }}
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
index 799ef9600eb00..32e59902eaf38 100644
--- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
+++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js
@@ -3255,10 +3255,33 @@ export default defineComponent({
return video.value.paused
}
+ function play() {
+ video.value.play()
+ }
+
function pause() {
video.value.pause()
}
+ /**
+ * @param {number} volume a value between 0 and 100
+ */
+ function setVolume(volume) {
+ const video_ = video.value
+ const clamped = Math.min(100, Math.max(0, volume)) / 100
+
+ video_.muted = clamped === 0
+ video_.volume = clamped
+ }
+
+ /**
+ * @returns {number} a value between 0 and 100
+ */
+ function getVolume() {
+ const video_ = video.value
+ return video_.muted ? 0 : Math.round(video_.volume * 100)
+ }
+
function getCurrentTime() {
return video.value.currentTime
}
@@ -3324,7 +3347,10 @@ export default defineComponent({
hasLoaded,
isPaused,
+ play,
pause,
+ setVolume,
+ getVolume,
getCurrentTime,
setCurrentTime,
destroyPlayer
diff --git a/src/renderer/helpers/remote-control.js b/src/renderer/helpers/remote-control.js
new file mode 100644
index 0000000000000..6f55b626d8ce0
--- /dev/null
+++ b/src/renderer/helpers/remote-control.js
@@ -0,0 +1,200 @@
+import { getLocalSearchResults } from './api/local'
+import { getInvidiousSearchResults } from './api/invidious'
+import store from '../store/index'
+
+const STATE_REPORT_MIN_INTERVAL_MS = 500
+const LOCAL_THUMBNAIL_BASE_URL = 'https://i.ytimg.com'
+
+/** @type {{ play: () => void, pause: () => void, setVolume: (v: number) => void, seek: (s: number) => void } | null} */
+let activePlayer = null
+
+let router_ = null
+let initialized = false
+let serverRunning = false
+
+let lastStateReportTime = 0
+
+/**
+ * Wires up the IPC listeners for remote-control commands and search requests.
+ * Safe to call once during app startup (Electron only).
+ * @param {import('vue-router').Router} router
+ */
+export function initRemoteControl(router) {
+ if (initialized || !process.env.IS_ELECTRON) {
+ return
+ }
+
+ initialized = true
+ router_ = router
+
+ window.ftElectron.handleRemoteControlCommand(handleCommand)
+ window.ftElectron.handleRemoteControlSearchRequest(handleSearchRequest)
+}
+
+/**
+ * @returns {boolean} whether the remote control server was started via {@linkcode startRemoteControlServer}
+ */
+export function isRemoteControlRunning() {
+ return serverRunning
+}
+
+/**
+ * Starts the remote control server and remembers that it's running, so
+ * {@linkcode reportState} knows whether it's worth reporting state at all.
+ * @returns {Promise<{ url: string, addresses: string[], port: number }>}
+ */
+export async function startRemoteControlServer() {
+ const result = await window.ftElectron.startRemoteControlServer()
+ serverRunning = true
+ return result
+}
+
+export function stopRemoteControlServer() {
+ window.ftElectron.stopRemoteControlServer()
+ serverRunning = false
+}
+
+/**
+ * @param {{ play: () => void, pause: () => void, setVolume: (v: number) => void, seek: (s: number) => void }} adapter
+ */
+export function registerActivePlayer(adapter) {
+ activePlayer = adapter
+}
+
+/**
+ * @param {object} adapter the same adapter instance previously passed to {@linkcode registerActivePlayer}
+ */
+export function unregisterActivePlayer(adapter) {
+ if (activePlayer === adapter) {
+ activePlayer = null
+ }
+}
+
+/**
+ * Reports the current playback state to any connected remotes.
+ * Discrete events (loaded/play/pause) should pass `immediate: true`;
+ * continuous updates (timeupdate) are throttled to avoid flooding the IPC/WS channels.
+ * @param {object} state
+ * @param {{ immediate?: boolean }} [options]
+ */
+export function reportState(state, options = {}) {
+ if (!process.env.IS_ELECTRON || !window.ftElectron || !serverRunning) {
+ return
+ }
+
+ const now = Date.now()
+ if (!options.immediate && now - lastStateReportTime < STATE_REPORT_MIN_INTERVAL_MS) {
+ return
+ }
+
+ lastStateReportTime = now
+ window.ftElectron.sendRemoteControlState(state)
+}
+
+/**
+ * Applies a command received from a connected remote to the currently active player,
+ * or navigates to a new video for the 'open' action.
+ * @param {{ action: string, value?: string|number }} command
+ */
+function handleCommand(command) {
+ switch (command.action) {
+ case 'play':
+ activePlayer?.play()
+ break
+ case 'pause':
+ activePlayer?.pause()
+ break
+ case 'fullscreen':
+ window.ftElectron.requestFullscreen()
+ break
+ case 'volume':
+ if (typeof command.value === 'number') {
+ activePlayer?.setVolume(command.value)
+ }
+ break
+ case 'seek':
+ if (typeof command.value === 'number') {
+ activePlayer?.seek(command.value)
+ }
+ break
+ case 'open':
+ if (typeof command.value === 'string' && router_) {
+ router_.push(`/watch/${command.value}`)
+ }
+ break
+ }
+}
+
+/**
+ * @param {string} requestId
+ * @param {string} query
+ */
+async function handleSearchRequest(requestId, query) {
+ try {
+ const results = await performSearch(query)
+ window.ftElectron.sendRemoteControlSearchResult(requestId, results, undefined)
+ } catch (error) {
+ console.error('[remote-control] search failed', error)
+ window.ftElectron.sendRemoteControlSearchResult(requestId, undefined, 'Search failed')
+ }
+}
+
+/**
+ * @param {string} query
+ * @returns {Promise<{ videoId: string, title: string, author: string, thumbnail: string }[]>}
+ */
+async function performSearch(query) {
+ const backendPreference = store.getters.getBackendPreference
+
+ if (backendPreference === 'invidious') {
+ const results = await getInvidiousSearchResults(query, 1, {
+ prioritize: 'relevance',
+ time: '',
+ duration: '',
+ type: 'video',
+ features: []
+ })
+
+ return (results ?? [])
+ .filter(item => item.type === 'video')
+ .slice(0, 20)
+ .map(mapInvidiousResult)
+ }
+
+ const { results } = await getLocalSearchResults(query, {}, store.getters.getShowFamilyFriendlyOnly)
+
+ return results
+ .filter(item => item.type === 'video')
+ .slice(0, 20)
+ .map(mapLocalResult)
+}
+
+/**
+ * Reduces a local-API video result to the safe subset of fields the remote page needs.
+ * @param {object} item
+ * @returns {{ videoId: string, title: string, author: string, thumbnail: string }}
+ */
+function mapLocalResult(item) {
+ return {
+ videoId: item.videoId,
+ title: item.title ?? '',
+ author: item.author ?? '',
+ thumbnail: `${LOCAL_THUMBNAIL_BASE_URL}/vi/${item.videoId}/mqdefault.jpg`
+ }
+}
+
+/**
+ * Reduces an Invidious-API video result to the safe subset of fields the remote page needs.
+ * @param {object} item
+ * @returns {{ videoId: string, title: string, author: string, thumbnail: string }}
+ */
+function mapInvidiousResult(item) {
+ const thumbnail = item.videoThumbnails?.find(t => t.quality === 'medium') ?? item.videoThumbnails?.[0]
+
+ return {
+ videoId: item.videoId,
+ title: item.title ?? '',
+ author: item.author ?? '',
+ thumbnail: thumbnail?.url ?? ''
+ }
+}
diff --git a/src/renderer/main.js b/src/renderer/main.js
index 763d6a0875973..99538d33ba864 100644
--- a/src/renderer/main.js
+++ b/src/renderer/main.js
@@ -80,6 +80,7 @@ import {
faLocationDot,
faLock,
faMessage,
+ faMobileScreenButton,
faMoneyCheckDollar,
faNetworkWired,
faPalette,
@@ -205,6 +206,7 @@ library.add(
faLocationDot,
faLock,
faMessage,
+ faMobileScreenButton,
faMoneyCheckDollar,
faNetworkWired,
faPalette,
diff --git a/src/renderer/views/Settings/Settings.vue b/src/renderer/views/Settings/Settings.vue
index 927361de33235..ab4c7e0cc462e 100644
--- a/src/renderer/views/Settings/Settings.vue
+++ b/src/renderer/views/Settings/Settings.vue
@@ -73,6 +73,7 @@ import PrivacySettings from '../../components/PrivacySettings.vue'
import DataSettings from '../../components/DataSettings/DataSettings.vue'
import DistractionSettings from '../../components/DistractionSettings/DistractionSettings.vue'
import ProxySettings from '../../components/ProxySettings/ProxySettings.vue'
+import RemoteControlSettings from '../../components/RemoteControlSettings/RemoteControlSettings.vue'
import SponsorBlockSettings from '../../components/SponsorBlockSettings.vue'
import ParentalControlSettings from '../../components/ParentalControlSettings.vue'
import ExperimentalSettings from '../../components/ExperimentalSettings/ExperimentalSettings.vue'
@@ -155,6 +156,12 @@ const settingsComponentsData = computed(() => {
title: t('Settings.Proxy Settings.Proxy Settings'),
icon: ['fas', 'network-wired'],
component: ProxySettings
+ },
+ {
+ type: 'remote-control',
+ title: t('Settings.Remote Control Settings.Remote Control Settings'),
+ icon: ['fas', 'mobile-screen-button'],
+ component: RemoteControlSettings
}
]
: []),
diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js
index 34a3742201244..18f39c7de8814 100644
--- a/src/renderer/views/Watch/Watch.js
+++ b/src/renderer/views/Watch/Watch.js
@@ -41,6 +41,7 @@ import {
import { sortCaptions } from '../../helpers/player/utils'
import { MANIFEST_TYPE_SABR } from '../../helpers/player/SabrManifestParser'
import { useI18n } from 'vue-i18n'
+import { registerActivePlayer, unregisterActivePlayer, reportState, isRemoteControlRunning } from '../../helpers/remote-control'
/**
* @typedef {{
@@ -353,8 +354,53 @@ export default defineComponent({
},
mounted: function () {
this.onMountedDependOnLocalStateLoading()
+
+ if (process.env.IS_ELECTRON) {
+ this._remoteControlAdapter = {
+ play: () => this.$refs.player?.play(),
+ pause: () => this.$refs.player?.pause(),
+ setVolume: (volume) => this.$refs.player?.setVolume(volume),
+ seek: (seconds) => this.$refs.player?.setCurrentTime(seconds)
+ }
+ registerActivePlayer(this._remoteControlAdapter)
+
+ this._remoteControlPollInterval = setInterval(() => {
+ this.reportRemoteControlState()
+ }, 1000)
+ }
+ },
+ beforeUnmount: function () {
+ if (process.env.IS_ELECTRON) {
+ unregisterActivePlayer(this._remoteControlAdapter)
+ clearInterval(this._remoteControlPollInterval)
+ }
},
methods: {
+ /**
+ * Pushes the current playback state to any connected remote controls.
+ * @param {boolean} [immediate] bypass the throttling applied to frequent (polled) updates
+ */
+ reportRemoteControlState: function (immediate = false) {
+ if (!isRemoteControlRunning()) {
+ return
+ }
+
+ const player = this.$refs.player
+ if (!player?.hasLoaded) {
+ return
+ }
+
+ reportState({
+ title: this.videoTitle,
+ videoId: this.videoId,
+ thumbnail: this.thumbnail,
+ paused: player.isPaused(),
+ currentTime: player.getCurrentTime(),
+ duration: this.videoLengthSeconds,
+ volume: player.getVolume()
+ }, { immediate })
+ },
+
async reloadView() {
await this.handleRouteChange()
@@ -1331,6 +1377,8 @@ export default defineComponent({
this.updateLocalPlaylistLastPlayedAtSometimes()
}
+
+ this.reportRemoteControlState(true)
},
checkIfPlaylist: function () {
@@ -1444,6 +1492,7 @@ export default defineComponent({
},
handleVideoEnded: function () {
+ this.reportRemoteControlState(true)
this.handleWatchProgressAutoSaveWhenProgressEnabled()
if (!this.autoplayEnabled) {
return
diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml
index 7b094ff928fe5..c1b0e35e505c4 100644
--- a/static/locales/en-US.yaml
+++ b/static/locales/en-US.yaml
@@ -651,6 +651,13 @@ Settings:
Region: Region
City: City
Error getting network information. Is your proxy configured properly?: Error getting network information. Is your proxy configured properly?
+ Remote Control Settings:
+ Remote Control Settings: Remote Control
+ Warning: Anyone on your local network who has the link or scans the QR code below will be able to control playback and search for videos, until you turn this off. Only enable it on networks you trust.
+ Enable Remote Control: Enable Remote Control
+ QR Code Alt: QR code linking to the remote control page
+ Regenerate Link: Regenerate Link
+ Failed to start: Failed to start the remote control server. The port may already be in use.
SponsorBlock Settings:
SponsorBlock Settings: SponsorBlock
Enable SponsorBlock: Enable SponsorBlock