From 76e9d17bde171fc207d60c97a15ddb413819595c Mon Sep 17 00:00:00 2001
From: LambdaGW <175590243+LambdaGW@users.noreply.github.com>
Date: Sun, 12 Jul 2026 01:48:39 +0200
Subject: [PATCH 1/2] Add remote control feature (search, play/pause, volume,
seek from phone)
Adds an opt-in local HTTP+WebSocket server (Settings > Remote Control)
that serves a self-contained mobile page over the LAN, letting a phone
browser search videos and control playback without installing an app.
Off by default; access is gated by a random per-session token shown as
a QR code, and only the window that started the server can report
state or resolve searches for it.
---
_scripts/webpack.main.config.js | 5 +
package.json | 2 +
pnpm-lock.yaml | 145 +++++-
src/constants.js | 9 +-
src/main/index.js | 41 ++
src/main/remoteControlPage.js | 421 +++++++++++++++++
src/main/remoteControlServer.js | 435 ++++++++++++++++++
src/preload/interface.js | 45 ++
src/renderer/App.vue | 2 +
.../RemoteControlSettings.css | 33 ++
.../RemoteControlSettings.vue | 118 +++++
.../ft-shaka-video-player.js | 26 ++
src/renderer/helpers/remote-control.js | 186 ++++++++
src/renderer/main.js | 2 +
src/renderer/views/Settings/Settings.vue | 7 +
src/renderer/views/Watch/Watch.js | 45 ++
static/locales/en-US.yaml | 7 +
17 files changed, 1511 insertions(+), 18 deletions(-)
create mode 100644 src/main/remoteControlPage.js
create mode 100644 src/main/remoteControlServer.js
create mode 100644 src/renderer/components/RemoteControlSettings/RemoteControlSettings.css
create mode 100644 src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue
create mode 100644 src/renderer/helpers/remote-control.js
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..573927680bc35
--- /dev/null
+++ b/src/main/remoteControlPage.js
@@ -0,0 +1,421 @@
+/**
+ * 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
+
+
+
+
+
+
+
+
+
+`
+}
diff --git a/src/main/remoteControlServer.js b/src/main/remoteControlServer.js
new file mode 100644
index 0000000000000..8510783ea8348
--- /dev/null
+++ b/src/main/remoteControlServer.js
@@ -0,0 +1,435 @@
+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
+ }
+}
+
+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
+}
+
+export function isRunning() {
+ return httpServer !== null
+}
+
+/**
+ * @param {import('electron').WebContents} webContents
+ */
+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
+}
+
+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))
+}
+
+/**
+ * @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))
+ }
+}
+
+/**
+ * @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':
+ 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..98c07ffff6965
--- /dev/null
+++ b/src/renderer/components/RemoteControlSettings/RemoteControlSettings.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+ {{ $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..309e6dd6cd055
--- /dev/null
+++ b/src/renderer/helpers/remote-control.js
@@ -0,0 +1,186 @@
+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)
+}
+
+export function isRemoteControlRunning() {
+ return serverRunning
+}
+
+/**
+ * @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)
+}
+
+/**
+ * @param {{ action: string, value?: string|number }} command
+ */
+function handleCommand(command) {
+ switch (command.action) {
+ case 'play':
+ activePlayer?.play()
+ break
+ case 'pause':
+ activePlayer?.pause()
+ 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)
+}
+
+/**
+ * @param {object} item
+ */
+function mapLocalResult(item) {
+ return {
+ videoId: item.videoId,
+ title: item.title ?? '',
+ author: item.author ?? '',
+ thumbnail: `${LOCAL_THUMBNAIL_BASE_URL}/vi/${item.videoId}/mqdefault.jpg`
+ }
+}
+
+/**
+ * @param {object} item
+ */
+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..b899e71b08556 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,49 @@ 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: {
+ 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 +1373,8 @@ export default defineComponent({
this.updateLocalPlaylistLastPlayedAtSometimes()
}
+
+ this.reportRemoteControlState(true)
},
checkIfPlaylist: function () {
@@ -1444,6 +1488,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
From a3e36e9469c905c6a431eaf257f036cdd799d5a0 Mon Sep 17 00:00:00 2001
From: LambdaGW <175590243+LambdaGW@users.noreply.github.com>
Date: Sun, 12 Jul 2026 02:32:10 +0200
Subject: [PATCH 2/2] Add fullscreen toggle to remote control, fix settings
error display, align code style
- Route a new 'fullscreen' command through the existing IPC command channel,
reusing the same already-shipped toggleFullScreen bridge used by the 'f'
keyboard shortcut.
- Add skip +/-10s, quick mute toggle, haptic feedback, and connection-status
gating (controls disable while disconnected) to the phone remote page.
- Fix RemoteControlSettings.vue: the error message and loading states were
nested inside `v-if="isRunning"`, so a failed start could never actually
show its error, and the loading spinner never appeared before isRunning
flipped true. Both now render independently of it.
- Rewrite the embedded remote-control page script to ES6 (let/const, arrow
functions) and fill in missing JSDoc, per project conventions.
- Verified end-to-end against the running app via CDP: real search (Local
API), real open/play/pause/volume/seek against the live player, real
state broadcast, and a real Invidious search attempt (which surfaced a
public instance's bot-check page rather than a bug in this code, verified
independently with a matching curl request).
---
src/main/remoteControlPage.js | 282 ++++++++++++------
src/main/remoteControlServer.js | 15 +
.../RemoteControlSettings.vue | 54 ++--
src/renderer/helpers/remote-control.js | 14 +
src/renderer/views/Watch/Watch.js | 4 +
5 files changed, 258 insertions(+), 111 deletions(-)
diff --git a/src/main/remoteControlPage.js b/src/main/remoteControlPage.js
index 573927680bc35..abb1d87433f09 100644
--- a/src/main/remoteControlPage.js
+++ b/src/main/remoteControlPage.js
@@ -90,7 +90,7 @@ export function getRemoteControlPageHtml(nonce) {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
- .controls { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.9rem; }
+ .controls { display: flex; align-items: center; justify-content: center; gap: 0.75rem; margin-top: 0.9rem; }
button.iconBtn {
border: none;
background: var(--accent);
@@ -101,10 +101,38 @@ export function getRemoteControlPageHtml(nonce) {
font-size: 1.1rem;
flex-shrink: 0;
}
- button.iconBtn:active { opacity: 0.8; }
+ button.iconBtn.secondary {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ width: 2.6rem;
+ height: 2.6rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ }
+ button.iconBtn:active { opacity: 0.7; }
+ button.iconBtn:disabled { opacity: 0.4; }
input[type="range"] { width: 100%; }
.rangeRow { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; color: var(--muted); margin-top: 0.6rem; }
.rangeRow input { flex: 1; }
+ button.muteBtn {
+ border: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 1.1rem;
+ padding: 0;
+ width: 1.5rem;
+ flex-shrink: 0;
+ }
+ body.disconnected .controls button,
+ body.disconnected .rangeRow input,
+ body.disconnected .rangeRow button,
+ body.disconnected #searchInput,
+ body.disconnected #searchBtn,
+ body.disconnected .result {
+ opacity: 0.5;
+ pointer-events: none;
+ }
form#searchForm { display: flex; gap: 0.5rem; }
input#searchInput {
flex: 1;
@@ -169,18 +197,19 @@ export function getRemoteControlPageHtml(nonce) {
Nothing playing
+
+
+
+
+
+ 0:00
+
+ 0:00
@@ -195,11 +224,11 @@ export function getRemoteControlPageHtml(nonce) {