diff --git a/README.md b/README.md index 817ee1e..62bd7ce 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,37 @@ LAYA_BASE_URL=http://localhost:8000 npm start Repository maintainers can build and publish both the nodecast-tv and Laya images with `./scripts/docker-build-push.sh`; this script is not needed by Compose users. `REGISTRY`, `OWNER`, `IMAGE_NAME`, `LAYA_IMAGE_NAME`, and `TAG` can override the publication targets. Use `--no-push` to build both images locally without publishing them. +#### Server-side Google Cast + +Nodecast can discover and control Chromecast and compatible Android TV devices from the server. This makes casting available from any browser: the browser asks Nodecast to start playback, while the Nodecast server performs LAN discovery and communicates with the receiver. + +Configure both of these variables: + +- `CAST_PUBLIC_BASE_URL`: the absolute Nodecast URL that the receiver can reach on the LAN. It must not use `localhost`, `127.0.0.1`, or `::1`. +- `CAST_SIGNING_SECRET`: a dedicated secret of at least 32 bytes, used for temporary cookie-free media URLs. Do not reuse a public value or commit the secret. + +For example, add the following entries to the `nodecast-tv` service in `docker-compose.yml`, replacing the address and secret: + +```yaml +services: + nodecast-tv: + environment: + - CAST_PUBLIC_BASE_URL=http://192.168.1.50:3000 + - CAST_SIGNING_SECRET=${CAST_SIGNING_SECRET} +``` + +Then define the secret in the deployment `.env` file. A suitable value can be generated with `openssl rand -hex 32`: + +```env +CAST_SIGNING_SECRET=replace-with-a-random-secret-of-at-least-32-bytes +``` + +Restart Nodecast, open a Live or Watch player in any browser, select the Cast control, and refresh the device list if necessary. Nodecast discovers Google Cast receivers over mDNS from its own network environment; the browser itself does not need native Chromecast support. + +Docker bridge networks may not pass multicast DNS traffic from the LAN. Depending on the host platform, discovery may require host networking or an explicitly configured Docker/LAN network. The receiver must also be able to open `CAST_PUBLIC_BASE_URL` and its media URLs. + +Fire TV / Fire Stick is not a native Google Cast target. Matter Casting or Fire TV playback requires a compatible receiver application that is outside the scope of this integration; unsupported devices are shown as **Receiver required** rather than being presented as castable. + ### Hardware Acceleration Setup @@ -279,6 +310,20 @@ docker compose --profile laya pull laya docker compose --profile laya up -d --force-recreate laya ``` +### Server-side Cast + +If no Chromecast or Android TV appears: + +- Confirm that Nodecast and the receiver are on reachable LAN segments and that multicast DNS (`_googlecast._tcp`) is not blocked by Wi-Fi isolation, a firewall, VLAN rules, or Docker networking. +- When Nodecast runs in Docker, test a host-network or LAN-compatible container configuration if the default bridge cannot see mDNS advertisements. +- Refresh the device list from the Cast dialog and inspect `docker compose logs nodecast-tv` for discovery errors. + +If a device is found but cannot start playback, open `CAST_PUBLIC_BASE_URL` from another device on the same network. The URL must contain the host's reachable LAN address or DNS name and the published port; a container name or `localhost` will not work from the receiver. Also check firewall access to that address. + +If Nodecast reports that Cast signing is unavailable, set a dedicated `CAST_SIGNING_SECRET` containing at least 32 bytes and recreate the container. Nodecast deliberately refuses to issue predictable unsigned or weakly signed media URLs. + +Fire TV, Fire Stick, and Matter Casting do not automatically provide Google Cast compatibility. Without a compatible receiver application, Nodecast reports **Receiver required**; use a Chromecast or Google Cast-enabled Android TV for this server-cast integration. + ### Video Won't Play (Black Screen or Loading Forever) | Symptom | Likely Cause | Solution | diff --git a/package-lock.json b/package-lock.json index 7595c62..a0f16fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@ffprobe-installer/ffprobe": "^2.1.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.5.0", + "bonjour-service": "^1.4.4", + "chromecast-client": "^1.0.1", "dotenv": "^17.2.3", "express": "^4.18.2", "express-session": "^1.18.2", @@ -178,6 +180,69 @@ "win32" ] }, + "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/@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.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "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/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.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -354,6 +419,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/bonjour-service": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.4.tgz", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -436,12 +511,82 @@ "license": "Apache-2.0", "optional": true }, + "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/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/castv2/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, + "node_modules/chromecast-client": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/chromecast-client/-/chromecast-client-1.0.1.tgz", + "integrity": "sha512-79QTIu6g1oYHvI9i8sPc8kpwrZGb6hXiNnEJmoiFLfjTYTJO+kKc8ML1uqNNKZbKMMFsgR/89da0A3ezD5p92A==", + "license": "MIT", + "dependencies": { + "castv2": "^0.1.10", + "debug": "^4.3.4", + "zod": "^3.20.2" + }, + "funding": { + "url": "https://www.patreon.com/dantaylor" + } + }, + "node_modules/chromecast-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/chromecast-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -613,6 +758,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/domexception": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", @@ -844,6 +1001,12 @@ "node": ">= 0.8.0" } }, + "node_modules/fast-deep-equal": { + "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==", + "license": "MIT" + }, "node_modules/ffmpeg-static": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", @@ -1346,6 +1509,12 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1448,6 +1617,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -1669,6 +1851,38 @@ "node": ">=0.4.0" } }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2100,6 +2314,12 @@ "node": ">=6" } }, + "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2182,6 +2402,12 @@ "node": ">= 0.8" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, "node_modules/universalify": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", @@ -2376,6 +2602,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index b5bd8f5..36dd610 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@ffprobe-installer/ffprobe": "^2.1.2", "bcryptjs": "^3.0.3", "better-sqlite3": "^12.5.0", + "bonjour-service": "^1.4.4", + "chromecast-client": "^1.0.1", "dotenv": "^17.2.3", "express": "^4.18.2", "express-session": "^1.18.2", @@ -27,6 +29,11 @@ "optionalDependencies": { "ffmpeg-static": "^5.3.0" }, + "overrides": { + "castv2": { + "protobufjs": "^7.6.3" + } + }, "engines": { "node": ">=18.0.0" }, diff --git a/public/css/main.css b/public/css/main.css index ae672d0..46070f9 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -4610,6 +4610,88 @@ select.form-input optgroup:disabled { z-index: 100; } +.cast-device-dialog { + width: min(520px, calc(100vw - 32px)); + max-height: min(80vh, 680px); + padding: var(--space-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + box-shadow: var(--shadow-lg); +} + +.cast-device-dialog::backdrop { + background: rgba(0, 0, 0, 0.72); +} + +.cast-dialog-header, +.cast-dialog-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-sm); +} + +.cast-device-dialog .form-input { + width: 100%; + margin: var(--space-md) 0; +} + +.cast-device-message { + min-height: 1.25rem; + margin-bottom: var(--space-sm); + color: var(--color-text-muted); + font-size: 0.8125rem; +} + +.cast-device-message[data-state="error"] { + color: var(--color-error); +} + +.cast-device-list { + display: grid; + gap: var(--space-sm); + max-height: 360px; + margin-bottom: var(--space-md); + overflow-y: auto; +} + +.cast-device-item { + display: grid; + gap: 3px; + min-height: 56px; + padding: var(--space-sm) var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-tertiary); + color: var(--color-text-primary); + text-align: left; + cursor: pointer; +} + +.cast-device-item:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.cast-device-name { + font-weight: 600; +} + +.cast-device-status, +.cast-device-explanation { + color: var(--color-text-muted); + font-size: 0.75rem; +} + +@media (max-width: 520px) { + .cast-dialog-actions { + align-items: stretch; + flex-direction: column; + } +} + .overflow-menu-item { display: flex; align-items: center; diff --git a/public/index.html b/public/index.html index 5652652..493807b 100644 --- a/public/index.html +++ b/public/index.html @@ -1218,11 +1218,27 @@ + + + Play on a device + × + + Choose a device connected to the same network as Nodecast. + + + + + Refresh devices + Use browser device picker + + + - + - + + @@ -1238,7 +1254,7 @@ - +
Choose a device connected to the same network as Nodecast.