From 5df2c8a0d98d3300c3234d76496ba13ad7779587 Mon Sep 17 00:00:00 2001 From: Daniel Bakhsheshi Date: Mon, 18 May 2026 23:30:09 +1000 Subject: [PATCH 1/4] Add casting: Chromecast + DLNA receivers Adds a Cast button to Movie, TV and Downloads pages plus a device-picker modal and mini-controller. Streams anime mp4, anime Sl-Hls, vidsrc / videasy / 2embed HLS captures, and completed local downloads to any Chromecast-built-in device (incl. XGIMI Android TV projectors) or DLNA renderer on the LAN. Highlights - New main-process module src/ipc/cast.js - Discovery: bonjour-service (_googlecast._tcp) + dlnacasts3 SSDP - LAN-routable HTTP server bound 0.0.0.0: - /file/:token range-supported local file serve - /proxy/:token header-injecting proxy with on-the-fly HLS playlist rewriting (segments + EXT-X-KEY / EXT-X-MAP child tokens) so receivers always speak to a Referer-injecting proxy - /sub/:token VTT with CORS, inline SRT->VTT conversion - Single active session (mirrors PIP). castv2-client for Chromecast, dlnacasts3 for DLNA. Clean disconnect on before-quit. - Preload bridge: cast:* IPC surface modeled on existing PIP naming. - Renderer - useCast() hook (src/utils/useCast.js) for device list + session state - CastPickerModal with recent / available device lists, CAST/DLNA badges - CastMiniController with play/pause/scrubber/CC toggle/volume/stop - CastIcon + CastingIcon in Icons.jsx - Wired into MoviePage, TVPage, DownloadsPage; Sidebar status via the global mini-controller variant - Settings: new "Cast & Devices" section (auto-discover, include-DLNA, auto-stop, preferred device, rescan, limitations notes). - Storage: CAST_* keys for the above preferences. Dependencies - castv2-client (Chromecast control) - bonjour-service (mDNS for _googlecast._tcp) - dlnacasts3 (DLNA SSDP + AVTransport) All pure JS, no native deps. --- index.js | 2 + package-lock.json | 571 ++++++++++-- package.json | 3 + preload.js | 43 + src/components/CastMiniController.jsx | 220 +++++ src/components/CastPickerModal.jsx | 340 +++++++ src/components/Icons.jsx | 37 + src/ipc/cast.js | 1198 +++++++++++++++++++++++++ src/pages/DownloadsPage.jsx | 38 + src/pages/MoviePage.jsx | 81 ++ src/pages/SettingsPage.jsx | 289 ++++++ src/pages/TVPage.jsx | 90 ++ src/utils/storage.js | 6 + src/utils/useCast.js | 186 ++++ 14 files changed, 3047 insertions(+), 57 deletions(-) create mode 100644 src/components/CastMiniController.jsx create mode 100644 src/components/CastPickerModal.jsx create mode 100644 src/ipc/cast.js create mode 100644 src/utils/useCast.js diff --git a/index.js b/index.js index bae2b15..7c173c3 100644 --- a/index.js +++ b/index.js @@ -42,6 +42,7 @@ const downloadsIpc = require("./src/ipc/downloads"); const subtitlesIpc = require("./src/ipc/subtitles"); const allmangaIpc = require("./src/ipc/allmanga"); const playerIpc = require("./src/ipc/player"); +const castIpc = require("./src/ipc/cast"); // -- Ad/tracker block list ----------------------------------------------------- const BLOCKED_HOSTS = [ @@ -319,6 +320,7 @@ playerIpc.register(getMainWindow, { writeSecretMigration: storageIpc.writeSecretMigration, }); blockStats.init(getMainWindow); +castIpc.register(getMainWindow); // get-block-stats lives with its data ipcMain.handle("get-block-stats", () => blockStats.getBlockStats()); diff --git a/package-lock.json b/package-lock.json index 7ca5a54..e25175a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "streambert", - "version": "2.3.0", + "version": "2.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "streambert", - "version": "2.3.0", + "version": "2.4.0", "license": "GPL-3.0", "dependencies": { + "bonjour-service": "^1.3.0", + "castv2-client": "^1.2.0", + "dlnacasts3": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -1266,6 +1269,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1344,6 +1353,69 @@ "node": ">= 10.0.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1443,9 +1515,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1460,9 +1529,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1477,9 +1543,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1557,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1511,9 +1571,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1528,9 +1585,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1545,9 +1599,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1562,9 +1613,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1579,9 +1627,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1596,9 +1641,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1613,9 +1655,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1630,9 +1669,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1647,9 +1683,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1868,6 +1901,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1879,7 +1918,6 @@ "version": "24.12.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -2356,6 +2394,22 @@ "node": ">=6.0.0" } }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", + "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -2452,7 +2506,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -2596,6 +2649,41 @@ ], "license": "CC-BY-4.0" }, + "node_modules/castv2": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/castv2/-/castv2-0.1.10.tgz", + "integrity": "sha512-3QWevHrjT22KdF08Y2a217IYCDQDP7vEJaY4n0lPBeC5UBYbMFMadDfVTsaQwq7wqsEgYUHElPGm3EO1ey+TNw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "protobufjs": "^6.8.8" + } + }, + "node_modules/castv2-client": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/castv2-client/-/castv2-client-1.2.0.tgz", + "integrity": "sha512-2diOsC0vSSxa3QEOgoGBy9fZRHzNXatHz464Kje2OpwQ7GM5vulyrD0gLFOQ1P4rgLAFsYiSGQl4gK402nEEuA==", + "license": "MIT", + "dependencies": { + "castv2": "~0.1.4", + "debug": "^2.2.0" + } + }, + "node_modules/castv2-client/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/castv2-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2752,6 +2840,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2763,9 +2866,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/crc": { "version": "3.8.0", @@ -2825,7 +2926,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2843,7 +2943,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -2859,7 +2958,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2976,6 +3074,20 @@ "node": "*" } }, + "node_modules/dlnacasts3": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/dlnacasts3/-/dlnacasts3-1.0.0.tgz", + "integrity": "sha512-lOZ+uiOKqaVQzwDz+Y49E79ootjbrkLbee+7ICiQP1Ib9FCqqd+DGM4imLs3AjS4gWMA/QWzslIodNt/0YkjRQ==", + "license": "MIT", + "dependencies": { + "node-ssdp": "^4.0.1", + "run-parallel": "^1.2.0", + "simple-get": "^4.0.1", + "thunky": "^1.1.0", + "upnp-mediarenderer-client": "^1.4.0", + "xml2js": "^0.6.2" + } + }, "node_modules/dmg-builder": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.8.1.tgz", @@ -3058,6 +3170,18 @@ "node": ">=8" } }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -3314,6 +3438,24 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/elementtree": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/elementtree/-/elementtree-0.1.7.tgz", + "integrity": "sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==", + "license": "Apache-2.0", + "dependencies": { + "sax": "1.1.4" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/elementtree/node_modules/sax": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.4.tgz", + "integrity": "sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==", + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3478,6 +3620,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -3514,7 +3662,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -4098,9 +4245,14 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ip": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.9.tgz", + "integrity": "sha512-cyRxvOEpNHNtchU3Ln9KC/auJgup87llfQpQ+t5ghoC/UhL16SWzbueiCsdTnWmqAWl7LadfuwhlqmtOaqMHdQ==", + "license": "MIT" + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4111,6 +4263,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isbinaryfile": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", @@ -4257,9 +4415,14 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4429,9 +4592,21 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -4451,6 +4626,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/network-address": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/network-address/-/network-address-1.1.2.tgz", + "integrity": "sha512-Q6878fmvItA1mE7H9Il46lONgFgTzX2f98zkS0c2YlkCACzNjwvum/8Kq693IQpxe9zy+w+Zm/4p0wQreLEtZw==", + "license": "MIT", + "bin": { + "network-address": "cli.js" + } + }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -4579,6 +4763,40 @@ "dev": true, "license": "MIT" }, + "node_modules/node-ssdp": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/node-ssdp/-/node-ssdp-4.0.1.tgz", + "integrity": "sha512-uJXkLZVuyaMg1qNbMbGQ6YzNzyOD+NLxYyxIJocPTKTVECPDokOiCZA686jTLXHMUnV34uY/lcUSJ+/5fhY43A==", + "license": "MIT", + "dependencies": { + "async": "^2.6.0", + "bluebird": "^3.5.1", + "debug": "^3.1.0", + "extend": "^3.0.1", + "ip": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/node-ssdp/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/node-ssdp/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", @@ -4623,7 +4841,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -4801,6 +5018,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4837,6 +5060,32 @@ "signal-exit": "^3.0.2" } }, + "node_modules/protobufjs": { + "version": "6.11.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.11.6.tgz", + "integrity": "sha512-k8BHqgPBOtrlougZZqF2uUk5Z7bN8f0wj+3e8M3hvtSv0NBAz4VBy5f6R5Nxq/l+i7mRFTgNZb2trxqTpHNY/A==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.1", + "@types/node": ">=13.7.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4858,6 +5107,26 @@ "node": ">=6" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -4919,6 +5188,21 @@ "read-binary-file-arch": "cli.js" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5056,6 +5340,35 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5077,7 +5390,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -5157,6 +5469,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -5260,6 +5617,15 @@ "node": ">= 6" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5431,6 +5797,12 @@ "dev": true, "license": "MIT" }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -5512,6 +5884,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undici": { "version": "6.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", @@ -5526,7 +5904,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -5570,6 +5947,59 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/upnp-device-client": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/upnp-device-client/-/upnp-device-client-1.0.2.tgz", + "integrity": "sha512-5BcYJJU5wXR6xGko/UuLSavybAA0sZx17Hka4ikOXwA9Ze3fiExmgUDytAXE5qjdbKzARl0lOLC3hPSUwAa3eQ==", + "license": "MIT", + "dependencies": { + "concat-stream": "^1.4.8", + "debug": "^2.1.3", + "elementtree": "~0.1.6", + "network-address": "^1.0.0" + } + }, + "node_modules/upnp-device-client/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/upnp-device-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/upnp-mediarenderer-client": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/upnp-mediarenderer-client/-/upnp-mediarenderer-client-1.4.0.tgz", + "integrity": "sha512-F+C3Yceoz0j3ZWEchz5tpaOEqkbpObRUmeuPGc9+2u2YvC1CDbXGQ6mjbM10MDhnUJ0tTWYTufpj6xsWctnULw==", + "license": "MIT", + "dependencies": { + "debug": "^2.1.3", + "elementtree": "^0.1.6", + "upnp-device-client": "^1.0.0" + } + }, + "node_modules/upnp-mediarenderer-client/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/upnp-mediarenderer-client/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5587,6 +6017,12 @@ "dev": true, "license": "(WTFPL OR MIT)" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/verror": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", @@ -5716,9 +6152,30 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index d15dc56..959be11 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "dist:mac": "cross-env ELECTRON_DIST=1 vite build && electron-builder --mac --publish never" }, "dependencies": { + "bonjour-service": "^1.3.0", + "castv2-client": "^1.2.0", + "dlnacasts3": "^1.0.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/preload.js b/preload.js index f2b17c3..863771f 100644 --- a/preload.js +++ b/preload.js @@ -170,6 +170,49 @@ contextBridge.exposeInMainWorld("electron", { return h; }, offUpdateProgress: (h) => ipcRenderer.removeListener("update-progress", h), + // ── Casting (Chromecast + DLNA) ───────────────────────────────────────── + castStartDiscovery: () => ipcRenderer.invoke("cast:start-discovery"), + castStopDiscovery: () => ipcRenderer.invoke("cast:stop-discovery"), + castListDevices: () => ipcRenderer.invoke("cast:list-devices"), + castConnect: (deviceId) => ipcRenderer.invoke("cast:connect", { deviceId }), + castDisconnect: () => ipcRenderer.invoke("cast:disconnect"), + castLoad: (args) => ipcRenderer.invoke("cast:load", args), + castPlay: () => ipcRenderer.invoke("cast:play"), + castPause: () => ipcRenderer.invoke("cast:pause"), + castStop: () => ipcRenderer.invoke("cast:stop"), + castSeek: (seconds) => ipcRenderer.invoke("cast:seek", { seconds }), + castSetVolume: (level) => ipcRenderer.invoke("cast:set-volume", { level }), + castSetMute: (muted) => ipcRenderer.invoke("cast:set-mute", { muted }), + castGetStatus: () => ipcRenderer.invoke("cast:get-status"), + castSetSubtitleTrack: (trackIndex) => + ipcRenderer.invoke("cast:set-subtitle-track", { trackIndex }), + onCastDevicesUpdated: (cb) => { + const h = (_, d) => cb(d); + ipcRenderer.on("cast:devices-updated", h); + return h; + }, + offCastDevicesUpdated: (h) => + ipcRenderer.removeListener("cast:devices-updated", h), + onCastStatus: (cb) => { + const h = (_, d) => cb(d); + ipcRenderer.on("cast:status", h); + return h; + }, + offCastStatus: (h) => ipcRenderer.removeListener("cast:status", h), + onCastSessionEnded: (cb) => { + const h = (_, d) => cb(d); + ipcRenderer.on("cast:session-ended", h); + return h; + }, + offCastSessionEnded: (h) => + ipcRenderer.removeListener("cast:session-ended", h), + onCastError: (cb) => { + const h = (_, d) => cb(d); + ipcRenderer.on("cast:error", h); + return h; + }, + offCastError: (h) => ipcRenderer.removeListener("cast:error", h), + // Scheduled backups getScheduledBackupSettings: () => ipcRenderer.invoke("get-scheduled-backup-settings"), diff --git a/src/components/CastMiniController.jsx b/src/components/CastMiniController.jsx new file mode 100644 index 0000000..11d4ae3 --- /dev/null +++ b/src/components/CastMiniController.jsx @@ -0,0 +1,220 @@ +import { useEffect, useRef, useState } from "react"; +import { CastingIcon, PlayIcon, SubtitlesIcon } from "./Icons"; + +const PauseIcon = ({ size = 14 }) => ( + +); + +function fmtTime(sec) { + sec = Math.max(0, Math.floor(Number(sec) || 0)); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + +/** + * Compact play/pause/seek/volume/disconnect bar. + * + * Props: + * cast: useCast() result + * variant: "player" | "global" — affects positioning + max width + */ +export default function CastMiniController({ cast, variant = "player" }) { + const { sessionState, position, duration, volume, currentDevice } = cast; + + // Local scrubber state — avoids snap-back while user drags + const [drag, setDrag] = useState(null); + const seekRef = useRef(null); + const [subsOn, setSubsOn] = useState(true); + + useEffect(() => { + if (drag === null) return; + const onUp = () => setDrag(null); + window.addEventListener("mouseup", onUp); + window.addEventListener("touchend", onUp); + return () => { + window.removeEventListener("mouseup", onUp); + window.removeEventListener("touchend", onUp); + }; + }, [drag]); + + if (sessionState === "idle" || !currentDevice) return null; + + const pct = + duration > 0 + ? Math.max(0, Math.min(100, ((drag ?? position) / duration) * 100)) + : 0; + + const handleScrubChange = (e) => { + const v = Number(e.target.value); + setDrag((v / 100) * duration); + }; + const handleScrubCommit = (e) => { + const v = Number(e.target.value); + cast.seek((v / 100) * duration); + setDrag(null); + }; + + const isPlaying = sessionState === "playing"; + const isBuffering = sessionState === "buffering" || sessionState === "connecting"; + + const containerStyle = + variant === "global" + ? { + padding: "10px 12px", + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: 10, + margin: "8px", + } + : { + padding: "8px 12px", + background: "var(--surface)", + borderTop: "1px solid var(--border)", + }; + + return ( +
+
+ +
+
+ Casting to +
+
+ {currentDevice.friendlyName || currentDevice.name} +
+
+ + + +
+ + {fmtTime(drag ?? position)} + + + + {duration ? fmtTime(duration) : "--:--"} + +
+ + + + {variant === "player" && ( + cast.setVolume(Number(e.target.value) / 100)} + title="Volume" + style={{ width: 70, cursor: "pointer" }} + /> + )} + + +
+
+ ); +} diff --git a/src/components/CastPickerModal.jsx b/src/components/CastPickerModal.jsx new file mode 100644 index 0000000..831c5fc --- /dev/null +++ b/src/components/CastPickerModal.jsx @@ -0,0 +1,340 @@ +import { useEffect, useState } from "react"; +import { CastIcon } from "./Icons"; +import { storage, STORAGE_KEYS } from "../utils/storage"; + +const MAX_RECENT = 5; + +function pushRecent(deviceId) { + if (!deviceId) return; + const cur = storage.get(STORAGE_KEYS.CAST_RECENT_DEVICE_IDS) || []; + const next = [deviceId, ...cur.filter((id) => id !== deviceId)].slice( + 0, + MAX_RECENT, + ); + storage.set(STORAGE_KEYS.CAST_RECENT_DEVICE_IDS, next); +} + +function TypeBadge({ type }) { + const isCast = type === "cast"; + return ( + + {isCast ? "CAST" : "DLNA"} + + ); +} + +function DeviceRow({ device, busy, onSelect }) { + return ( + + ); +} + +/** + * CastPickerModal — list discovered Chromecast + DLNA devices. + * + * Props: + * open: bool + * onClose: () => void + * loadArgs: object — passed to cast:load after connect succeeds + * onConnected: (device) => void + * cast: useCast() result (devices, isDiscovering, connect, load, startDiscovery, ...) + */ +export default function CastPickerModal({ + open, + onClose, + loadArgs, + onConnected, + cast, +}) { + const [connectingId, setConnectingId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setError(null); + cast.startDiscovery(); + }, [open]); + + if (!open) return null; + + const recentIds = storage.get(STORAGE_KEYS.CAST_RECENT_DEVICE_IDS) || []; + const recentDevices = recentIds + .map((id) => cast.devices.find((d) => d.id === id)) + .filter(Boolean); + const recentIdSet = new Set(recentDevices.map((d) => d.id)); + const otherDevices = cast.devices.filter((d) => !recentIdSet.has(d.id)); + + const handleSelect = async (device) => { + setConnectingId(device.id); + setError(null); + try { + const r = await cast.connect(device.id); + if (!r?.ok) { + setError(r?.error || "Connect failed"); + return; + } + pushRecent(device.id); + if (loadArgs) { + const lr = await cast.load(loadArgs); + if (!lr?.ok) { + setError(lr?.error || "Load failed"); + return; + } + } + onConnected?.(device); + onClose?.(); + } finally { + setConnectingId(null); + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ + + Cast to a device + +
+ + +
+
+ + {/* Body */} +
+ {recentDevices.length > 0 && ( +
+
+ Recent +
+
+ {recentDevices.map((d) => ( + + ))} +
+
+ )} + +
+
+ {recentDevices.length ? "Other devices" : "Available devices"} +
+ {otherDevices.length === 0 && !cast.isDiscovering && ( +
+ No devices found on your network. +
+ Make sure your TV/projector is on and connected to the same + Wi-Fi. +
+ )} + {cast.isDiscovering && otherDevices.length === 0 && ( +
+ Scanning the network… +
+ )} +
+ {otherDevices.map((d) => ( + + ))} +
+
+ + {(error || cast.lastError) && ( +
+ {error || cast.lastError} +
+ )} + + {connectingId && ( +
+ Connecting… +
+ )} +
+
+
+ ); +} diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx index 658493d..2bce6a8 100644 --- a/src/components/Icons.jsx +++ b/src/components/Icons.jsx @@ -361,3 +361,40 @@ export const PopOutIcon = ({ size = 16 }) => ( ); + +export const CastIcon = ({ size = 16 }) => ( + + + + + + +); + +export const CastingIcon = ({ size = 16 }) => ( + + + + + + + +); diff --git a/src/ipc/cast.js b/src/ipc/cast.js new file mode 100644 index 0000000..ababa2b --- /dev/null +++ b/src/ipc/cast.js @@ -0,0 +1,1198 @@ +// ── IPC: Cast (Chromecast + DLNA) ──────────────────────────────────────────── +// Main-process subsystem. Provides: +// - Device discovery (mDNS for Google Cast, SSDP for DLNA) +// - LAN-routable HTTP server: /file/:token, /sub/:token, /proxy/:token, /health +// (separate from src/ipc/allmanga.js getPlayerServer which stays 127.0.0.1) +// - Session lifecycle: connect / loadMedia / play / pause / stop / seek / volume +// - IPC surface mirrors PIP naming (cast:* channels) +// +// Single active session at a time, like the PIP window. + +const http = require("http"); +const https = require("https"); +const os = require("os"); +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); +const { ipcMain, app } = require("electron"); + +// Lazy-required heavy deps (keep cold start fast) +let Bonjour = null; +let castv2Client = null; +let dlnacasts = null; + +// ── Module state ───────────────────────────────────────────────────────────── + +/** id format: "cast:" or "dlna:" */ +const _devices = new Map(); + +let _bonjourBrowser = null; +let _bonjourInstance = null; +let _dlnaList = null; +let _discoveryRunning = false; + +/** { device, type:"cast"|"dlna", client?, player?, contentUrl, mediaInfo, lastStatus, tearDown } */ +let _session = null; + +let _castServer = null; + +/** token -> { kind:"file"|"sub"|"proxy", filePath?, target?, referer?, headers?, contentType?, expiresAt } */ +const _serveTokens = new Map(); + +const SERVE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12h + +let _getMainWindow = () => null; +let _devicesPushTimer = null; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function pushDevicesUpdate() { + if (_devicesPushTimer) clearTimeout(_devicesPushTimer); + _devicesPushTimer = setTimeout(() => { + _devicesPushTimer = null; + const mw = _getMainWindow(); + if (mw && !mw.isDestroyed()) { + mw.webContents.send("cast:devices-updated", listDevices()); + } + }, 100); +} + +function pushStatus(status) { + const mw = _getMainWindow(); + if (mw && !mw.isDestroyed()) mw.webContents.send("cast:status", status); +} + +function pushSessionEnded(reason) { + const mw = _getMainWindow(); + if (mw && !mw.isDestroyed()) + mw.webContents.send("cast:session-ended", { reason }); +} + +function pushError(message) { + const mw = _getMainWindow(); + if (mw && !mw.isDestroyed()) mw.webContents.send("cast:error", { message }); +} + +function listDevices() { + return [..._devices.values()] + .map((d) => ({ + id: d.id, + name: d.name, + type: d.type, + address: d.address, + port: d.port, + model: d.model || null, + friendlyName: d.friendlyName || d.name, + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +function getLocalIPv4() { + const ifaces = os.networkInterfaces(); + const preferred = []; + const fallback = []; + for (const [name, addrs] of Object.entries(ifaces)) { + if (!addrs) continue; + const lname = name.toLowerCase(); + const isVirtual = + lname.includes("vethernet") || + lname.includes("vmware") || + lname.includes("virtualbox") || + lname.includes("docker") || + lname.includes("hyper-v") || + lname.includes("loopback") || + lname.includes("vbox"); + const isPreferred = + lname.startsWith("en") || + lname.startsWith("eth") || + lname.startsWith("wlan") || + lname.includes("wi-fi") || + lname.includes("wifi") || + lname.includes("ethernet"); + for (const addr of addrs) { + if (addr.family !== "IPv4" || addr.internal) continue; + if (isVirtual) continue; + if (isPreferred) preferred.push(addr.address); + else fallback.push(addr.address); + } + } + return preferred[0] || fallback[0] || "127.0.0.1"; +} + +function makeToken(entry, ttlMs = SERVE_TOKEN_TTL_MS) { + const token = crypto.randomBytes(16).toString("hex"); + _serveTokens.set(token, { ...entry, expiresAt: Date.now() + ttlMs }); + return token; +} + +function castUrlFor(token, kind = "file") { + if (!_castServer) return null; + const ip = getLocalIPv4(); + const port = _castServer.address().port; + return `http://${ip}:${port}/${kind}/${token}`; +} + +function gcServeTokens() { + const now = Date.now(); + for (const [tok, entry] of _serveTokens) { + if (entry.expiresAt < now) _serveTokens.delete(tok); + } +} +setInterval(gcServeTokens, 5 * 60 * 1000).unref(); + +// ── HTTP server ────────────────────────────────────────────────────────────── + +const COMMON_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"; + +function _contentTypeForExt(filePath) { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".mp4": + case ".m4v": + return "video/mp4"; + case ".mkv": + return "video/x-matroska"; + case ".webm": + return "video/webm"; + case ".ts": + return "video/mp2t"; + case ".avi": + return "video/x-msvideo"; + case ".mov": + return "video/quicktime"; + case ".vtt": + return "text/vtt"; + case ".srt": + return "text/vtt"; // we convert in /sub + default: + return "application/octet-stream"; + } +} + +function _serveLocalFile(filePath, contentType, req, res) { + fs.stat(filePath, (err, stat) => { + if (err || !stat.isFile()) { + res.writeHead(404); + res.end(); + return; + } + const total = stat.size; + const range = req.headers["range"]; + const baseHeaders = { + "Content-Type": contentType, + "Accept-Ranges": "bytes", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-store", + }; + if (range) { + const m = /^bytes=(\d*)-(\d*)$/.exec(range); + if (!m) { + res.writeHead(416, { "Content-Range": `bytes */${total}` }); + res.end(); + return; + } + const start = m[1] ? parseInt(m[1], 10) : 0; + const end = m[2] ? parseInt(m[2], 10) : total - 1; + if ( + Number.isNaN(start) || + Number.isNaN(end) || + start > end || + end >= total + ) { + res.writeHead(416, { "Content-Range": `bytes */${total}` }); + res.end(); + return; + } + res.writeHead(206, { + ...baseHeaders, + "Content-Range": `bytes ${start}-${end}/${total}`, + "Content-Length": end - start + 1, + }); + fs.createReadStream(filePath, { start, end }).pipe(res); + } else { + res.writeHead(200, { ...baseHeaders, "Content-Length": total }); + fs.createReadStream(filePath).pipe(res); + } + }); +} + +function _isHlsContentType(ct) { + if (!ct) return false; + ct = ct.toLowerCase(); + return ct.includes("mpegurl") || ct.includes("vnd.apple"); +} + +/** Rewrite an HLS playlist so every segment URL flows through /proxy/. */ +function _rewriteHlsPlaylist(body, baseUrl, parentEntry) { + const baseHeadersJson = JSON.stringify(parentEntry.headers || {}); + const rewriteUrl = (raw) => { + let abs; + try { + abs = new URL(raw, baseUrl).toString(); + } catch { + return raw; + } + const tok = makeToken({ + kind: "proxy", + target: abs, + referer: parentEntry.referer, + headers: JSON.parse(baseHeadersJson), + }); + const ip = getLocalIPv4(); + const port = _castServer.address().port; + return `http://${ip}:${port}/proxy/${tok}`; + }; + return body + .split(/\r?\n/) + .map((line) => { + if (!line || line.startsWith("#")) { + // Rewrite URI="..." attributes (EXT-X-KEY, EXT-X-MAP, EXT-X-MEDIA) + return line.replace(/URI="([^"]+)"/g, (_, u) => `URI="${rewriteUrl(u)}"`); + } + return rewriteUrl(line.trim()); + }) + .join("\n"); +} + +function _serveProxy(entry, req, res) { + const target = entry.target; + if (!target) { + res.writeHead(400); + res.end(); + return; + } + let upstreamUrl; + try { + upstreamUrl = new URL(target); + } catch { + res.writeHead(400); + res.end(); + return; + } + const lib = upstreamUrl.protocol === "https:" ? https : http; + + const upstreamHeaders = { + "User-Agent": COMMON_UA, + Accept: "*/*", + ...(entry.headers || {}), + }; + if (entry.referer) upstreamHeaders["Referer"] = entry.referer; + if (req.headers["range"]) upstreamHeaders["Range"] = req.headers["range"]; + + const upReq = lib.request( + { + protocol: upstreamUrl.protocol, + hostname: upstreamUrl.hostname, + port: upstreamUrl.port || (upstreamUrl.protocol === "https:" ? 443 : 80), + path: upstreamUrl.pathname + upstreamUrl.search, + method: req.method || "GET", + headers: upstreamHeaders, + }, + (upRes) => { + const passHeaders = {}; + for (const h of [ + "content-type", + "content-length", + "content-range", + "accept-ranges", + "last-modified", + "etag", + ]) { + if (upRes.headers[h]) passHeaders[h] = upRes.headers[h]; + } + passHeaders["Access-Control-Allow-Origin"] = "*"; + passHeaders["Cache-Control"] = "no-store"; + + const upstreamContentType = upRes.headers["content-type"]; + const looksHls = + _isHlsContentType(upstreamContentType) || + /\.m3u8(\?|$)/i.test(upstreamUrl.pathname + upstreamUrl.search); + + if (looksHls) { + // Buffer playlist, rewrite, then send. + const chunks = []; + upRes.on("data", (c) => chunks.push(c)); + upRes.on("end", () => { + const body = Buffer.concat(chunks).toString("utf8"); + const rewritten = _rewriteHlsPlaylist(body, target, entry); + const out = Buffer.from(rewritten, "utf8"); + passHeaders["Content-Type"] = "application/vnd.apple.mpegurl"; + delete passHeaders["content-type"]; + passHeaders["Content-Length"] = out.length; + delete passHeaders["content-length"]; + delete passHeaders["content-range"]; + res.writeHead(upRes.statusCode || 200, passHeaders); + res.end(out); + }); + upRes.on("error", () => { + try { + res.writeHead(502); + res.end(); + } catch {} + }); + return; + } + + res.writeHead(upRes.statusCode || 200, passHeaders); + upRes.pipe(res); + }, + ); + upReq.on("error", (e) => { + try { + console.error("[cast proxy]", upstreamUrl.toString(), e.message); + res.writeHead(502); + res.end(); + } catch {} + }); + // Forward request body for non-GET (unlikely for cast media but safe) + if (req.method && req.method !== "GET" && req.method !== "HEAD") { + req.pipe(upReq); + } else { + upReq.end(); + } +} + +function _serveSub(entry, _req, res) { + const headers = { + "Content-Type": "text/vtt; charset=utf-8", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "*", + "Cache-Control": "no-store", + }; + + // Local sidecar — convert SRT→VTT on the fly if needed + if (entry.filePath) { + fs.readFile(entry.filePath, "utf8", (err, data) => { + if (err) { + res.writeHead(404); + res.end(); + return; + } + const isSrt = + entry.filePath.toLowerCase().endsWith(".srt") || + !/^WEBVTT/.test(data.trimStart()); + const out = isSrt + ? "WEBVTT\n\n" + data.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2") + : data; + const buf = Buffer.from(out, "utf8"); + headers["Content-Length"] = buf.length; + res.writeHead(200, headers); + res.end(buf); + }); + return; + } + + // Remote VTT — fetch upstream, rewrite as VTT + if (entry.target) { + let u; + try { + u = new URL(entry.target); + } catch { + res.writeHead(400); + res.end(); + return; + } + const lib = u.protocol === "https:" ? https : http; + const upHeaders = { + "User-Agent": COMMON_UA, + Accept: "*/*", + }; + if (entry.referer) upHeaders["Referer"] = entry.referer; + lib + .get( + { + protocol: u.protocol, + hostname: u.hostname, + port: u.port || (u.protocol === "https:" ? 443 : 80), + path: u.pathname + u.search, + headers: upHeaders, + }, + (upRes) => { + const chunks = []; + upRes.on("data", (c) => chunks.push(c)); + upRes.on("end", () => { + let body = Buffer.concat(chunks).toString("utf8"); + if (!/^WEBVTT/.test(body.trimStart())) { + body = + "WEBVTT\n\n" + + body.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2"); + } + const out = Buffer.from(body, "utf8"); + headers["Content-Length"] = out.length; + res.writeHead(200, headers); + res.end(out); + }); + }, + ) + .on("error", () => { + res.writeHead(502); + res.end(); + }); + return; + } + + res.writeHead(404); + res.end(); +} + +async function getCastServer() { + if (_castServer) return _castServer; + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://localhost"); + const parts = url.pathname.split("/").filter(Boolean); + + // Permissive CORS preflight for cast receivers that probe OPTIONS + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET,HEAD,OPTIONS", + "Access-Control-Allow-Headers": "*", + }); + res.end(); + return; + } + + if (parts.length === 1 && parts[0] === "health") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + + if (parts.length === 2) { + const [kind, token] = parts; + const entry = _serveTokens.get(token); + if (!entry || entry.expiresAt < Date.now()) { + res.writeHead(404); + res.end(); + return; + } + if (kind === "file" && entry.kind === "file" && entry.filePath) { + _serveLocalFile( + entry.filePath, + entry.contentType || _contentTypeForExt(entry.filePath), + req, + res, + ); + return; + } + if (kind === "proxy" && entry.kind === "proxy") { + _serveProxy(entry, req, res); + return; + } + if (kind === "sub" && entry.kind === "sub") { + _serveSub(entry, req, res); + return; + } + } + + res.writeHead(404); + res.end(); + }); + server.on("error", reject); + server.listen(0, "0.0.0.0", () => { + _castServer = server; + resolve(server); + }); + }); +} + +// ── Discovery ──────────────────────────────────────────────────────────────── + +function _pickServiceAddress(service) { + if (Array.isArray(service.addresses)) { + const v4 = service.addresses.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a)); + if (v4) return v4; + } + if (service.referer && service.referer.address) return service.referer.address; + return null; +} + +function _addCastDevice(service) { + const address = _pickServiceAddress(service); + if (!address) return; + const txt = service.txt || {}; + const id = `cast:${service.fqdn || service.name}`; + _devices.set(id, { + id, + type: "cast", + name: txt.fn || service.name, + friendlyName: txt.fn || service.name, + model: txt.md || null, + address, + port: service.port || 8009, + raw: service, + }); + pushDevicesUpdate(); +} + +function _removeCastDevice(service) { + const id = `cast:${service.fqdn || service.name}`; + if (_devices.delete(id)) pushDevicesUpdate(); +} + +function _addDlnaPlayer(player) { + if (!player || !player.name) return; + const id = `dlna:${player.name}`; + // host can be "ip:port" or just ip + let address = player.host || ""; + let port = 0; + const m = String(address).match(/^([^:]+)(?::(\d+))?$/); + if (m) { + address = m[1]; + port = m[2] ? Number(m[2]) : 0; + } + _devices.set(id, { + id, + type: "dlna", + name: player.name, + friendlyName: player.name, + model: null, + address, + port, + raw: player, + }); + pushDevicesUpdate(); +} + +async function startDiscovery() { + if (_discoveryRunning) return { ok: true, alreadyRunning: true }; + _discoveryRunning = true; + + // ── Chromecast via mDNS ─────────────────────────────────────────────────── + try { + if (!Bonjour) Bonjour = require("bonjour-service").Bonjour; + if (!_bonjourInstance) _bonjourInstance = new Bonjour(); + _bonjourBrowser = _bonjourInstance.find({ type: "googlecast" }, (svc) => { + _addCastDevice(svc); + }); + _bonjourBrowser.on("down", (svc) => _removeCastDevice(svc)); + } catch (e) { + pushError(`Chromecast discovery failed: ${e.message}`); + } + + // ── DLNA via SSDP (dlnacasts3) ──────────────────────────────────────────── + try { + if (!dlnacasts) dlnacasts = require("dlnacasts3"); + if (!_dlnaList) { + _dlnaList = dlnacasts(); + _dlnaList.on("update", (player) => _addDlnaPlayer(player)); + } + // dlnacasts3 1.x: must explicitly trigger an SSDP search + try { + _dlnaList.update(); + } catch {} + } catch (e) { + pushError(`DLNA discovery failed: ${e.message}`); + } + + // Initial push (may already be empty; subsequent finds will push again) + pushDevicesUpdate(); + return { ok: true }; +} + +async function stopDiscovery() { + if (!_discoveryRunning) return { ok: true }; + _discoveryRunning = false; + try { + if (_bonjourBrowser) _bonjourBrowser.stop(); + } catch {} + _bonjourBrowser = null; + // dlnacasts3 has no stop() — leave the list instance for reuse on next start. + return { ok: true }; +} + +// ── Cast load-args resolver ───────────────────────────────────────────────── +// +// Renderer can send either a fully-resolved `contentUrl` or a `mode` descriptor +// that the main process turns into a server token + URL. +// +// { mode:"localFile", filePath, contentType?, title, posterUrl?, startTime?, +// localVttSubs: [{ path, lang }] } +// { mode:"mp4Remote", url, referer, contentType?, title, ..., remoteVttSubs: [{url,lang}] } +// { mode:"hlsRemote", url, referer, title, ..., remoteVttSubs: [{url,lang}] } +// +// Returns: { contentUrl, contentType, title, posterUrl, startTime, subtitles:[{url,lang}] } +function _resolveLoadArgs(args) { + const out = { + contentUrl: null, + contentType: args.contentType || null, + title: args.title || "Streambert", + posterUrl: args.posterUrl || null, + startTime: Number.isFinite(args.startTime) ? Number(args.startTime) : 0, + subtitles: [], + }; + + if (args.mode === "localFile") { + const token = makeToken({ + kind: "file", + filePath: args.filePath, + contentType: args.contentType || _contentTypeForExt(args.filePath || ""), + }); + out.contentUrl = castUrlFor(token, "file"); + out.contentType = args.contentType || _contentTypeForExt(args.filePath || ""); + } else if (args.mode === "mp4Remote") { + const token = makeToken({ + kind: "proxy", + target: args.url, + referer: args.referer || null, + headers: args.extraHeaders || {}, + }); + out.contentUrl = castUrlFor(token, "proxy"); + out.contentType = args.contentType || "video/mp4"; + } else if (args.mode === "hlsRemote") { + const token = makeToken({ + kind: "proxy", + target: args.url, + referer: args.referer || null, + headers: args.extraHeaders || {}, + }); + out.contentUrl = castUrlFor(token, "proxy"); + out.contentType = "application/vnd.apple.mpegurl"; + } else if (args.contentUrl) { + // Fully-resolved URL provided directly + out.contentUrl = args.contentUrl; + out.contentType = args.contentType || "video/mp4"; + } else { + throw new Error("cast:load — missing mode or contentUrl"); + } + + // Subtitles + if (Array.isArray(args.localVttSubs)) { + for (const s of args.localVttSubs) { + if (!s || !s.path) continue; + const tok = makeToken({ kind: "sub", filePath: s.path }); + out.subtitles.push({ url: castUrlFor(tok, "sub"), lang: s.lang || "und" }); + } + } + if (Array.isArray(args.remoteVttSubs)) { + for (const s of args.remoteVttSubs) { + if (!s || !s.url) continue; + const tok = makeToken({ + kind: "sub", + target: s.url, + referer: args.referer || null, + }); + out.subtitles.push({ url: castUrlFor(tok, "sub"), lang: s.lang || "und" }); + } + } + + return out; +} + +// ── Chromecast session ─────────────────────────────────────────────────────── + +function _connectChromecast(device) { + if (!castv2Client) castv2Client = require("castv2-client"); + const { Client, DefaultMediaReceiver } = castv2Client; + + return new Promise((resolve, reject) => { + const client = new Client(); + let settled = false; + + const fail = (e) => { + if (settled) return; + settled = true; + try { + client.close(); + } catch {} + reject(e); + }; + client.on("error", fail); + + client.connect(device.address, () => { + client.launch(DefaultMediaReceiver, (err, player) => { + if (err) return fail(err); + if (settled) return; + settled = true; + + const tearDown = () => { + try { + player.removeAllListeners(); + } catch {} + try { + client.removeAllListeners(); + } catch {} + try { + player.stop(); + } catch {} + try { + client.close(); + } catch {} + }; + + player.on("status", (status) => { + if (!_session) return; + _session.lastStatus = _mapCastStatus(status); + pushStatus(_session.lastStatus); + }); + client.on("status", (status) => { + if (!_session) return; + if (status && status.volume) { + _session.lastStatus = { + ...(_session.lastStatus || {}), + volume: status.volume.level, + muted: !!status.volume.muted, + deviceId: device.id, + }; + pushStatus(_session.lastStatus); + } + }); + client.on("error", (e) => { + pushError(`Chromecast error: ${e.message}`); + pushSessionEnded("error"); + _session = null; + }); + + resolve({ client, player, tearDown }); + }); + }); + }); +} + +function _mapCastStatus(s) { + let playerState = "idle"; + if (s) { + switch (s.playerState) { + case "PLAYING": + playerState = "playing"; + break; + case "PAUSED": + playerState = "paused"; + break; + case "BUFFERING": + playerState = "buffering"; + break; + case "IDLE": + playerState = + s.idleReason === "FINISHED" ? "ended" : "idle"; + break; + } + } + return { + sessionState: playerState, + currentTime: s ? s.currentTime || 0 : 0, + duration: s && s.media ? s.media.duration || 0 : 0, + deviceId: _session ? _session.device.id : null, + }; +} + +// ── DLNA session ───────────────────────────────────────────────────────────── + +function _mapDlnaStatus(status) { + let playerState = "idle"; + if (status) { + switch (status.playerState) { + case "PLAYING": + playerState = "playing"; + break; + case "PAUSED_PLAYBACK": + playerState = "paused"; + break; + case "TRANSITIONNING": + case "TRANSITIONING": + playerState = "buffering"; + break; + case "STOPPED": + case "NO_MEDIA_PRESENT": + playerState = "idle"; + break; + } + } + return { + sessionState: playerState, + currentTime: status ? status.currentTime || 0 : 0, + duration: 0, + volume: status && status.volume ? (status.volume.level || 0) / 100 : 0, + muted: !!(status && status.volume && status.volume.muted), + deviceId: _session ? _session.device.id : null, + }; +} + +async function _connectDlna(device) { + const player = device.raw; + if (!player || typeof player.play !== "function") { + throw new Error("DLNA player object missing"); + } + player.removeAllListeners && player.removeAllListeners("status"); + player.on("status", (status) => { + if (!_session) return; + _session.lastStatus = _mapDlnaStatus(status); + pushStatus(_session.lastStatus); + }); + const tearDown = () => { + try { + player.removeAllListeners(); + } catch {} + try { + player.stop(); + } catch {} + }; + return { player, tearDown }; +} + +// ── Public session API ─────────────────────────────────────────────────────── + +async function connect(deviceId) { + if (!deviceId) return { ok: false, error: "missing deviceId" }; + const device = _devices.get(deviceId); + if (!device) return { ok: false, error: "device not found" }; + + // Single active session — disconnect previous + if (_session) { + try { + _session.tearDown && _session.tearDown(); + } catch {} + _session = null; + } + + try { + if (device.type === "cast") { + const { client, player, tearDown } = await _connectChromecast(device); + _session = { + device, + type: "cast", + client, + player, + lastStatus: { sessionState: "connecting", deviceId }, + tearDown, + }; + } else if (device.type === "dlna") { + const { player, tearDown } = await _connectDlna(device); + _session = { + device, + type: "dlna", + player, + lastStatus: { sessionState: "connecting", deviceId }, + tearDown, + }; + } else { + return { ok: false, error: `unknown device type: ${device.type}` }; + } + } catch (e) { + return { ok: false, error: e.message }; + } + + // Ensure the LAN HTTP server is up so subsequent loadMedia can mint URLs + try { + await getCastServer(); + } catch (e) { + return { ok: false, error: `cast server failed: ${e.message}` }; + } + + pushStatus(_session.lastStatus); + return { ok: true, deviceId }; +} + +async function loadMedia(args) { + if (!_session) return { ok: false, error: "no active session" }; + await getCastServer(); // safety: server must be running before tokens are minted + + const resolved = _resolveLoadArgs(args || {}); + _session.contentUrl = resolved.contentUrl; + _session.mediaInfo = resolved; + + if (_session.type === "cast") { + if (!castv2Client) castv2Client = require("castv2-client"); + const tracks = resolved.subtitles.map((s, i) => ({ + trackId: i + 1, + type: "TEXT", + trackContentId: s.url, + trackContentType: "text/vtt", + subtype: "SUBTITLES", + language: s.lang || "und", + name: s.lang || `Track ${i + 1}`, + })); + const media = { + contentId: resolved.contentUrl, + contentType: resolved.contentType, + streamType: "BUFFERED", + metadata: { + type: 0, + metadataType: 0, + title: resolved.title, + images: resolved.posterUrl ? [{ url: resolved.posterUrl }] : [], + }, + }; + if (tracks.length) { + media.tracks = tracks; + media.textTrackStyle = { + backgroundColor: "#00000000", + foregroundColor: "#FFFFFFFF", + edgeType: "OUTLINE", + edgeColor: "#000000FF", + fontScale: 1.0, + }; + } + const options = { + autoplay: true, + currentTime: resolved.startTime, + }; + if (tracks.length) options.activeTrackIds = [1]; + + return new Promise((resolve) => { + _session.player.load(media, options, (err) => { + if (err) return resolve({ ok: false, error: err.message }); + resolve({ ok: true, contentUrl: resolved.contentUrl }); + }); + }); + } + + if (_session.type === "dlna") { + const opts = { + type: resolved.contentType, + title: resolved.title, + seek: resolved.startTime, + dlnaFeatures: + "DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01100000000000000000000000000000", + }; + if (resolved.subtitles.length) { + opts.subtitles = resolved.subtitles.map((s) => s.url); + opts.autoSubtitles = true; + } + return new Promise((resolve) => { + _session.player.play(resolved.contentUrl, opts, (err) => { + if (err) return resolve({ ok: false, error: err.message }); + resolve({ ok: true, contentUrl: resolved.contentUrl }); + }); + }); + } + + return { ok: false, error: "unknown session type" }; +} + +function _wrap(fn) { + return new Promise((resolve) => { + try { + fn((err) => { + if (err) return resolve({ ok: false, error: err.message }); + resolve({ ok: true }); + }); + } catch (e) { + resolve({ ok: false, error: e.message }); + } + }); +} + +async function play() { + if (!_session) return { ok: false, error: "no active session" }; + if (_session.type === "cast") return _wrap((cb) => _session.player.play(cb)); + if (_session.type === "dlna") return _wrap((cb) => _session.player.resume(cb)); + return { ok: false, error: "unknown session type" }; +} + +async function pause() { + if (!_session) return { ok: false, error: "no active session" }; + if (_session.type === "cast") return _wrap((cb) => _session.player.pause(cb)); + if (_session.type === "dlna") return _wrap((cb) => _session.player.pause(cb)); + return { ok: false, error: "unknown session type" }; +} + +async function stop() { + if (!_session) return { ok: false, error: "no active session" }; + if (_session.type === "cast") return _wrap((cb) => _session.player.stop(cb)); + if (_session.type === "dlna") return _wrap((cb) => _session.player.stop(cb)); + return { ok: false, error: "unknown session type" }; +} + +async function seek(seconds) { + if (!_session) return { ok: false, error: "no active session" }; + const sec = Number(seconds) || 0; + if (_session.type === "cast") return _wrap((cb) => _session.player.seek(sec, cb)); + if (_session.type === "dlna") return _wrap((cb) => _session.player.seek(sec, cb)); + return { ok: false, error: "unknown session type" }; +} + +async function setVolume(level) { + if (!_session) return { ok: false, error: "no active session" }; + const lvl = Math.max(0, Math.min(1, Number(level) || 0)); + if (_session.type === "cast") + return _wrap((cb) => _session.client.setVolume({ level: lvl }, cb)); + if (_session.type === "dlna") + return _wrap((cb) => _session.player.setVolume(Math.round(lvl * 100), cb)); + return { ok: false, error: "unknown session type" }; +} + +async function setMute(muted) { + if (!_session) return { ok: false, error: "no active session" }; + if (_session.type === "cast") + return _wrap((cb) => _session.client.setVolume({ muted: !!muted }, cb)); + // DLNA mute via setVolume(0) is unreliable; many renderers don't expose mute. No-op. + return { ok: true, note: "mute-not-supported" }; +} + +/** + * Set the active subtitle track. + * trackIndex: 0-based into the subtitles array passed to loadMedia. + * null/negative = subtitles off. + */ +async function setSubtitleTrack(trackIndex) { + if (!_session) return { ok: false, error: "no active session" }; + if (_session.type === "cast") { + const activeTrackIds = + trackIndex == null || trackIndex < 0 ? [] : [trackIndex + 1]; + return new Promise((resolve) => { + try { + _session.player.media.sessionRequest( + { type: "EDIT_TRACKS_INFO", activeTrackIds }, + (err) => { + if (err) return resolve({ ok: false, error: err.message }); + resolve({ ok: true }); + }, + ); + } catch (e) { + resolve({ ok: false, error: e.message }); + } + }); + } + if (_session.type === "dlna") { + return _wrap((cb) => + _session.player.subtitles(trackIndex == null || trackIndex < 0 ? false : trackIndex, cb), + ); + } + return { ok: false, error: "unknown session type" }; +} + +async function disconnect() { + if (!_session) return { ok: true }; + try { + _session.tearDown && _session.tearDown(); + } catch {} + _session = null; + pushSessionEnded("user"); + return { ok: true }; +} + +function getStatus() { + if (!_session) return { sessionState: "idle" }; + return _session.lastStatus || { sessionState: "connecting" }; +} + +// ── IPC registration ───────────────────────────────────────────────────────── + +function register(getMainWindow) { + _getMainWindow = getMainWindow || (() => null); + + ipcMain.handle("cast:start-discovery", async () => { + try { + return await startDiscovery(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:stop-discovery", async () => { + try { + return await stopDiscovery(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:list-devices", () => listDevices()); + + ipcMain.handle("cast:connect", async (_, { deviceId } = {}) => { + try { + return await connect(deviceId); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:disconnect", async () => { + try { + return await disconnect(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:load", async (_, args) => { + try { + return await loadMedia(args || {}); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:play", async () => { + try { + return await play(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:pause", async () => { + try { + return await pause(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:stop", async () => { + try { + return await stop(); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:seek", async (_, { seconds } = {}) => { + try { + return await seek(seconds); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:set-volume", async (_, { level } = {}) => { + try { + return await setVolume(level); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:set-mute", async (_, { muted } = {}) => { + try { + return await setMute(muted); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + ipcMain.handle("cast:get-status", () => getStatus()); + + ipcMain.handle("cast:set-subtitle-track", async (_, { trackIndex } = {}) => { + try { + return await setSubtitleTrack( + trackIndex === undefined ? null : trackIndex, + ); + } catch (e) { + return { ok: false, error: e.message }; + } + }); + + // Clean disconnect on quit so receiver doesn't sit on a black screen + app.on("before-quit", () => { + try { + if (_session && _session.tearDown) _session.tearDown(); + } catch {} + _session = null; + }); +} + +module.exports = { + register, + // Exposed for internal use (e.g. tests, future modules): + _internals: { + listDevices, + getLocalIPv4, + getCastServer, + makeToken, + castUrlFor, + }, +}; diff --git a/src/pages/DownloadsPage.jsx b/src/pages/DownloadsPage.jsx index df13742..c6f0b07 100644 --- a/src/pages/DownloadsPage.jsx +++ b/src/pages/DownloadsPage.jsx @@ -7,9 +7,13 @@ import { FilmIcon, WatchedIcon, SubtitlesIcon, + CastIcon, } from "../components/Icons"; import { storage, isElectron, STORAGE_KEYS } from "../utils/storage"; import SubtitleDownloaderModal from "../components/SubtitleDownloaderModal"; +import CastPickerModal from "../components/CastPickerModal"; +import CastMiniController from "../components/CastMiniController"; +import { useCast } from "../utils/useCast"; import { imgUrl } from "../utils/api"; const STATUS_CLASS = { @@ -100,6 +104,22 @@ export default function DownloadsPage({ const [localFiles, setLocalFiles] = useState( () => storage.get("localFiles") || [], ); + // Casting + const cast = useCast({ autoDiscover: true }); + const [castDl, setCastDl] = useState(null); + const castLoadArgs = useMemo(() => { + if (!castDl || !castDl.filePath) return null; + const subs = (castDl.subtitlePaths || []) + .filter((sp) => sp && sp.path) + .map((sp) => ({ path: sp.path, lang: sp.lang || "und" })); + return { + mode: "localFile", + filePath: castDl.filePath, + title: castDl.name || "Streambert", + posterUrl: castDl.posterPath ? imgUrl(castDl.posterPath, "w500") : null, + localVttSubs: subs, + }; + }, [castDl]); const [scanning, setScanning] = useState(false); const [scanFolder, setScanFolder] = useState( () => storage.get("downloadPath") || "", @@ -336,6 +356,13 @@ export default function DownloadsPage({ return (
+ setCastDl(null)} + cast={cast} + loadArgs={castLoadArgs} + /> + {cast.currentDevice && } {subtitleModalDl && ( window.electron?.showInFolder(dl.filePath) } + onCast={() => setCastDl(dl)} onDelete={dl.isLocalOnly ? undefined : () => handleDelete(dl)} isHighlighted={isHighlighted} highlightRef={isHighlighted ? highlightRef : null} @@ -763,6 +791,7 @@ const LocalFileCard = memo(function LocalFileCard({ onHistory, onOpenSubtitleDownloader, onOpenLog, + onCast, }) { const isDownload = !dl.isLocalOnly; const canWatch = !!fileExists && !!dl.filePath; @@ -1312,6 +1341,15 @@ const LocalFileCard = memo(function LocalFileCard({ {savedSecs > 0 ? "Resume" : "Watch"} )} + {canWatch && onCast && ( + + )} {dl.filePath && ( + {/* Cast button */} + {/* Pop-out button*/}
); } diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index a93a4d6..f52ac97 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -1992,6 +1992,263 @@ function NotificationsSection() { ); } +// ── Cast & Devices Section ──────────────────────────────────────────────────── +function CastSettingsSection() { + const [autoDiscover, setAutoDiscover] = useState( + () => storage.get(STORAGE_KEYS.CAST_AUTO_DISCOVER) ?? true, + ); + const [enableDlna, setEnableDlna] = useState( + () => storage.get(STORAGE_KEYS.CAST_ENABLE_DLNA) ?? true, + ); + const [autoStop, setAutoStop] = useState( + () => storage.get(STORAGE_KEYS.CAST_AUTO_STOP_ON_PLAYER_STOP) ?? false, + ); + const [preferredId, setPreferredId] = useState( + () => storage.get(STORAGE_KEYS.CAST_PREFERRED_DEVICE_ID) || "", + ); + const [devices, setDevices] = useState([]); + const [scanning, setScanning] = useState(false); + + useEffect(() => { + if (!window.electron) return; + let mounted = true; + setScanning(true); + window.electron + .castStartDiscovery?.() + .finally(() => mounted && setScanning(false)); + window.electron.castListDevices?.().then((list) => { + if (mounted && Array.isArray(list)) setDevices(list); + }); + const h = window.electron.onCastDevicesUpdated?.((list) => { + if (mounted && Array.isArray(list)) setDevices(list); + }); + return () => { + mounted = false; + if (h) window.electron.offCastDevicesUpdated?.(h); + }; + }, []); + + const toggleAuto = (v) => { + setAutoDiscover(v); + storage.set(STORAGE_KEYS.CAST_AUTO_DISCOVER, v); + }; + const toggleDlna = (v) => { + setEnableDlna(v); + storage.set(STORAGE_KEYS.CAST_ENABLE_DLNA, v); + }; + const toggleAutoStop = (v) => { + setAutoStop(v); + storage.set(STORAGE_KEYS.CAST_AUTO_STOP_ON_PLAYER_STOP, v); + }; + const handlePreferred = (id) => { + setPreferredId(id); + storage.set(STORAGE_KEYS.CAST_PREFERRED_DEVICE_ID, id || null); + }; + + const visibleDevices = enableDlna + ? devices + : devices.filter((d) => d.type !== "dlna"); + + return ( +
+ {/* Auto-discover */} +
+ +
+
+ Auto-discover devices on app start +
+
+ Streambert scans the LAN for Chromecast and DLNA receivers so the + picker is ready instantly. +
+
+
+ + {/* DLNA toggle */} +
+ +
+
+ Include DLNA (UPnP) devices +
+
+ Useful for older smart TVs and non-AndroidTV XGIMI models. Disable + if SSDP discovery is noisy on your network. +
+
+
+ + {/* Auto-stop */} +
+ +
+
+ Disconnect cast when in-app player stops +
+
+ Leave off if you want to keep casting after closing the player view. +
+
+
+ + {/* Preferred device */} +
+
+
+
+ Preferred device +
+
+ When set, the in-player Cast button one-taps to this device + instead of opening the picker. +
+
+ ({ + value: d.id, + label: `${d.friendlyName || d.name} (${d.type.toUpperCase()})`, + })), + ]} + /> +
+
+ + + {visibleDevices.length === 0 + ? "No devices found yet" + : `${visibleDevices.length} device${visibleDevices.length === 1 ? "" : "s"} found`} + +
+
+ + {/* Notes & limitations */} +
+
+ Notes & limitations +
+
    +
  • + Some streams use one-time signed segment URLs — restart playback if + casting stalls after long sessions. +
  • +
  • + DRM-protected content (Widevine / PlayReady) will not play on cast + receivers. +
  • +
  • + Non-AndroidTV DLNA-only renderers usually reject HLS — prefer MP4 + anime sources or local downloads on those. +
  • +
  • + Mid-segment seeks on rewritten HLS can take 2–3 seconds. +
  • +
  • Only one active session at a time (mirrors pop-out).
  • +
  • + The cast proxy injects Referer + User-Agent only — sources that + require cookies will not work. +
  • +
+
+
+ ); +} + // ── Section Group Header ────────────────────────────────────────────────────── function SectionGroupHeader({ title, subtitle }) { return ( @@ -2124,6 +2381,25 @@ const SECTION_NAV = [ "files", ], }, + { + id: "cast", + label: "Cast & Devices", + icon: "📡", + keywords: [ + "cast", + "chromecast", + "dlna", + "upnp", + "tv", + "projector", + "xgimi", + "screen", + "mirror", + "device", + "network", + "discover", + ], + }, { id: "notifications", label: "Notifications", @@ -2842,6 +3118,7 @@ export default function SettingsPage({ const secPlayback = useRef(null); const secSubtitles = useRef(null); const secDownloads = useRef(null); + const secCast = useRef(null); const secNotifications = useRef(null); const secInterface = useRef(null); const secLibrary = useRef(null); @@ -2854,6 +3131,7 @@ export default function SettingsPage({ playback: secPlayback, subtitles: secSubtitles, downloads: secDownloads, + cast: secCast, notifications: secNotifications, interface: secInterface, library: secLibrary, @@ -3593,6 +3871,17 @@ export default function SettingsPage({ + {/* ══════════════════════════════════════════════════════════════════ */} + {/* GROUP: CAST & DEVICES */} + {/* ══════════════════════════════════════════════════════════════════ */} +
+ + +
+ {/* ══════════════════════════════════════════════════════════════════ */} {/* GROUP: NOTIFICATIONS */} {/* ══════════════════════════════════════════════════════════════════ */} diff --git a/src/pages/TVPage.jsx b/src/pages/TVPage.jsx index 397fa56..24f8916 100644 --- a/src/pages/TVPage.jsx +++ b/src/pages/TVPage.jsx @@ -44,11 +44,16 @@ import { SourceIcon, ShieldBlockIcon, PopOutIcon, + CastIcon, + CastingIcon, } from "../components/Icons"; import DownloadModal from "../components/DownloadModal"; import TrailerModal from "../components/TrailerModal"; import BlockedStatsModal from "../components/BlockedStatsModal"; +import CastPickerModal from "../components/CastPickerModal"; +import CastMiniController from "../components/CastMiniController"; import { useBlockedStats } from "../utils/useBlockedStats"; +import { useCast } from "../utils/useCast"; import { storage, STORAGE_KEYS } from "../utils/storage"; import { fetchAniSkipTimings } from "../utils/aniSkip"; import { @@ -379,6 +384,10 @@ export default function TVPage({ const [showTrailer, setShowTrailer] = useState(false); const [m3u8Url, setM3u8Url] = useState(null); const [interceptedSubs, setInterceptedSubs] = useState([]); + // Casting + const cast = useCast({ autoDiscover: true }); + const [showCastPicker, setShowCastPicker] = useState(false); + const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); const [playerSource, setPlayerSource] = useState( () => storage.get("playerSource") || NON_ANIME_DEFAULT_SOURCE, ); @@ -663,6 +672,7 @@ export default function TVPage({ if (!mounted) return; if (res?.ok && res.url) { if (res.isDirectMp4 !== undefined) { + setAllmangaIsDirectMp4(!!res.isDirectMp4); window.electron .setPlayerVideo({ url: res.url, @@ -1751,6 +1761,31 @@ export default function TVPage({ )} + {/* Cast button */} + {/* Pop-out button */} + ); + + const subsBtn = ( + + ); + + const seekBar = ( +
+ {fmtTime(drag ?? position)} + + {duration ? fmtTime(duration) : "--:--"} +
+ ); + + const volumeSlider = ( + cast.setVolume(Number(e.target.value) / 100)} + title="Volume" + style={{ width: variant === "modal" ? 110 : 70, cursor: "pointer" }} + /> + ); + + const stopBtn = ( + + ); + + const deviceLabel = ( +
+ +
+
+ Casting to +
+
+ {currentDevice.friendlyName || currentDevice.name} +
+
+
+ ); + + // ── Modal: stacked full remote ────────────────────────────────────────────── + if (variant === "modal") { + return ( +
+ {deviceLabel} + {seekBar} +
+ + {playPauseBtn} + + {subsBtn} +
+ 🔊 {volumeSlider} +
+ {stopBtn} +
+
+ ); + } + + // ── Player / global: horizontal bar ───────────────────────────────────────── const containerStyle = variant === "global" ? { @@ -86,135 +248,26 @@ export default function CastMiniController({ cast, variant = "player" }) { return (
-
- -
-
- Casting to -
-
- {currentDevice.friendlyName || currentDevice.name} -
-
- - - -
- - {fmtTime(drag ?? position)} - - - - {duration ? fmtTime(duration) : "--:--"} - +
+
+ {deviceLabel}
- - - - {variant === "player" && ( - cast.setVolume(Number(e.target.value) / 100)} - title="Volume" - style={{ width: 70, cursor: "pointer" }} - /> - )} - - + {playPauseBtn} + {seekBar} + {subsBtn} + {variant === "player" && volumeSlider} + {stopBtn}
); } + +function timeStyle(textAlign) { + return { + fontSize: 10, + color: "var(--text3)", + fontVariantNumeric: "tabular-nums", + minWidth: 36, + textAlign, + }; +} diff --git a/src/components/CastPickerModal.jsx b/src/components/CastPickerModal.jsx index 831c5fc..bfd7545 100644 --- a/src/components/CastPickerModal.jsx +++ b/src/components/CastPickerModal.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { CastIcon } from "./Icons"; +import CastMiniController from "./CastMiniController"; import { storage, STORAGE_KEYS } from "../utils/storage"; const MAX_RECENT = 5; @@ -116,6 +117,11 @@ export default function CastPickerModal({ if (!open) return; setError(null); cast.startDiscovery(); + // Stop scanning when the picker closes so SSDP/mDNS don't run in the + // background. The active session (if any) is unaffected by stopping discovery. + return () => { + cast.stopDiscovery(); + }; }, [open]); if (!open) return null; @@ -200,7 +206,7 @@ export default function CastPickerModal({ }} > - Cast to a device + {cast.currentDevice ? "Casting" : "Cast to a device"}
)} + {/* Casting active: stop the in-app player; playback is on the device */} + {cast.currentDevice && !pipOpen && ( +
+ + + Playing on {cast.currentDevice.friendlyName || cast.currentDevice.name} + +
+ +
+
+ )} - {cast.currentDevice && }
); } diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index f52ac97..519fb31 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -2014,7 +2014,7 @@ function CastSettingsSection() { let mounted = true; setScanning(true); window.electron - .castStartDiscovery?.() + .castStartDiscovery?.({ enableDlna }) .finally(() => mounted && setScanning(false)); window.electron.castListDevices?.().then((list) => { if (mounted && Array.isArray(list)) setDevices(list); @@ -2025,6 +2025,8 @@ function CastSettingsSection() { return () => { mounted = false; if (h) window.electron.offCastDevicesUpdated?.(h); + // Stop scanning when leaving Settings so SSDP/mDNS don't run in background. + window.electron.castStopDiscovery?.(); }; }, []); @@ -2182,7 +2184,7 @@ function CastSettingsSection() { onClick={() => { setScanning(true); window.electron - ?.castStartDiscovery?.() + ?.castStartDiscovery?.({ enableDlna }) .finally(() => setScanning(false)); }} disabled={scanning} diff --git a/src/pages/TVPage.jsx b/src/pages/TVPage.jsx index 24f8916..f9ff303 100644 --- a/src/pages/TVPage.jsx +++ b/src/pages/TVPage.jsx @@ -385,7 +385,7 @@ export default function TVPage({ const [m3u8Url, setM3u8Url] = useState(null); const [interceptedSubs, setInterceptedSubs] = useState([]); // Casting - const cast = useCast({ autoDiscover: true }); + const cast = useCast(); const [showCastPicker, setShowCastPicker] = useState(false); const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); const [playerSource, setPlayerSource] = useState( @@ -1675,10 +1675,43 @@ export default function TVPage({
)} + {/* Casting active: stop the in-app player; playback is on the device */} + {cast.currentDevice && !pipOpen && ( +
+ + + Playing on{" "} + {cast.currentDevice.friendlyName || cast.currentDevice.name} + +
+ +
+
+ )} - {cast.currentDevice && } ); } diff --git a/src/utils/useCast.js b/src/utils/useCast.js index f7259e0..a733e0d 100644 --- a/src/utils/useCast.js +++ b/src/utils/useCast.js @@ -1,24 +1,37 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { storage, STORAGE_KEYS } from "./storage"; + +// Discovery options derived from user settings (gate DLNA SSDP if disabled). +const discoveryOpts = () => ({ + enableDlna: storage.get(STORAGE_KEYS.CAST_ENABLE_DLNA) ?? true, +}); /** * Casting state + control hook. Subscribes to main-process cast IPC. * + * Discovery is on-demand: nothing scans the network until `startDiscovery()` + * is called (the picker / settings do this when opened). The active session's + * device is tracked independently of the live discovery list, so the overlay + * and controls keep working even while discovery is stopped. + * * Returns: * devices Device[] * isDiscovering bool - * currentDevice Device | null + * currentDevice Device | null (the connected device; survives discovery off) * sessionState "idle"|"connecting"|"buffering"|"playing"|"paused"|"ended"|"error" * position, duration seconds * volume, muted 0..1, bool * lastError string | null * * Actions: startDiscovery, stopDiscovery, connect, disconnect, - * load, play, pause, stop, seek, setVolume, setMute + * load, play, pause, stop, seek, setVolume, setMute, setSubtitleTrack */ -export function useCast({ autoDiscover = true } = {}) { +export function useCast({ autoDiscover = false } = {}) { const [devices, setDevices] = useState([]); const [isDiscovering, setIsDiscovering] = useState(false); - const [currentDeviceId, setCurrentDeviceId] = useState(null); + // The connected device object — set on connect, cleared on session end. + // Independent of `devices` so it survives discovery being stopped. + const [connectedDevice, setConnectedDevice] = useState(null); const [sessionState, setSessionState] = useState("idle"); const [position, setPosition] = useState(0); const [duration, setDuration] = useState(0); @@ -27,6 +40,8 @@ export function useCast({ autoDiscover = true } = {}) { const [lastError, setLastError] = useState(null); const mountedRef = useRef(true); + const devicesRef = useRef([]); + devicesRef.current = devices; // Subscribe to push events from main useEffect(() => { @@ -44,12 +59,11 @@ export function useCast({ autoDiscover = true } = {}) { if (Number.isFinite(s.duration) && s.duration > 0) setDuration(s.duration); if (Number.isFinite(s.volume)) setVolume(s.volume); if (typeof s.muted === "boolean") setMuted(s.muted); - if (s.deviceId) setCurrentDeviceId(s.deviceId); }); const endedH = window.electron.onCastSessionEnded?.(() => { if (!mountedRef.current) return; setSessionState("idle"); - setCurrentDeviceId(null); + setConnectedDevice(null); setPosition(0); setDuration(0); }); @@ -58,14 +72,9 @@ export function useCast({ autoDiscover = true } = {}) { setLastError(e?.message || "Cast error"); }); - // Prime device list - window.electron.castListDevices?.().then((list) => { - if (mountedRef.current && Array.isArray(list)) setDevices(list); - }); - if (autoDiscover) { setIsDiscovering(true); - window.electron.castStartDiscovery?.().finally(() => { + window.electron.castStartDiscovery?.(discoveryOpts()).finally(() => { if (mountedRef.current) setIsDiscovering(false); }); } @@ -93,13 +102,12 @@ export function useCast({ autoDiscover = true } = {}) { return () => clearInterval(t); }, [sessionState]); - const currentDevice = - devices.find((d) => d.id === currentDeviceId) || null; + const currentDevice = connectedDevice; const startDiscovery = useCallback(async () => { setIsDiscovering(true); try { - await window.electron?.castStartDiscovery?.(); + await window.electron?.castStartDiscovery?.(discoveryOpts()); } finally { setIsDiscovering(false); } @@ -112,10 +120,14 @@ export function useCast({ autoDiscover = true } = {}) { const connect = useCallback(async (deviceId) => { setLastError(null); setSessionState("connecting"); + const device = + devicesRef.current.find((d) => d.id === deviceId) || + { id: deviceId, name: deviceId, type: "cast" }; + setConnectedDevice(device); const r = await window.electron?.castConnect?.(deviceId); - if (r?.ok) setCurrentDeviceId(deviceId); - else { + if (!r?.ok) { setSessionState("idle"); + setConnectedDevice(null); setLastError(r?.error || "Connect failed"); } return r; @@ -124,7 +136,7 @@ export function useCast({ autoDiscover = true } = {}) { const disconnect = useCallback(async () => { const r = await window.electron?.castDisconnect?.(); setSessionState("idle"); - setCurrentDeviceId(null); + setConnectedDevice(null); setPosition(0); setDuration(0); return r; From 030fb76fee7a66b36c0dd2f3535546c7fa62e55d Mon Sep 17 00:00:00 2001 From: Daniel Bakhsheshi Date: Sat, 23 May 2026 11:42:51 +1000 Subject: [PATCH 3/4] Fix cast device discovery on multi-homed machines + harden serving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovery (root cause of "no devices found") - Bind the mDNS socket to the LAN interface (new Bonjour({ interface })) via getLocalIPv4(). On machines with an active VPN or a second adapter the default bind landed on the wrong interface and received zero responses, so no Chromecast / Chromecast-built-in (e.g. XGIMI) devices ever appeared. Rebuild the instance if the routable IP changes (VPN toggled / network switched) so discovery recovers without a restart. - Keep the mDNS browser alive for the session and re-query on reopen instead of tearing it down on every picker/settings close (the teardown made already-found devices disappear). - Restore page-mount discovery (autoDiscover) now that it's cheap: passive mDNS listener, DLNA SSDP gated by setting + throttled to 8s, and the dlnacasts "[DLNACASTS] querying ssdp" debug line silenced. - Hold the "scanning" state for a ~5s response window so the picker shows "Scanning the network…" instead of flashing "No devices found" (the start-discovery IPC resolves immediately while responses trickle in). Serving correctness (from code review) - getLocalIPv4 is now subnet-aware: when a receiver is connected it picks the local interface on the receiver's subnet, falling back to a preferred adapter, then any non-internal IPv4, then loopback. Prevents handing the receiver an unreachable URL on multi-NIC setups. - Dedup proxy tokens by target+referer (makeProxyToken) so HLS playlist re-fetches (seeks/variant switches) reuse tokens instead of growing _serveTokens unbounded; gcServeTokens prunes the dedup index too. Co-Authored-By: Claude Opus 4.7 --- src/components/CastPickerModal.jsx | 7 +- src/ipc/cast.js | 146 ++++++++++++++++++++++++----- src/pages/DownloadsPage.jsx | 2 +- src/pages/MoviePage.jsx | 2 +- src/pages/SettingsPage.jsx | 2 - src/pages/TVPage.jsx | 2 +- src/utils/useCast.js | 35 ++++--- 7 files changed, 147 insertions(+), 49 deletions(-) diff --git a/src/components/CastPickerModal.jsx b/src/components/CastPickerModal.jsx index bfd7545..38ee0eb 100644 --- a/src/components/CastPickerModal.jsx +++ b/src/components/CastPickerModal.jsx @@ -116,12 +116,9 @@ export default function CastPickerModal({ useEffect(() => { if (!open) return; setError(null); + // Re-query on open. The mDNS browser stays alive for the session (passive, + // no polling), so devices already found remain listed; this just refreshes. cast.startDiscovery(); - // Stop scanning when the picker closes so SSDP/mDNS don't run in the - // background. The active session (if any) is unaffected by stopping discovery. - return () => { - cast.stopDiscovery(); - }; }, [open]); if (!open) return null; diff --git a/src/ipc/cast.js b/src/ipc/cast.js index 847dd5f..cd32b13 100644 --- a/src/ipc/cast.js +++ b/src/ipc/cast.js @@ -28,6 +28,7 @@ const _devices = new Map(); let _bonjourBrowser = null; let _bonjourInstance = null; +let _bonjourBindIp = null; let _dlnaList = null; let _discoveryRunning = false; let _lastDlnaUpdate = 0; @@ -40,6 +41,12 @@ let _castServer = null; /** token -> { kind:"file"|"sub"|"proxy", filePath?, target?, referer?, headers?, contentType?, expiresAt } */ const _serveTokens = new Map(); +/** Dedup index for proxy tokens: `${target}\n${referer}\n${isHls}` -> token. + * Prevents the HLS rewriter minting a fresh token every time a receiver + * re-fetches a playlist (seek / variant switch), which would grow _serveTokens + * without bound over a long session. */ +const _proxyTokenIndex = new Map(); + const SERVE_TOKEN_TTL_MS = 12 * 60 * 60 * 1000; // 12h let _getMainWindow = () => null; @@ -88,10 +95,34 @@ function listDevices() { .sort((a, b) => a.name.localeCompare(b.name)); } -function getLocalIPv4() { +function _ipv4ToInt(ip) { + const p = ip.split("."); + if (p.length !== 4) return null; + let n = 0; + for (const part of p) { + const o = Number(part); + if (!Number.isInteger(o) || o < 0 || o > 255) return null; + n = (n << 8) | o; + } + return n >>> 0; +} + +function _sameSubnet(ip, netmask, target) { + const a = _ipv4ToInt(ip); + const m = _ipv4ToInt(netmask); + const t = _ipv4ToInt(target); + if (a == null || m == null || t == null) return false; + return ((a & m) >>> 0) === ((t & m) >>> 0); +} + +// Pick the LAN IPv4 the cast receiver can actually reach. When the receiver's +// address is known, prefer the local interface on the SAME subnet (critical on +// machines with a VPN/secondary NIC, where the first "preferred"-named adapter +// may be on an unreachable network). Falls back to a preferred adapter, then any +// non-internal IPv4, then loopback. +function getLocalIPv4(targetAddr) { const ifaces = os.networkInterfaces(); - const preferred = []; - const fallback = []; + const candidates = []; for (const [name, addrs] of Object.entries(ifaces)) { if (!addrs) continue; const lname = name.toLowerCase(); @@ -113,11 +144,21 @@ function getLocalIPv4() { for (const addr of addrs) { if (addr.family !== "IPv4" || addr.internal) continue; if (isVirtual) continue; - if (isPreferred) preferred.push(addr.address); - else fallback.push(addr.address); + candidates.push({ + address: addr.address, + netmask: addr.netmask, + preferred: isPreferred, + }); } } - return preferred[0] || fallback[0] || "127.0.0.1"; + if (targetAddr) { + const match = candidates.find((c) => + _sameSubnet(c.address, c.netmask, targetAddr), + ); + if (match) return match.address; + } + const pref = candidates.find((c) => c.preferred); + return (pref || candidates[0])?.address || "127.0.0.1"; } function makeToken(entry, ttlMs = SERVE_TOKEN_TTL_MS) { @@ -126,9 +167,33 @@ function makeToken(entry, ttlMs = SERVE_TOKEN_TTL_MS) { return token; } +// Reuse a proxy token for an identical upstream so repeated playlist fetches +// don't accumulate tokens. Refreshes the TTL on reuse. +function makeProxyToken({ target, referer, headers, isHls }) { + const key = `${target}\n${referer || ""}\n${isHls ? 1 : 0}`; + const existing = _proxyTokenIndex.get(key); + if (existing) { + const e = _serveTokens.get(existing); + if (e && e.expiresAt > Date.now()) { + e.expiresAt = Date.now() + SERVE_TOKEN_TTL_MS; + return existing; + } + _proxyTokenIndex.delete(key); + } + const token = makeToken({ + kind: "proxy", + target, + referer: referer || null, + headers: headers || {}, + isHls: !!isHls, + }); + _proxyTokenIndex.set(key, token); + return token; +} + function castUrlFor(token, kind = "file") { if (!_castServer) return null; - const ip = getLocalIPv4(); + const ip = getLocalIPv4(_session?.device?.address); const port = _castServer.address().port; return `http://${ip}:${port}/${kind}/${token}`; } @@ -138,6 +203,9 @@ function gcServeTokens() { for (const [tok, entry] of _serveTokens) { if (entry.expiresAt < now) _serveTokens.delete(tok); } + for (const [key, tok] of _proxyTokenIndex) { + if (!_serveTokens.has(tok)) _proxyTokenIndex.delete(key); + } } setInterval(gcServeTokens, 5 * 60 * 1000).unref(); @@ -227,6 +295,8 @@ function _isHlsContentType(ct) { /** Rewrite an HLS playlist so every segment URL flows through /proxy/. */ function _rewriteHlsPlaylist(body, baseUrl, parentEntry) { const baseHeadersJson = JSON.stringify(parentEntry.headers || {}); + const ip = getLocalIPv4(_session?.device?.address); + const port = _castServer.address().port; const rewriteUrl = (raw) => { let abs; try { @@ -234,8 +304,7 @@ function _rewriteHlsPlaylist(body, baseUrl, parentEntry) { } catch { return raw; } - const tok = makeToken({ - kind: "proxy", + const tok = makeProxyToken({ target: abs, referer: parentEntry.referer, headers: JSON.parse(baseHeadersJson), @@ -243,8 +312,6 @@ function _rewriteHlsPlaylist(body, baseUrl, parentEntry) { // upstream mislabels their content-type. isHls: /\.m3u8(\?|$)/i.test(abs), }); - const ip = getLocalIPv4(); - const port = _castServer.address().port; return `http://${ip}:${port}/proxy/${tok}`; }; return body @@ -579,23 +646,47 @@ function _quietDlnaSearch(fn) { const DLNA_SSDP_THROTTLE_MS = 8000; async function startDiscovery({ enableDlna = true } = {}) { - // Re-entrant: if already scanning, just (throttled) re-query DLNA so newly - // powered-on renderers surface, without restarting the mDNS browser. - const firstStart = !_discoveryRunning; _discoveryRunning = true; // ── Chromecast via mDNS ─────────────────────────────────────────────────── - if (firstStart) { - try { - if (!Bonjour) Bonjour = require("bonjour-service").Bonjour; - if (!_bonjourInstance) _bonjourInstance = new Bonjour(); + // Bind the mDNS socket to the LAN interface. On multi-homed machines (active + // VPN or a second adapter) the default bind lands on the wrong interface and + // receives zero responses — the cause of "no devices found". Rebuild if the + // routable IP changed (e.g. VPN toggled) so discovery recovers without a + // restart. The browser is otherwise kept alive for the session (passive + // listener, no polling, no log noise); subsequent calls issue a fresh query. + try { + if (!Bonjour) Bonjour = require("bonjour-service").Bonjour; + const bindIp = getLocalIPv4(); + if (_bonjourInstance && _bonjourBindIp && _bonjourBindIp !== bindIp) { + try { + if (_bonjourBrowser) _bonjourBrowser.stop(); + } catch {} + try { + _bonjourInstance.destroy(); + } catch {} + _bonjourBrowser = null; + _bonjourInstance = null; + } + if (!_bonjourInstance) { + _bonjourInstance = + bindIp && bindIp !== "127.0.0.1" + ? new Bonjour({ interface: bindIp }) + : new Bonjour(); + _bonjourBindIp = bindIp; + } + if (!_bonjourBrowser) { _bonjourBrowser = _bonjourInstance.find({ type: "googlecast" }, (svc) => { _addCastDevice(svc); }); _bonjourBrowser.on("down", (svc) => _removeCastDevice(svc)); - } catch (e) { - pushError(`Chromecast discovery failed: ${e.message}`); + } else { + try { + _bonjourBrowser.update(); + } catch {} } + } catch (e) { + pushError(`Chromecast discovery failed: ${e.message}`); } // ── DLNA via SSDP (dlnacasts3), gated by setting + throttled ─────────────── @@ -620,14 +711,19 @@ async function startDiscovery({ enableDlna = true } = {}) { return { ok: true }; } +// Fully tear down discovery sockets. Not called on every picker close (that +// caused devices to disappear); reserved for app shutdown. async function stopDiscovery() { - if (!_discoveryRunning) return { ok: true }; _discoveryRunning = false; try { if (_bonjourBrowser) _bonjourBrowser.stop(); } catch {} _bonjourBrowser = null; - // dlnacasts3 stops its SSDP socket once the last "update" listener is removed. + try { + if (_bonjourInstance) _bonjourInstance.destroy(); + } catch {} + _bonjourInstance = null; + _bonjourBindIp = null; try { if (_dlnaList) { _dlnaList.removeAllListeners("update"); @@ -667,8 +763,7 @@ function _resolveLoadArgs(args) { out.contentUrl = castUrlFor(token, "file"); out.contentType = args.contentType || _contentTypeForExt(args.filePath || ""); } else if (args.mode === "mp4Remote") { - const token = makeToken({ - kind: "proxy", + const token = makeProxyToken({ target: args.url, referer: args.referer || null, headers: args.extraHeaders || {}, @@ -676,8 +771,7 @@ function _resolveLoadArgs(args) { out.contentUrl = castUrlFor(token, "proxy"); out.contentType = args.contentType || "video/mp4"; } else if (args.mode === "hlsRemote") { - const token = makeToken({ - kind: "proxy", + const token = makeProxyToken({ target: args.url, referer: args.referer || null, headers: args.extraHeaders || {}, diff --git a/src/pages/DownloadsPage.jsx b/src/pages/DownloadsPage.jsx index c34d875..c6f0b07 100644 --- a/src/pages/DownloadsPage.jsx +++ b/src/pages/DownloadsPage.jsx @@ -105,7 +105,7 @@ export default function DownloadsPage({ () => storage.get("localFiles") || [], ); // Casting - const cast = useCast(); + const cast = useCast({ autoDiscover: true }); const [castDl, setCastDl] = useState(null); const castLoadArgs = useMemo(() => { if (!castDl || !castDl.filePath) return null; diff --git a/src/pages/MoviePage.jsx b/src/pages/MoviePage.jsx index 35743f8..5fd80a7 100644 --- a/src/pages/MoviePage.jsx +++ b/src/pages/MoviePage.jsx @@ -115,7 +115,7 @@ export default function MoviePage({ const pipUrlRef = useRef(null); // URL to restore when pop-out closes const pipWebContentsIdRef = useRef(null); // cached WebContents ID of the pop-out window // Casting - const cast = useCast(); + const cast = useCast({ autoDiscover: true }); const [showCastPicker, setShowCastPicker] = useState(false); // AllManga sets this from res.isDirectMp4 so cast knows mp4 vs hls const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 519fb31..a24a75e 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -2025,8 +2025,6 @@ function CastSettingsSection() { return () => { mounted = false; if (h) window.electron.offCastDevicesUpdated?.(h); - // Stop scanning when leaving Settings so SSDP/mDNS don't run in background. - window.electron.castStopDiscovery?.(); }; }, []); diff --git a/src/pages/TVPage.jsx b/src/pages/TVPage.jsx index f9ff303..faafc49 100644 --- a/src/pages/TVPage.jsx +++ b/src/pages/TVPage.jsx @@ -385,7 +385,7 @@ export default function TVPage({ const [m3u8Url, setM3u8Url] = useState(null); const [interceptedSubs, setInterceptedSubs] = useState([]); // Casting - const cast = useCast(); + const cast = useCast({ autoDiscover: true }); const [showCastPicker, setShowCastPicker] = useState(false); const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); const [playerSource, setPlayerSource] = useState( diff --git a/src/utils/useCast.js b/src/utils/useCast.js index a733e0d..377944a 100644 --- a/src/utils/useCast.js +++ b/src/utils/useCast.js @@ -42,6 +42,24 @@ export function useCast({ autoDiscover = false } = {}) { const mountedRef = useRef(true); const devicesRef = useRef([]); devicesRef.current = devices; + const discoverTimerRef = useRef(null); + + // Discovery is fire-and-forget in the main process: cast:start-discovery + // resolves immediately while mDNS/SSDP responses trickle in over the next few + // seconds. Keep `isDiscovering` true for that window so the UI shows + // "Scanning…" instead of flashing "No devices found". + const DISCOVER_WINDOW_MS = 5000; + const runDiscovery = () => { + if (!window.electron?.castStartDiscovery) return; + setIsDiscovering(true); + if (discoverTimerRef.current) clearTimeout(discoverTimerRef.current); + Promise.resolve(window.electron.castStartDiscovery(discoveryOpts())).catch( + () => {}, + ); + discoverTimerRef.current = setTimeout(() => { + if (mountedRef.current) setIsDiscovering(false); + }, DISCOVER_WINDOW_MS); + }; // Subscribe to push events from main useEffect(() => { @@ -72,15 +90,11 @@ export function useCast({ autoDiscover = false } = {}) { setLastError(e?.message || "Cast error"); }); - if (autoDiscover) { - setIsDiscovering(true); - window.electron.castStartDiscovery?.(discoveryOpts()).finally(() => { - if (mountedRef.current) setIsDiscovering(false); - }); - } + if (autoDiscover) runDiscovery(); return () => { mountedRef.current = false; + if (discoverTimerRef.current) clearTimeout(discoverTimerRef.current); if (devH) window.electron.offCastDevicesUpdated?.(devH); if (statusH) window.electron.offCastStatus?.(statusH); if (endedH) window.electron.offCastSessionEnded?.(endedH); @@ -104,13 +118,8 @@ export function useCast({ autoDiscover = false } = {}) { const currentDevice = connectedDevice; - const startDiscovery = useCallback(async () => { - setIsDiscovering(true); - try { - await window.electron?.castStartDiscovery?.(discoveryOpts()); - } finally { - setIsDiscovering(false); - } + const startDiscovery = useCallback(() => { + runDiscovery(); }, []); const stopDiscovery = useCallback(async () => { From 061d2055b06cff2b6ff9997922a49d944e4ee5d2 Mon Sep 17 00:00:00 2001 From: Daniel Bakhsheshi Date: Sat, 23 May 2026 21:26:22 +1000 Subject: [PATCH 4/4] Keep display awake during in-app playback; stop background pausing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app player paused when the laptop display slept or the window was occluded because the main window used backgroundThrottling: true and nothing held a power-save blocker (pre-existing, unrelated to casting). - Hold powerSaveBlocker("prevent-display-sleep") while in-app playback is active; release on pause/stop, on window close, and on app quit. - Release the blocker while casting — playback then lives on the receiver and the laptop is free to sleep. - Set backgroundThrottling: false on the main and pop-out windows so occlusion / alt-tab no longer throttles or pauses media. Wired via a new playback-keepawake IPC + setPlaybackKeepAwake preload bridge, driven from MoviePage/TVPage on `playing && !cast.currentDevice`. Co-Authored-By: Claude Opus 4.7 --- index.js | 33 ++++++++++++++++++++++++++++++++- preload.js | 4 ++++ src/pages/MoviePage.jsx | 8 ++++++++ src/pages/TVPage.jsx | 8 ++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/index.js b/index.js index 7c173c3..d9ef2ff 100644 --- a/index.js +++ b/index.js @@ -9,9 +9,28 @@ const { session, webContents, Notification, + powerSaveBlocker, } = require("electron"); const path = require("path"); +// -- Playback keep-awake ------------------------------------------------------- +// Hold a display-sleep blocker while in-app playback is active so the player +// doesn't pause when the laptop screen sleeps. Released when paused/stopped or +// when casting (playback then lives on the receiver, laptop may sleep). +let _psbId = null; +function setPlaybackKeepAwake(active) { + try { + if (active) { + if (_psbId == null || !powerSaveBlocker.isStarted(_psbId)) { + _psbId = powerSaveBlocker.start("prevent-display-sleep"); + } + } else if (_psbId != null) { + if (powerSaveBlocker.isStarted(_psbId)) powerSaveBlocker.stop(_psbId); + _psbId = null; + } + } catch {} +} + // -- RAM / performance flags --------------------------------------------------- app.commandLine.appendSwitch( "js-flags", @@ -221,7 +240,9 @@ function createWindow() { contextIsolation: true, nodeIntegration: false, webviewTag: true, - backgroundThrottling: true, + // Keep media alive when the window is occluded/backgrounded; pausing on + // background is handled deliberately, not by Chromium throttling. + backgroundThrottling: false, spellcheck: false, // Caps the renderer's V8 heap + exposes gc() for manual GC hints after navigation additionalArguments: ["--js-flags=--max-old-space-size=256 --expose-gc"], @@ -303,6 +324,7 @@ function createWindow() { }); mainWindow.on("closed", () => { + setPlaybackKeepAwake(false); mainWindow = null; app.quit(); }); @@ -322,6 +344,13 @@ playerIpc.register(getMainWindow, { blockStats.init(getMainWindow); castIpc.register(getMainWindow); +// Renderer toggles the display-sleep blocker based on its play state. +ipcMain.handle("playback-keepawake", (_event, active) => { + setPlaybackKeepAwake(!!active); +}); + +app.on("before-quit", () => setPlaybackKeepAwake(false)); + // get-block-stats lives with its data ipcMain.handle("get-block-stats", () => blockStats.getBlockStats()); @@ -512,6 +541,8 @@ ipcMain.handle("open-pip-window", (_, { url, title }) => { partition: "persist:player", nodeIntegration: false, contextIsolation: true, + // Keep the pop-out playing when it's behind other windows. + backgroundThrottling: false, // Injects the custom title bar and wires window-control IPC preload: path.join(__dirname, "popout-preload.js"), }, diff --git a/preload.js b/preload.js index 17898f9..a9e7c1d 100644 --- a/preload.js +++ b/preload.js @@ -99,6 +99,10 @@ contextBridge.exposeInMainWorld("electron", { // Quit app quitApp: () => ipcRenderer.invoke("quit-app"), + // Keep the display awake while in-app playback is active + setPlaybackKeepAwake: (active) => + ipcRenderer.invoke("playback-keepawake", active), + // Signal to main process that the player has stopped playerStopped: () => ipcRenderer.send("player-stopped"), diff --git a/src/pages/MoviePage.jsx b/src/pages/MoviePage.jsx index 5fd80a7..1c372fc 100644 --- a/src/pages/MoviePage.jsx +++ b/src/pages/MoviePage.jsx @@ -120,6 +120,14 @@ export default function MoviePage({ // AllManga sets this from res.isDirectMp4 so cast knows mp4 vs hls const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); + // Keep the display awake while playing in-app; release while casting (the + // receiver plays, the laptop may sleep) or when the player closes. + useEffect(() => { + const active = playing && !cast.currentDevice; + window.electron?.setPlaybackKeepAwake?.(active); + return () => window.electron?.setPlaybackKeepAwake?.(false); + }, [playing, cast.currentDevice]); + // Derived: detect anime before any effects so effects can use it const isAnime = useMemo( () => isAnimeContent(item, details), diff --git a/src/pages/TVPage.jsx b/src/pages/TVPage.jsx index faafc49..6b0d165 100644 --- a/src/pages/TVPage.jsx +++ b/src/pages/TVPage.jsx @@ -388,6 +388,14 @@ export default function TVPage({ const cast = useCast({ autoDiscover: true }); const [showCastPicker, setShowCastPicker] = useState(false); const [allmangaIsDirectMp4, setAllmangaIsDirectMp4] = useState(null); + + // Keep the display awake while playing in-app; release while casting (the + // receiver plays, the laptop may sleep) or when the player closes. + useEffect(() => { + const active = playing && !cast.currentDevice; + window.electron?.setPlaybackKeepAwake?.(active); + return () => window.electron?.setPlaybackKeepAwake?.(false); + }, [playing, cast.currentDevice]); const [playerSource, setPlayerSource] = useState( () => storage.get("playerSource") || NON_ANIME_DEFAULT_SOURCE, );