diff --git a/.cargo/config.toml b/.cargo/config.toml index be37477a..afb5015f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,7 @@ # TODO: static link glibc [alias] +xtask = "run --quiet -p xtask --" new-migration = "run --quiet -p xtask -- new-migration" msi-version = "run --quiet -p xtask -- msi-version" diff --git a/.clang-format b/.clang-format new file mode 100644 index 00000000..782d1575 --- /dev/null +++ b/.clang-format @@ -0,0 +1,116 @@ +--- +# This file is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Generated from CLion C/C++ Code Style settings +BasedOnStyle: LLVM +AccessModifierOffset: -2 +AlignAfterOpenBracket: BlockIndent +AlignConsecutiveAssignments: None +AlignEscapedNewlines: DontAlign +AlignOperands: Align +AllowAllArgumentsOnNextLine: false +AllowAllConstructorInitializersOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: None +AllowShortLoopsOnASingleLine: true +AlignTrailingComments: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: MultiLine +BinPackArguments: false +BinPackParameters: false +BracedInitializerIndentWidth: 2 +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: true + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterUnion: false + BeforeCatch: true + BeforeElse: true + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: true +BreakArrays: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeTernaryOperators: false +BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterColon +ColumnLimit: 0 +CompactNamespaces: false +ContinuationIndentWidth: 2 +Cpp11BracedListStyle: true +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: Always +ExperimentalAutoDetectBinPacking: true +FixNamespaceComments: true +IncludeBlocks: Regroup +IndentAccessModifiers: false +IndentCaseBlocks: true +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: true +IndentPPDirectives: BeforeHash +IndentWidth: 2 +IndentWrappedFunctionNames: true +InsertBraces: true +InsertNewlineAtEOF: true +KeepEmptyLinesAtTheStartOfBlocks: false +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: All +ObjCBinPackProtocolList: Never +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PackConstructorInitializers: Never +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 1 +PenaltyBreakString: 1 +PenaltyBreakFirstLessLess: 0 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 100000000 +PointerAlignment: Right +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveSemicolon: false +SeparateDefinitionBlocks: Always +SortIncludes: CaseInsensitive +SortUsingDeclarations: Lexicographic +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: true +SpaceBeforeCtorInitializerColon: false +SpaceBeforeInheritanceColon: false +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Never +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInLineCommentPrefix: + Maximum: 3 + Minimum: 1 +SpacesInParentheses: false +SpacesInSquareBrackets: false +TabWidth: 2 +UseTab: Never diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..a8948ef1 --- /dev/null +++ b/.flake8 @@ -0,0 +1,7 @@ +[flake8] +filename = + *.py +max-line-length = 120 +extend-exclude = + .venv/ + venv/ diff --git a/.github/workflows/ci-rust.yml b/.github/workflows/ci-rust.yml index 7b3c8ec8..2fa81904 100644 --- a/.github/workflows/ci-rust.yml +++ b/.github/workflows/ci-rust.yml @@ -107,7 +107,12 @@ jobs: env: CARGO_TERM_COLOR: always INPUT_RELEASE_VERSION: ${{ inputs.release_version }} - TEST_TOOLCHAIN: nightly # libtest's JSON output requires nightly. + # Temporary pin pending https://github.com/rust-lang/rust/pull/165266; tracked in PR #351's checklist. + TEST_TOOLCHAIN: nightly-2026-10-31 # libtest JSON needs nightly; November 1 has a compiler regression. + # musl must support dlopen for Koko's bundled native TV library. + RUSTFLAGS: >- + ${{ contains(matrix.target, 'musl') && + '-C target-feature=-crt-static -C link-arg=-Wl,-rpath,$ORIGIN/live-tv' || '' }} steps: - name: Fix arm64 Alpine container if: runner.arch == 'ARM64' && contains(matrix.container, 'alpine') @@ -121,11 +126,15 @@ jobs: apk add --no-cache \ bash \ build-base \ + cmake \ curl \ git \ jq \ + linux-headers \ nodejs \ npm \ + numactl \ + python3 \ openssl-dev \ openssl-libs-static \ p7zip \ @@ -137,15 +146,15 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive - name: Trust Alpine checkout if: contains(matrix.container, 'alpine') run: | git config --global --add safe.directory "${GITHUB_WORKSPACE}" git config --global --add safe.directory "${GITHUB_WORKSPACE}/third-party/build-deps" - - - name: Initialize build-deps - run: git submodule update --init --depth=1 third-party/build-deps + git config --global --add safe.directory "${GITHUB_WORKSPACE}/third-party/tsduck" - name: Install Ubuntu dependencies if: matrix.container == '' && contains(matrix.os, 'ubuntu') @@ -162,6 +171,10 @@ jobs: p7zip-full \ pkg-config + - name: Install macOS native build tools + if: contains(matrix.os, 'macos') + run: brew install bash coreutils gnu-sed grep make + - name: Setup Rust if: matrix.container == '' uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 @@ -216,6 +229,8 @@ jobs: - name: Test id: test if: matrix.run == 'test' + env: + GH_TOKEN: ${{ github.token }} run: | tarpaulin_args=() case "${{ matrix.target }}" in @@ -267,12 +282,24 @@ jobs: - name: Build if: matrix.run == 'build' env: + GH_TOKEN: ${{ github.token }} KOKO_FANART_PROJECT_API_KEY: ${{ secrets.FANART_PROJECT_API_KEY }} KOKO_LRCLIB_APPLICATION_ID: ${{ secrets.LRCLIB_APPLICATION_ID }} run: | cargo build --locked ${{ matrix.cargo_features }} --target ${{ matrix.target }} --release \ --message-format=json | tee "${RUNNER_TEMP}/cargo-build.json" + - name: Bundle native TV library + if: matrix.run == 'build' + run: | + out_dir="$(jq -ber ' + select(.reason == "build-script-executed" and (.package_id | contains("#koko@"))) | .out_dir + ' "${RUNNER_TEMP}/cargo-build.json")" + package_dir="$(cat "${out_dir}/native-tv-root.txt")" + destination="target/${{ matrix.target }}/release/live-tv" + mkdir -p "${destination}" + cp -R "${package_dir}/." "${destination}/" + - name: Bundle shared FFmpeg tools if: matrix.run == 'build' env: @@ -304,7 +331,8 @@ jobs: 7z a "./artifacts/koko-${{ matrix.target }}.7z" \ "./assets" \ "./target/${{ matrix.target }}/release/koko${extension}" \ - "./target/${{ matrix.target }}/release/ffmpeg" + "./target/${{ matrix.target }}/release/ffmpeg" \ + "./target/${{ matrix.target }}/release/live-tv" - name: Prepare build artifact if: matrix.run == 'build' && matrix.build_artifact_path == 'build-artifact' @@ -323,6 +351,8 @@ jobs: "build-artifact/target/${{ matrix.target }}/release/koko${extension}" cp -R "crates/client-web/dist" "build-artifact/crates/client-web/dist" cp -R "target/${{ matrix.target }}/release/ffmpeg" "build-artifact/target/${{ matrix.target }}/release/ffmpeg" + cp -R "target/${{ matrix.target }}/release/live-tv" \ + "build-artifact/target/${{ matrix.target }}/release/live-tv" - name: Upload build artifact if: matrix.run == 'build' diff --git a/.github/workflows/ci-windows-installer.yml b/.github/workflows/ci-windows-installer.yml index 7eac8284..e9a0d064 100644 --- a/.github/workflows/ci-windows-installer.yml +++ b/.github/workflows/ci-windows-installer.yml @@ -128,7 +128,8 @@ jobs: 7z a "artifacts\koko-${{ matrix.target }}.7z" ` ".\assets" ` ".\target\${{ matrix.target }}\release\koko.exe" ` - ".\target\${{ matrix.target }}\release\ffmpeg" + ".\target\${{ matrix.target }}\release\ffmpeg" ` + ".\target\${{ matrix.target }}\release\live-tv" - name: Resolve MSI package version shell: pwsh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3958fdfa..44328109 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,14 @@ jobs: - name: Check licenses run: cargo bin cargo-deny check licenses + - name: Test media tool package compatibility + run: | + rustc --test --edition=2021 -D warnings packaging/ffmpeg_stack.rs -o "${RUNNER_TEMP}/ffmpeg-stack-tests" + "${RUNNER_TEMP}/ffmpeg-stack-tests" + + - name: Test native packaging metadata and downloads + run: cargo test -p xtask --locked + clippy: name: Clippy permissions: @@ -78,7 +86,8 @@ jobs: permissions: contents: read runs-on: ubuntu-latest - timeout-minutes: 30 + # Cold native SDK builds and the playback suite can exceed thirty minutes together. + timeout-minutes: 45 strategy: fail-fast: false matrix: @@ -88,9 +97,12 @@ jobs: env: KOKO_SKIP_FFMPEG_DOWNLOAD: '1' KOKO_E2E_BROWSER: ${{ matrix.browser }} + KOKO_E2E_SERVER: ${{ github.workspace }}/target/debug/koko steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive - name: Setup Rust uses: actions-rust-lang/setup-rust-toolchain@ecabd13d1c56bd1345c230e542e9144811ad706f # v2.0.0 @@ -119,6 +131,11 @@ jobs: node node_modules/playwright/cli.js install --with-deps chrome fi + - name: Build browser test server + env: + KOKO_SKIP_FFMPEG_DOWNLOAD: '1' + run: cargo build -p koko --locked --no-default-features + - name: Test real browser playback working-directory: crates/client-web run: npm run test:e2e diff --git a/.gitmodules b/.gitmodules index b15014f3..ee0c0cfa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,7 @@ path = third-party/dockle url = https://github.com/LizardByte/dockle.git branch = master +[submodule "third-party/tsduck"] + path = third-party/tsduck + url = https://github.com/tsduck/tsduck.git + branch = master diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 0553e524..235af6ec 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -27,6 +27,8 @@ build: fi chmod +x "${dockle_dir}/readthedocs_build.sh" export DOCKLE_DIR="${dockle_dir}" + # Rustdoc generates the TV APIs without building or loading the native tuner runtime. + export KOKO_SKIP_TSDUCK_BUILD=1 "${dockle_dir}/readthedocs_build.sh" python: diff --git a/Cargo.lock b/Cargo.lock index 53fe82b4..d538956b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2379,6 +2379,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2949,6 +2960,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.5.0", + "indexmap 2.14.2", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -3151,7 +3181,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", @@ -3173,7 +3203,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", + "futures-channel", "futures-core", + "h2 0.4.20", "http 1.5.0", "http-body 1.1.0", "httparse", @@ -3458,6 +3490,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "image" version = "0.25.10" @@ -3869,20 +3911,26 @@ dependencies = [ "cargo_metadata", "chrono", "config", + "crc32fast", "diesel", "diesel_migrations", "dirs", "fern", + "flate2", "image", "imohash", "jsonwebtoken", "keyring", "keyring-core", + "libloading 0.8.9", "libsqlite3-sys", "log", + "md5", + "mdns-sd", "musicbrainz_rs", "objc2-core-foundation", "once_cell", + "quick-xml", "rand 0.10.3", "rcgen", "regex", @@ -3899,12 +3947,14 @@ dependencies = [ "sha2 0.11.1", "strsim 0.11.1", "tao", + "tempfile", "tmdb_client", "tokio", "tray-icon", "tvdb4", "unicode-normalization", "webbrowser", + "xtask", ] [[package]] @@ -4197,6 +4247,27 @@ dependencies = [ "rayon", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "mdns-sd" +version = "0.21.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8562654710f72a9cbce4b0d5b7c366a2f94330ba1ce3740f15e932ea2d37b161" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2 0.6.5", +] + [[package]] name = "memchr" version = "2.8.3" @@ -4305,6 +4376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -5449,6 +5521,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.12" @@ -5837,7 +5918,7 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "hyper 0.14.32", @@ -5914,6 +5995,7 @@ dependencies = [ "base64 0.23.1", "bytes", "futures-core", + "h2 0.4.20", "http 1.5.0", "http-body 1.1.0", "http-body-util", @@ -6906,6 +6988,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2 0.6.5", + "windows-sys 0.61.2", +] + [[package]] name = "socket2" version = "0.5.10" @@ -6937,6 +7030,9 @@ name = "spin" version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -8809,6 +8905,11 @@ dependencies = [ [[package]] name = "xtask" version = "0.0.0" +dependencies = [ + "serde_json", + "sha2 0.11.1", + "tempfile", +] [[package]] name = "y4m" diff --git a/assets/providers/README.md b/assets/providers/README.md index c18b7f7d..cbf187a9 100644 --- a/assets/providers/README.md +++ b/assets/providers/README.md @@ -4,23 +4,55 @@ The backend embeds these files and serves them at `/assets/providers/` Provider descriptors supply those URLs to clients; no logo is downloaded at runtime. Vite serves the same files for development and mock previews. -| Asset | Source | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `audiosilo.svg` | Unmodified [AudioSilo Meta site logo](https://github.com/kodestar/audiosilo-meta/blob/main/site/public/logo.svg) | -| `comicvine.svg` | Inline Comic Vine logo extracted from the [official API page](https://comicvine.gamespot.com/api/), with the SVG namespace added for standalone use | -| `fanart.png` | Unmodified [Fanart.tv wiki logo](https://github.com/fanart-tv/fanartwiki/blob/master/docs/assets/images/fatv-logo.png) | -| `libex-dark.png` | Unmodified [Libex dark-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo-dark.png) | -| `libex-light.png` | Unmodified [Libex light-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo.png) | -| `librivox.png` | Unmodified [LibriVox site logo](https://librivox.org/wp-content/themes/librivox/images/librivox-logo.png) | -| `local-nfo.svg` | Original document icon for Koko's local sidecar metadata provider | -| `lrclib.png` | Unmodified [LRCLIB site logo](https://lrclib.net/assets/lrclib-370c57eb.png) | -| `musicbrainz.svg` | MusicBrainz symbol extracted without the wordmark from the [official header logo](https://static.metabrainz.org/MB/header-logo-1f7dc2a.svg); [official logo sources](https://musicbrainz.org/doc/About/Logos) | -| `open-library.png` | Unmodified [Open Library site icon](https://openlibrary.org/static/images/openlibrary-192x192.png) | -| `themerr.png` | Unmodified [Themerr icon](https://github.com/LizardByte/Themerr/blob/master/web/images/icon-default.png) | -| `tmdb.svg` | Unmodified [TMDB Primary short (blue) SVG](https://www.themoviedb.org/assets/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg); [attribution guidelines](https://www.themoviedb.org/about/logos-attribution) | -| `trailerdb-dark.svg` | TrailerDB favicon with its stroke changed to `#e6edf6` for dark backgrounds | -| `trailerdb-light.svg` | Unmodified [TrailerDB favicon](https://trailerdb.org/favicon.svg) | -| `tvdb-dark.png` | Unmodified [TheTVDB dark-background attribution logo](https://thetvdb.com/images/attribution/logo1.png) | -| `tvdb-light.png` | Unmodified [TheTVDB light-background attribution logo](https://thetvdb.com/images/attribution/logo2.png) | +| Asset | Source | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `audiosilo.svg` | Unmodified [AudioSilo Meta site logo](https://github.com/kodestar/audiosilo-meta/blob/main/site/public/logo.svg) | +| `comicvine.svg` | Inline Comic Vine logo extracted from the [official API page](https://comicvine.gamespot.com/api/), with the SVG namespace added for standalone use | +| `fanart.png` | Unmodified [Fanart.tv wiki logo](https://github.com/fanart-tv/fanartwiki/blob/master/docs/assets/images/fatv-logo.png) | +| `fastchannels.svg` | Unmodified [FastChannels application icon](https://github.com/kineticman/FastChannels/blob/main/app/static/favicon.svg) | +| `hdhomerun.ico` | Unmodified [HDHomeRun site icon](https://info.hdhomerun.com/info/_media/favicon.ico) | +| `libex-dark.png` | Unmodified [Libex dark-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo-dark.png) | +| `libex-light.png` | Unmodified [Libex light-background logo](https://github.com/LibexHQ/Libex/blob/main/app/static/logo.png) | +| `librivox.png` | Unmodified [LibriVox site logo](https://librivox.org/wp-content/themes/librivox/images/librivox-logo.png) | +| `local-nfo.svg` | Original document icon for Koko's local sidecar metadata provider | +| `lrclib.png` | Unmodified [LRCLIB site logo](https://lrclib.net/assets/lrclib-370c57eb.png) | +| `musicbrainz.svg` | MusicBrainz symbol extracted without the wordmark from the [official header logo](https://static.metabrainz.org/MB/header-logo-1f7dc2a.svg); [official logo sources](https://musicbrainz.org/doc/About/Logos) | +| `nextpvr.ico` | Unmodified [NextPVR site icon](https://nextpvr.com/favicon.ico) | +| `open-library.png` | Unmodified [Open Library site icon](https://openlibrary.org/static/images/openlibrary-192x192.png) | +| `themerr.png` | Unmodified [Themerr icon](https://github.com/LizardByte/Themerr/blob/master/web/images/icon-default.png) | +| `tmdb.svg` | Unmodified [TMDB Primary short (blue) SVG](https://www.themoviedb.org/assets/v4/logos/v2/blue_square_1-5bdc75aaebeb75dc7ae79426ddd9be3b2be1e342510f8202baf6bffa71d7f5c4.svg); [attribution guidelines](https://www.themoviedb.org/about/logos-attribution) | +| `trailerdb-dark.svg` | TrailerDB favicon with its stroke changed to `#e6edf6` for dark backgrounds | +| `trailerdb-light.svg` | Unmodified [TrailerDB favicon](https://trailerdb.org/favicon.svg) | +| `tv-adultswim.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/adult-swim-us.png) | +| `tv-amazon.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/amazon-prime.svg) | +| `tv-bally.svg` | [Bally Sports Live](https://www.ballysports.com/_next/static/media/bally_logo_red.8f7d15c0.svg) | +| `tv-cbs.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/cbs-sports-us.png) | +| `tv-espn.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/espn-us.png) | +| `tv-foxone.ico` | [FOX One](https://www.foxone.com/favicon.ico) | +| `tv-freelivesports.png` | [Free Live Sports](https://gizmeon.mdc.akamaized.net/PUB-50183/config/1784278232296.png) | +| `tv-fubo.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/fubo-tv.png) | +| `tv-hallmark.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/hallmark-channel-us.png) | +| `tv-hulu.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/hulu.png) | +| `tv-lg.svg` | [LG](https://media.us.lg.com/m/4f3e261da34f4910/original/lg_logo.svg) | +| `tv-localnow.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/local-now-us.png) | +| `tv-nfl.png` | [NFL](https://static.www.nfl.com/league/run3dfyjniqxah4ehxfu) | +| `tv-plex.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/plex-alt-light.svg) | +| `tv-pluto.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/pluto-tv.png) | +| `tv-reelz.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/countries/united-states/reelz-us.png) | +| `tv-roku.svg` | [Simple Icons (white wordmark)](https://github.com/simple-icons/simple-icons/blob/develop/icons/roku.svg); white fill for dark backgrounds | +| `tv-samsung.svg` | [Dashboard Icons](https://github.com/homarr-labs/dashboard-icons/blob/main/svg/samsung-tv-plus.svg) | +| `tv-sling.svg` | [Sling](https://www.sling.com/content/dam/sling-television/icons/logo-sling.svg) | +| `tv-spectrum.png` | [Spectrum TV](https://watch.spectrum.net/assets/18.0.2/images/apple-touch-icon.png?v=2598111e) | +| `tv-stirr.png` | [STIRR](https://vcz-ktest2-cloud-vodlix-com.b-cdn.net/u/ktest2/images/branding/custom/app_icon.png?lu=1792528577) | +| `tv-tcl.png` | [TCLtv+ official Google Play app icon](https://play.google.com/store/apps/details?id=com.tcl.tv.plus) | +| `tv-tubi.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/tubi.png) | +| `tv-vizio.png` | [Vizio WatchFree+](https://www.vizio.com/content/dam/vizio/2025/watchfreeplus/watchfree-logo.png) | +| `tv-whale.svg` | [Whale TV+](https://cdn.prod.website-files.com/6666d37a275951ae96644f4b/66c7b2a5415c4dd4e1ca6379_whaletv%2B_logo_whitepink_RGB.svg) | +| `tv-wsn.png` | [Women's Sports Network](https://womenssports.com/wp-content/uploads/2026/05/WSNLogoFuego.png) | +| `tv-xumo.svg` | [Xumo Play](https://www.xumo.com/static/images/refresh/xumo-play-logo.svg); white wordmark for dark backgrounds | +| `tv-youtube.png` | [TV Logos](https://github.com/tv-logo/tv-logos/blob/main/misc/media/youtube-tv.png) | +| `tvdb-dark.png` | Unmodified [TheTVDB dark-background attribution logo](https://thetvdb.com/images/attribution/logo1.png) | +| `tvdb-light.png` | Unmodified [TheTVDB light-background attribution logo](https://thetvdb.com/images/attribution/logo2.png) | +| `tvheadend.png` | Unmodified [Tvheadend application icon](https://github.com/tvheadend/tvheadend/blob/master/src/webui/static/img/logo.png) | Brand names and logos belong to their respective owners. diff --git a/assets/providers/fastchannels.svg b/assets/providers/fastchannels.svg new file mode 100644 index 00000000..74d69c32 --- /dev/null +++ b/assets/providers/fastchannels.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/providers/hdhomerun.ico b/assets/providers/hdhomerun.ico new file mode 100644 index 00000000..8e7b93eb Binary files /dev/null and b/assets/providers/hdhomerun.ico differ diff --git a/assets/providers/nextpvr.ico b/assets/providers/nextpvr.ico new file mode 100644 index 00000000..84f96f8e Binary files /dev/null and b/assets/providers/nextpvr.ico differ diff --git a/assets/providers/tv-adultswim.png b/assets/providers/tv-adultswim.png new file mode 100644 index 00000000..0def9ecd Binary files /dev/null and b/assets/providers/tv-adultswim.png differ diff --git a/assets/providers/tv-amazon.svg b/assets/providers/tv-amazon.svg new file mode 100644 index 00000000..6fd2d462 --- /dev/null +++ b/assets/providers/tv-amazon.svg @@ -0,0 +1 @@ + diff --git a/assets/providers/tv-bally.svg b/assets/providers/tv-bally.svg new file mode 100644 index 00000000..605b6ddc --- /dev/null +++ b/assets/providers/tv-bally.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-cbs.png b/assets/providers/tv-cbs.png new file mode 100644 index 00000000..a585f4d7 Binary files /dev/null and b/assets/providers/tv-cbs.png differ diff --git a/assets/providers/tv-espn.png b/assets/providers/tv-espn.png new file mode 100644 index 00000000..a29409be Binary files /dev/null and b/assets/providers/tv-espn.png differ diff --git a/assets/providers/tv-foxone.ico b/assets/providers/tv-foxone.ico new file mode 100644 index 00000000..427f7e3b Binary files /dev/null and b/assets/providers/tv-foxone.ico differ diff --git a/assets/providers/tv-freelivesports.png b/assets/providers/tv-freelivesports.png new file mode 100644 index 00000000..ca831c8d Binary files /dev/null and b/assets/providers/tv-freelivesports.png differ diff --git a/assets/providers/tv-fubo.png b/assets/providers/tv-fubo.png new file mode 100644 index 00000000..bfc1282d Binary files /dev/null and b/assets/providers/tv-fubo.png differ diff --git a/assets/providers/tv-hallmark.png b/assets/providers/tv-hallmark.png new file mode 100644 index 00000000..2d6b4677 Binary files /dev/null and b/assets/providers/tv-hallmark.png differ diff --git a/assets/providers/tv-hulu.png b/assets/providers/tv-hulu.png new file mode 100644 index 00000000..c733fcd7 Binary files /dev/null and b/assets/providers/tv-hulu.png differ diff --git a/assets/providers/tv-lg.svg b/assets/providers/tv-lg.svg new file mode 100644 index 00000000..3f27a7c2 --- /dev/null +++ b/assets/providers/tv-lg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-localnow.png b/assets/providers/tv-localnow.png new file mode 100644 index 00000000..cc6fa70c Binary files /dev/null and b/assets/providers/tv-localnow.png differ diff --git a/assets/providers/tv-nfl.png b/assets/providers/tv-nfl.png new file mode 100644 index 00000000..13010f9a Binary files /dev/null and b/assets/providers/tv-nfl.png differ diff --git a/assets/providers/tv-plex.svg b/assets/providers/tv-plex.svg new file mode 100644 index 00000000..ab364aeb --- /dev/null +++ b/assets/providers/tv-plex.svg @@ -0,0 +1 @@ + diff --git a/assets/providers/tv-pluto.png b/assets/providers/tv-pluto.png new file mode 100644 index 00000000..9b8ef690 Binary files /dev/null and b/assets/providers/tv-pluto.png differ diff --git a/assets/providers/tv-reelz.png b/assets/providers/tv-reelz.png new file mode 100644 index 00000000..3de5757c Binary files /dev/null and b/assets/providers/tv-reelz.png differ diff --git a/assets/providers/tv-roku.svg b/assets/providers/tv-roku.svg new file mode 100644 index 00000000..3192fa05 --- /dev/null +++ b/assets/providers/tv-roku.svg @@ -0,0 +1 @@ +Roku diff --git a/assets/providers/tv-samsung.svg b/assets/providers/tv-samsung.svg new file mode 100644 index 00000000..b0daba3f --- /dev/null +++ b/assets/providers/tv-samsung.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/providers/tv-sling.svg b/assets/providers/tv-sling.svg new file mode 100644 index 00000000..6288a7d5 --- /dev/null +++ b/assets/providers/tv-sling.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/providers/tv-spectrum.png b/assets/providers/tv-spectrum.png new file mode 100644 index 00000000..a5fccd9c Binary files /dev/null and b/assets/providers/tv-spectrum.png differ diff --git a/assets/providers/tv-stirr.png b/assets/providers/tv-stirr.png new file mode 100644 index 00000000..75a8be37 Binary files /dev/null and b/assets/providers/tv-stirr.png differ diff --git a/assets/providers/tv-tcl.png b/assets/providers/tv-tcl.png new file mode 100644 index 00000000..cc46235d Binary files /dev/null and b/assets/providers/tv-tcl.png differ diff --git a/assets/providers/tv-tubi.png b/assets/providers/tv-tubi.png new file mode 100644 index 00000000..b3f042eb Binary files /dev/null and b/assets/providers/tv-tubi.png differ diff --git a/assets/providers/tv-vizio.png b/assets/providers/tv-vizio.png new file mode 100644 index 00000000..be0bb33b Binary files /dev/null and b/assets/providers/tv-vizio.png differ diff --git a/assets/providers/tv-whale.svg b/assets/providers/tv-whale.svg new file mode 100644 index 00000000..8babc9b5 --- /dev/null +++ b/assets/providers/tv-whale.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-wsn.png b/assets/providers/tv-wsn.png new file mode 100644 index 00000000..8f1aed84 Binary files /dev/null and b/assets/providers/tv-wsn.png differ diff --git a/assets/providers/tv-xumo.svg b/assets/providers/tv-xumo.svg new file mode 100644 index 00000000..d63e3ed9 --- /dev/null +++ b/assets/providers/tv-xumo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/providers/tv-youtube.png b/assets/providers/tv-youtube.png new file mode 100644 index 00000000..5a3c6604 Binary files /dev/null and b/assets/providers/tv-youtube.png differ diff --git a/assets/providers/tvheadend.png b/assets/providers/tvheadend.png new file mode 100644 index 00000000..0c73d3fc Binary files /dev/null and b/assets/providers/tvheadend.png differ diff --git a/build.rs b/build.rs index 0e64398f..f47b57f8 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,7 @@ use std::path::Path; #[path = "packaging/ffmpeg.rs"] mod ffmpeg; +use xtask::live_tv; fn main() { // Get the workspace root directory @@ -21,6 +22,14 @@ fn main() { "cargo:rerun-if-changed={}", workspace_root.join("packaging/ffmpeg.rs").display() ); + println!( + "cargo:rerun-if-changed={}", + workspace_root.join("packaging/build_tools.rs").display() + ); + println!( + "cargo:rerun-if-changed={}", + workspace_root.join("packaging/ffmpeg_stack.rs").display() + ); let out_dir = env::var_os("OUT_DIR").expect("Failed to get OUT_DIR"); let target = env::var("TARGET").expect("Failed to get TARGET"); let prepared = ffmpeg::prepare(workspace_root, Path::new(&out_dir), &target) @@ -31,6 +40,13 @@ fn main() { println!("cargo:rustc-env=KOKO_BUILD_FFMPEG_ROOT={prepared}"); fs::write(Path::new(&out_dir).join("ffmpeg-root.txt"), prepared) .expect("Failed to record the prepared FFmpeg path"); + let native = live_tv::prepare(workspace_root, Path::new(&out_dir), &target) + .expect("Failed to prepare native TV support") + .map(|path| path.display().to_string()) + .unwrap_or_default(); + println!("cargo:rustc-env=KOKO_BUILD_TSDUCK_ROOT={native}"); + fs::write(Path::new(&out_dir).join("native-tv-root.txt"), native) + .expect("Failed to record native TV runtime path"); // Create target directory in workspace root let target_dir = workspace_root.join("target/doc/assets"); diff --git a/contributing/README.md b/contributing/README.md index bfc56696..4784f3c7 100644 --- a/contributing/README.md +++ b/contributing/README.md @@ -7,6 +7,27 @@ development procedures, and architectural notes here instead. - [Issue backlog](./known-issues.md) - [Metadata provider template](./METADATA_PROVIDER_TEMPLATE.md) +## Native TV builds + +Initialize the native dependencies with `git submodule update --init --recursive`. Cargo prepares +the TV component automatically using the shared Rust code in `packaging/live_tv.rs` and CMake. +Windows builds use the matching verified TSDuck SDK; Unix builds compile the pinned source with +Make. TSDuck's Unix code generators require Python, but Koko's build orchestration runs in Rust. + +Package builders can prepare the component separately with: + +```sh +cargo run --locked -p xtask -- prepare-live-tv \ + --target x86_64-unknown-linux-gnu --destination target/native-tv +``` + +Use `--source` for an existing checkout, `--cache` for a reusable build directory, `--revision` to +separate source caches, and `--jobs` to select build concurrency. `--system-cpp-runtime` uses the +package's C++ runtime libraries. Flatpak runs this command with `--offline` before building Koko, +then supplies the prepared directory through `KOKO_TSDUCK_ROOT`. + +Run the packaging and command-line regressions with `cargo test -p xtask --locked`. + ## Database migrations Use Diesel SQL migrations in `crates/server/sql/migrations` for schema and data changes, rather diff --git a/crates/client-web/playwright.config.mjs b/crates/client-web/playwright.config.mjs index 25a9ae7d..a02204c9 100644 --- a/crates/client-web/playwright.config.mjs +++ b/crates/client-web/playwright.config.mjs @@ -4,6 +4,9 @@ import { randomUUID } from 'node:crypto'; process.env.KOKO_E2E_PASSWORD ??= randomUUID(); process.env.KOKO_E2E_PORT ??= '19192'; +process.env.KOKO_E2E_FFMPEG ??= 'ffmpeg'; +process.env.KOKO_E2E_FFPROBE ??= 'ffprobe'; +process.env.KOKO_E2E_GENERATOR_FFMPEG ??= process.env.KOKO_E2E_FFMPEG; const baseURL = `http://127.0.0.1:${process.env.KOKO_E2E_PORT}`; const browserName = process.env.KOKO_E2E_BROWSER ?? 'chromium'; diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index 0a1f4bf9..7960b516 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -1337,7 +1337,8 @@ async function readJsonResponse(response: Response): Promise { return undefined as T; } -async function requestJson(method: string, path: string, body?: unknown, timeoutMs?: number): Promise { +/** Send a typed API request with the current account credentials and standard error handling. */ +export async function requestJson(method: string, path: string, body?: unknown, timeoutMs?: number): Promise { if (shouldUseMockApi()) { return getMockJsonFallback(method, path, body); } diff --git a/crates/client-web/src/app.ts b/crates/client-web/src/app.ts index ec4673d6..704dba81 100644 --- a/crates/client-web/src/app.ts +++ b/crates/client-web/src/app.ts @@ -1,4 +1,9 @@ /** Coordinates app startup, route-level data loading, and shell rendering. */ +import { renderLiveTvPage, renderTvPinnedHeader } from './app/liveTv'; +import { syncLiveTvAccount } from './app/liveTvActions'; +import './app/liveTv.css'; +import './app/liveTvGuide.css'; +import './app/tvMultiview.css'; import { renderPodcastPage } from './app/podcasts'; import kokoLogoUrl from '../../../assets/Koko.svg'; import { createIcons, icons } from 'lucide'; @@ -297,9 +302,18 @@ function eventBindingContext(): AppEventBindingContext { render, schedulePendingMetadataRefresh, setElementHtml, + renderTvSelection, }; } +/** Refresh the shared TV hero without rebuilding or rebinding the guide grid. */ +function renderTvSelection(): void { + const header = document.querySelector('#tv-pinned-header'); + if (!header || !state.liveTv) { render(); return; } + setElementHtml(header,renderTvPinnedHeader(state.liveTv)); + createIcons({icons,root:header}); +} + function renderLibraryRefreshIndicator(library: MediaLibrary): string { const progress = libraryRefreshProgress(library); if (!progress) { @@ -327,6 +341,7 @@ function isRailCollapsed(): boolean { } function renderCurrentPage(): string { + syncLiveTvAccount(); switch (state.route.page) { case 'item': return renderItemPage(); @@ -336,6 +351,8 @@ function renderCurrentPage(): string { return renderSettingsPage(); case 'podcasts': return renderPodcastPage(); + case 'live-tv': + return renderLiveTvPage(); default: return renderHomePage(); } @@ -390,6 +407,7 @@ function renderRail(): string { `) .join('')} ${showPodcasts ? `` : ''} +
@@ -412,6 +430,7 @@ function renderRail(): string { ${state.libraries.filter(library => library.path !== 'koko://podcasts').map((library) => ``).join('')} ${showPodcasts ? `` : ''} + @@ -435,6 +454,7 @@ function renderNavigationHint(): string { } function render(preserveScroll = true): void { + syncLiveTvAccount(); syncReaderAccount(); syncAudioPlayer(); if (!state.isPlayerOpen && !state.activeTrailer) { @@ -481,7 +501,7 @@ function render(preserveScroll = true): void {
${renderNavigationHint()} - ${renderPlayerOverlay()} + ${state.activeTvPlayback ? '' : renderPlayerOverlay()} `, preserveScroll); diff --git a/crates/client-web/src/app/confirmationDialog.css b/crates/client-web/src/app/confirmationDialog.css new file mode 100644 index 00000000..3109543e --- /dev/null +++ b/crates/client-web/src/app/confirmationDialog.css @@ -0,0 +1,9 @@ +.confirmation-dialog { width:min(32rem,calc(100vw - 2rem));max-height:calc(100vh - 2rem);margin:auto;padding:1.5rem;border:1px solid var(--surface-raised);border-radius:var(--radius-card);background:var(--surface-panel);color:inherit;font:inherit;box-shadow:0 1rem 4rem #0008; } +.confirmation-dialog::backdrop { background:#000b;backdrop-filter:blur(3px); } +.confirmation-dialog h2 { margin:0 0 .75rem;font-size:1.35rem; } +.confirmation-dialog p { color:var(--text-muted);line-height:1.6; } +.confirmation-dialog-actions { display:flex;flex-wrap:wrap;justify-content:flex-end;gap:.75rem;margin-top:1.5rem; } +.confirmation-dialog button { padding:.7rem 1rem;border:0;border-radius:.65rem;background:var(--surface-raised);color:inherit;font:inherit;font-weight:600;cursor:pointer; } +.confirmation-dialog button:hover { filter:brightness(1.15); } +.confirmation-dialog button:focus-visible { outline:2px solid var(--accent);outline-offset:3px; } +.confirmation-dialog .confirmation-dialog-confirm { background:#b33743;color:#fff; } diff --git a/crates/client-web/src/app/confirmationDialog.ts b/crates/client-web/src/app/confirmationDialog.ts new file mode 100644 index 00000000..7a568841 --- /dev/null +++ b/crates/client-web/src/app/confirmationDialog.ts @@ -0,0 +1,36 @@ +/** Presents themed confirmation dialogs with native focus trapping and keyboard dismissal. */ +import './confirmationDialog.css'; + +/** Text and destructive-action label for a confirmation presented inside Koko. */ +export interface ConfirmationOptions {title:string;message:string;confirmLabel:string} + +/** Resolve once, restoring focus on dismissal and cancelling if browser navigation leaves the page. */ +export function confirmAction(options:ConfirmationOptions):Promise { + const previous=document.activeElement as HTMLElement|null; + const dialog=document.createElement('dialog');dialog.className='confirmation-dialog'; + dialog.setAttribute('aria-labelledby','confirmation-dialog-title'); + dialog.setAttribute('aria-describedby','confirmation-dialog-message'); + const title=document.createElement('h2');title.id='confirmation-dialog-title';title.textContent=options.title; + const message=document.createElement('p');message.id='confirmation-dialog-message';message.textContent=options.message; + const actions=document.createElement('div');actions.className='confirmation-dialog-actions'; + const cancel=document.createElement('button');cancel.type='button';cancel.textContent='Cancel'; + const confirm=document.createElement('button');confirm.type='button';confirm.className='confirmation-dialog-confirm';confirm.textContent=options.confirmLabel; + actions.append(cancel,confirm);dialog.append(title,message,actions); + return new Promise(resolve=>{ + const events=new AbortController();let finished=false; + const finish=(accepted:boolean)=>{ + if(finished) {return;}finished=true;events.abort();dialog.close();dialog.remove(); + if(previous?.isConnected) {previous.focus();}resolve(accepted); + }; + cancel.addEventListener('click',()=>finish(false),{signal:events.signal}); + confirm.addEventListener('click',()=>finish(true),{signal:events.signal}); + dialog.addEventListener('cancel',event=>{event.preventDefault();finish(false);},{signal:events.signal}); + dialog.addEventListener('close',()=>finish(false),{signal:events.signal}); + dialog.addEventListener('click',event=>{ + const box=dialog.getBoundingClientRect(); + if(event.target===dialog&&(event.clientXbox.right||event.clientYbox.bottom)) finish(false); + },{signal:events.signal}); + globalThis.addEventListener('popstate',()=>finish(false),{signal:events.signal}); + document.body.append(dialog);dialog.showModal();cancel.focus(); + }); +} diff --git a/crates/client-web/src/app/domPatcher.ts b/crates/client-web/src/app/domPatcher.ts index 0c30ce85..4e3a43eb 100644 --- a/crates/client-web/src/app/domPatcher.ts +++ b/crates/client-web/src/app/domPatcher.ts @@ -63,6 +63,19 @@ const DOM_PATCH_KEY_ATTRIBUTES = [ 'data-music-open-item', 'data-player-audio-track-index', 'data-trailer-seek', + 'data-tv-source', + 'data-tv-source-group', + 'data-tv-provider', + 'data-tv-provider-editor', + 'data-tv-provider-form', + 'data-tv-select-program', + 'data-tv-select-channel', + 'data-tv-category', + 'data-tv-channel-view', + 'data-tv-favorite', + 'data-tv-source-card', + 'data-tv-channel-update', + 'data-tv-group-form', ] as const; function elementSelectorForFocus(element: Element | null): string | undefined { @@ -101,6 +114,7 @@ function domPatchKey(node: Node): string | undefined { } function scrollPositionKey(element: Element, index: number): string | undefined { + if (element.id === 'tv-guide-scroll') return `tv-guide:${(element as HTMLElement).dataset.tvView ?? ''}`; const patchKey = domPatchKey(element); if (patchKey) { return patchKey; @@ -141,22 +155,30 @@ function captureRenderSnapshot(): RenderSnapshot { } function restoreRenderSnapshot(root: HTMLElement, snapshot: RenderSnapshot): void { + const focusAfterPatch = document.activeElement; + // Honor navigation that scrolls after the patch but before the restore frame. + const afterPatch = new Map(); + document.querySelectorAll('.main-shell, .rail-nav, [data-shelf-row], .table-shell, .log-events').forEach(element => { + afterPatch.set(element,{top:element.scrollTop,left:element.scrollLeft}); + }); const frame = globalThis.requestAnimationFrame(() => { pendingSnapshotRestores.delete(root); document.querySelectorAll('.main-shell, .rail-nav, [data-shelf-row], .table-shell, .log-events').forEach((element, index) => { const key = scrollPositionKey(element, index); const position = key ? snapshot.scrollPositions.get(key) : undefined; if (position) { - element.scrollTop = position.top; - element.scrollLeft = position.left; + const expected = afterPatch.get(element); + if (element.scrollTop === expected?.top) element.scrollTop = position.top; + if (element.scrollLeft === expected?.left) element.scrollLeft = position.left; } }); const activeElement = snapshot.activeSelector ? document.querySelector(snapshot.activeSelector) : undefined; - activeElement?.focus({ preventScroll: true }); - if (snapshot.activeSelection && activeElement?.setSelectionRange) { + const restoreFocus = document.activeElement === focusAfterPatch; + if (restoreFocus && activeElement && document.activeElement !== activeElement) activeElement.focus({ preventScroll: true }); + if (restoreFocus && snapshot.activeSelection && activeElement?.setSelectionRange) { activeElement.setSelectionRange(snapshot.activeSelection.start, snapshot.activeSelection.end); } }); @@ -171,18 +193,15 @@ function nodesCanPatch(current: Node, next: Node): boolean { if (current.tagName !== next.tagName) { return false; } + // An unchanged video stays mounted when minimized; a new source needs a fresh + // element so decoders and source-specific event handlers are not carried over. + if (current instanceof HTMLMediaElement && next instanceof HTMLMediaElement + && current.getAttribute('src') !== next.getAttribute('src')) return false; const currentKey = domPatchKey(current); const nextKey = domPatchKey(next); if (currentKey || nextKey) { return currentKey === nextKey; } - if ( - current instanceof HTMLMediaElement - && next instanceof HTMLMediaElement - && current.getAttribute('src') !== next.getAttribute('src') - ) { - return false; - } } return true; } @@ -244,6 +263,21 @@ function syncFormControlState(current: Element, next: Element): void { } } +/** Preserve player controls, TV setup drafts, and open station choices across background refreshes. */ +function preserveOwnedControl(current: Element, next: Element): boolean { + if (!(current instanceof HTMLElement) || !(next instanceof HTMLElement)) return false; + if (current.id === 'media-player-overlay' && next.id === current.id + && current.dataset.playerViewKey !== undefined + && current.dataset.playerViewKey === next.dataset.playerViewKey) return true; + if (current.id === 'tv-source-form' && next.id === current.id) { + const key = current.dataset.tvFormKey; + return key !== undefined && key === next.dataset.tvFormKey; + } + return current instanceof HTMLSelectElement && next instanceof HTMLSelectElement + && current.dataset.tvGuideSource !== undefined && current.dataset.tvGuideSource === next.dataset.tvGuideSource + && current === document.activeElement; +} + function patchNode(current: Node, next: Node): Node { if (!nodesCanPatch(current, next)) { current.parentNode?.replaceChild(next, current); @@ -258,8 +292,9 @@ function patchNode(current: Node, next: Node): Node { } if (current instanceof Element && next instanceof Element) { + if (preserveOwnedControl(current, next)) return current; // A results refresh must preserve the user's expanded settings cards. - const detailsOpen = current instanceof HTMLDetailsElement ? current.open : undefined; + const detailsOpen = current instanceof HTMLDetailsElement && next instanceof HTMLElement && next.dataset.controlledOpen === undefined ? current.open : undefined; patchAttributes(current, next); patchChildren(current, next); syncFormControlState(current, next); @@ -313,6 +348,15 @@ function patchChildren(parent: Node, nextParent: ParentNode): void { continue; } + // Inserting a hint or loading row before a keyed element must not destroy + // that element when it is still present later in the next sibling list. + const currentKey = domPatchKey(currentChild); + if (currentKey && nextChildren.slice(index + 1).some((node) => domPatchKey(node) === currentKey)) { + currentChild.before(nextChild); + index += 1; + continue; + } + currentChild.replaceWith(nextChild); index += 1; } diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts index 02752bc1..ab26e732 100644 --- a/crates/client-web/src/app/eventBindings.ts +++ b/crates/client-web/src/app/eventBindings.ts @@ -1,6 +1,7 @@ /** Binds DOM events for each rendered app tree and owns transient UI timers. */ import { clearBrowseSessions } from './browseSession'; +import { bindLiveTvActions } from './liveTvActions'; import { bindPodcastActions, bindPodcastAddActions, bindPodcastDirectoryPreviews, retryPodcastAction } from './podcastActions'; import { renderPodcastFeature } from './podcasts'; import { bindBookActions } from './bookActions'; @@ -24,6 +25,7 @@ import { formDataString, formDataStrings, normalizedMetadataLanguages, parseMeta import { mediaExtraToTrailerOption } from './mediaExtras'; import { currentThemeSongYouTubeTarget, currentTrailerOptions } from './mediaTargets'; import { + dismissPlayer, bindPlayerProgress, bindTrailerPlayer, closeActivePlaybackSession, @@ -108,6 +110,8 @@ export interface AppEventBindingContext { refreshPendingMetadataData: () => Promise; /** Re-renders the app shell. */ render: (preserveScroll?: boolean) => void; + /** Refresh the TV hero while retaining the mounted guide and its focus. */ + renderTvSelection?: () => void; /** Schedules the background metadata refresh poller. */ schedulePendingMetadataRefresh: (force?: boolean) => void; /** Replaces a partial DOM subtree and reuses the app DOM patcher. */ @@ -503,7 +507,9 @@ function eventListenerOptionsWithSignal( return { signal }; } - return { ...options, signal }; + // Long-lived media bindings have their own lifetime while the surrounding page + // refreshes. Only supply the render signal when a caller did not provide one. + return { ...options, signal: options.signal ?? signal }; } /** Binds all event handlers for the current rendered DOM tree. */ @@ -519,7 +525,9 @@ export function bindEvents(context: AppEventBindingContext): void { listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, ): void { - if (this === globalThis) { + // An abort listener must outlive the render it cleans up. Attaching that + // same signal to its listener would remove it before cleanup can run. + if (this === globalThis || this instanceof AbortSignal) { originalAddEventListener.call(this, type, listener, options); return; } @@ -636,6 +644,7 @@ function bindRenderEvents(context: AppEventBindingContext, signal: AbortSignal): }); const { navigateTo, refreshData, refreshPendingMetadataData, render, schedulePendingMetadataRefresh, setElementHtml } = context; bindPodcastActions(signal, context); + bindLiveTvActions(signal, context); bindProfileImagePickers(); bindProviderLogos(); bindApiKeyPickers(); @@ -749,7 +758,7 @@ function bindRenderEvents(context: AppEventBindingContext, signal: AbortSignal): document.querySelector('[data-sign-out]')?.addEventListener('click', async () => { stopMusic(); - if (state.isPlayerOpen) closeActivePlaybackSession(); + if (state.isPlayerOpen || state.activeTvPlayback) closeActivePlaybackSession(); try { await logoutUser(); } catch (error) { @@ -1297,8 +1306,11 @@ function bindRenderEvents(context: AppEventBindingContext, signal: AbortSignal): }); document.querySelector('#close-player')?.addEventListener('click', () => { - closeActivePlaybackSession(); - }); + dismissPlayer(); + }, {signal}); + document.querySelector('#expand-tv-player')?.addEventListener('click', () => { + state.isPlayerOpen = true; render(); + }, {signal}); document.querySelector('#settings-form')?.addEventListener('submit', async (event) => { event.preventDefault(); diff --git a/crates/client-web/src/app/featureHero.ts b/crates/client-web/src/app/featureHero.ts new file mode 100644 index 00000000..c84b70b8 --- /dev/null +++ b/crates/client-web/src/app/featureHero.ts @@ -0,0 +1,37 @@ +/** Renders the standard browse hero shared by library previews and feature pages. */ +import { escapeHtml } from './format'; + +interface FeatureHeroOptions { + id?: string; + label?: string; + image?: string; + className?: string; + /** Escaped, application-rendered markup placed inside the standard copy column. */ + copy: string; + actions?: string; + decoration?: string; + poster?: { url: string; label: string }; + /** Reserve the artwork area for an existing player, without mounting another video. */ + media?: { id: string; active: boolean; content?: string }; +} + +/** Share hero backgrounds while keeping publisher URLs inside their CSS string. */ +export function featureArtworkStyle(backdropUrl?: string): string { + if (!backdropUrl) return ''; + const cssUrl = backdropUrl.replace(/['"\\\r\n\f]/g, character => '%' + character.codePointAt(0)!.toString(16).padStart(2, '0')); + return `style="--home-feature-image: url('${escapeHtml(cssUrl)}');"`; +} + +/** Use the ordinary full-width hero, typography, backdrop, and action layout. */ +export function renderFeatureHero(options: FeatureHeroOptions): string { + const id = options.id ? `id="${escapeHtml(options.id)}"` : ''; + const label = options.label ? `aria-label="${escapeHtml(options.label)}"` : ''; + const mediaClass = options.media?.active ? ' is-active' : ''; + const media = options.media ? `
${options.media.content ?? ''}
` : ''; + const poster = options.poster ? `${escapeHtml(options.poster.label)}` : ''; + const copy = poster ? `${poster}
${options.copy}
` : options.copy; + const copyClass = poster ? ' has-poster' : ''; + const classes = ['home-feature',options.className,options.image && 'has-artwork',options.media?.active && 'has-media-preview'].filter(Boolean).join(' '); + return `
+ ${options.decoration ?? ''}
${copy}
${options.actions ?? ''}${media}
`; +} diff --git a/crates/client-web/src/app/holdActions.ts b/crates/client-web/src/app/holdActions.ts new file mode 100644 index 00000000..21c703da --- /dev/null +++ b/crates/client-web/src/app/holdActions.ts @@ -0,0 +1,79 @@ +/** Delegates click, keyboard, and hold actions without retaining virtualized guide controls. */ +import { RESUME_HOLD_MS } from './resumeHold'; + +type Action=(button:HTMLButtonElement)=>void; +interface Binding {scope:AbortSignal;controller:AbortController;generation:number;activate:Action;options:Action} +const bindings=new Map(); + +/** Keep a pending hold through DOM patches, removing listeners when its route stops rebinding. */ +export function bindHoldActions(selector:string, activate:Action, options:Action, signal:AbortSignal):void { + if(signal.aborted)return; + let binding=bindings.get(selector); + if(!binding){ + binding={scope:signal,controller:new AbortController(),generation:0,activate,options};bindings.set(selector,binding); + const current=binding; + listenForHolds(selector,button=>{if(!current.scope.aborted)current.activate(button);},button=>{if(!current.scope.aborted)current.options(button);},current.controller.signal); + } + binding.scope=signal;binding.activate=activate;binding.options=options; + const current=binding,generation=++binding.generation; + signal.addEventListener('abort',()=>queueMicrotask(()=>{ + if(current.generation!==generation)return; + current.controller.abort();bindings.delete(selector); + }),{once:true}); +} + +/** Short presses activate; holds or context-menu gestures open options and consume release. */ +function listenForHolds(selector:string, activate:Action, options:Action, signal:AbortSignal):void { + type Press={button:HTMLButtonElement;source:'pointer'|'keyboard';opened:boolean;x:number;y:number}; + let press:Press|undefined,timer:ReturnType|undefined; + let suppressClick=false,releaseKey:string|undefined; + const target=(event:Event)=> (event.target as Element|null)?.closest?.(selector); + const cancel=()=>{ + if(timer!==undefined) clearTimeout(timer); + if(press?.source==='pointer') suppressClick=true; + press?.button.classList.remove('is-holding'); + press=undefined;timer=undefined; + }; + const begin=(button:HTMLButtonElement,source:Press['source'],x=0,y=0)=>{ + if(button.disabled||signal.aborted) return; + cancel();suppressClick=false;press={button,source,opened:false,x,y};button.classList.add('is-holding'); + timer=setTimeout(()=>{ + timer=undefined;if(!press||!button.isConnected) {cancel();return;} + press.opened=true;button.classList.remove('is-holding');suppressClick=source==='pointer';options(button); + },RESUME_HOLD_MS); + }; + document.addEventListener('click',event=>{ + if(suppressClick){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;return;} + const button=target(event);if(button&&!button.disabled&&!event.defaultPrevented) activate(button); + },{signal,capture:true}); + document.addEventListener('pointerdown',event=>{ + suppressClick=false;const button=target(event); + if(button&&event.button===0) {begin(button,'pointer',event.clientX,event.clientY);button.setPointerCapture?.(event.pointerId);} + },{signal,capture:true}); + document.addEventListener('pointermove',event=>{ + if(press?.source==='pointer'&&!press.opened&&Math.hypot(event.clientX-press.x,event.clientY-press.y)>12) cancel(); + },{signal,capture:true}); + document.addEventListener('pointerup',()=>{if(press?.source==='pointer') {if(timer!==undefined){clearTimeout(timer);}press.button.classList.remove('is-holding');press=undefined;timer=undefined;}},{signal,capture:true}); + document.addEventListener('pointercancel',cancel,{signal,capture:true}); + document.addEventListener('scroll',()=>{if(press?.source==='pointer'&&!press.opened) cancel();},{signal,capture:true,passive:true}); + document.addEventListener('focusout',event=>{if(press&&!press.opened&&event.target===press.button) cancel();},{signal,capture:true}); + document.addEventListener('contextmenu',event=>{ + const button=target(event);if(!button||button.disabled)return; + event.preventDefault();cancel();options(button); + },{signal}); + document.addEventListener('keydown',event=>{ + if(event.repeat&&event.key===releaseKey){event.preventDefault();event.stopImmediatePropagation();return;} + if(!event.repeat) suppressClick=false; + const button=target(event); + if(!button||button.disabled||event.altKey||event.ctrlKey||event.metaKey||!['Enter',' '].includes(event.key))return; + event.preventDefault();if(!event.repeat){releaseKey=event.key;begin(button,'keyboard');} + },{signal,capture:true}); + document.addEventListener('keyup',event=>{ + if(event.key!==releaseKey)return; + event.preventDefault();event.stopImmediatePropagation();releaseKey=undefined; + const held=press;cancel(); + if(held?.source==='keyboard'&&!held.opened&&held.button.isConnected)activate(held.button); + },{signal,capture:true}); + globalThis.addEventListener('blur',()=>{cancel();releaseKey=undefined;},{signal}); + signal.addEventListener('abort',()=>{cancel();releaseKey=undefined;},{once:true}); +} diff --git a/crates/client-web/src/app/homeView.ts b/crates/client-web/src/app/homeView.ts index 51660b01..ed42f0e8 100644 --- a/crates/client-web/src/app/homeView.ts +++ b/crates/client-web/src/app/homeView.ts @@ -3,6 +3,9 @@ import type { MediaHome, MediaCollectionSummary, MediaItemSummary, MediaLibrary, import { getArtworkUrl, getPersonImageUrl, resolveApiUrl } from '../api'; import { HOME_SHELF_CHUNK_SIZE } from './constants'; import { escapeHtml, formatTimestamp } from './format'; +import { featureArtworkStyle, renderFeatureHero } from './featureHero'; +/** Shared background styling retained for existing feature renderers. */ +export { featureArtworkStyle } from './featureHero'; import { activeBrowseFilterCount, browseItems } from './browsePreferences'; import { renderLoadingCards, renderLoadingDetail } from './loadingView'; import { currentThemeSongYouTubeTarget } from './mediaTargets'; @@ -494,13 +497,6 @@ export function renderHomeFeature(): string { return renderItemFeature(preview.item); } -/** Share hero backgrounds while keeping publisher URLs inside their CSS string. */ -export function featureArtworkStyle(backdropUrl?: string): string { - if (!backdropUrl) return ''; - const cssUrl = backdropUrl.replace(/['"\\\r\n\f]/g, character => '%' + character.codePointAt(0)!.toString(16).padStart(2, '0')); - return `style="--home-feature-image: url('${escapeHtml(cssUrl)}');"`; -} - function renderCollectionFeature(collection: MediaCollectionSummary): string { const backdropUrl = pageBackdropUrlForCollection(collection); const overview = collection.overview ?? `${collection.item_count} title${collection.item_count === 1 ? '' : 's'} in this collection.`; @@ -530,10 +526,9 @@ export function renderItemFeature(item: MediaItemSummary, catalog = [...homePrev const overview = item.overview?.trim(); const extent = formatChildCount(item); - return ` -
- ${item.podcast ? `` : ''} -
+ return renderFeatureHero({ image:backdropUrl, className:item.podcast ? 'home-feature-podcast' : undefined, + decoration:item.podcast ? `` : '', + copy:` ${logoUrl ? `` : `

${escapeHtml(identity.display_title)}

`} @@ -543,12 +538,9 @@ export function renderItemFeature(item: MediaItemSummary, catalog = [...homePrev ${genreMarkup} ${extent ? `${escapeHtml(extent)}` : ''}
- - -
- `; + ` }); } type ItemSearchResult = Extract; diff --git a/crates/client-web/src/app/input.ts b/crates/client-web/src/app/input.ts index db7f7e12..29c6edb8 100644 --- a/crates/client-web/src/app/input.ts +++ b/crates/client-web/src/app/input.ts @@ -2,6 +2,8 @@ import type { AppState } from './types'; import { createGamepadNavigationReader, type GamepadNavigationAction } from './gamepadNavigation'; import { findSpatialTarget, type NavigationDirection } from './spatialNavigation'; +import { rememberTvGuideFocus, rememberTvGuidePointer, restoreTvGuideFocus, tvGuideNavigationTarget, tvGuideNavigationTime, tvGuideJumpTarget } from './tvGuideNavigation'; +import { TV_WINDOW_SECONDS } from './tvGuideTime'; const SECTION_SELECTOR = '[data-nav-section]'; const CONTROL_SELECTOR = 'button:not(:disabled), a[href], summary, input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'; @@ -111,10 +113,51 @@ function closeResumeOptions(): boolean { return true; } +function revealGuideTime(guide: HTMLElement, control: HTMLElement): void { + if (control.closest('.tv-guide-channel')) return; + const bounds = guide.getBoundingClientRect(), rect = control.getBoundingClientRect(); + const left = bounds.left+(guide.querySelector('.tv-guide-channel')?.getBoundingClientRect().width ?? 0); + const time = tvGuideNavigationTime(control); + const track = guide.querySelector('.tv-guide-time-track')?.getBoundingClientRect(); + if (time !== undefined && track) { + const point = track.left+(time-Number(guide.dataset.tvWindowStart))/TV_WINDOW_SECONDS*track.width; + if (point < left) guide.scrollLeft += point-left; + else if (point > bounds.right-24) guide.scrollLeft += point-bounds.right+24; + return; + } + const intersects = rect.right > left && rect.left < bounds.right; + // Long broadcasts may cover the whole viewport; moving down should keep the + // current time visible rather than jumping to the far end of that program. + if (rect.width > bounds.right-left && intersects) return; + if (rect.left < left) guide.scrollLeft += rect.left-left; + else if (rect.right > bounds.right) guide.scrollLeft += rect.right-bounds.right; +} + +/** Reveal guide cells inside their shared scroller, allowing for sticky time/channel headings. */ +function revealTvGuideControl(control: HTMLElement): boolean { + const guide = control.closest('#tv-guide-scroll'); + if (!guide) return false; + const main = guide.closest('.main-shell'); + const header = document.querySelector('.home-pinned-header'); + const top = (main?.getBoundingClientRect().top ?? 0) + (header?.getBoundingClientRect().height ?? 0); + if (main && guide.getBoundingClientRect().top > main.getBoundingClientRect().bottom-136) { + main.scrollTop += guide.getBoundingClientRect().top-top-12; + } + const bounds = guide.getBoundingClientRect(), rect = control.getBoundingClientRect(); + const timeHeight = guide.querySelector('.tv-guide-time-row')?.getBoundingClientRect().height ?? 48; + const visibleTop = Math.max(bounds.top+timeHeight,top); + const bottom = Math.min(bounds.bottom, main?.getBoundingClientRect().bottom ?? innerHeight); + if (rect.top < visibleTop) guide.scrollTop += rect.top-visibleTop; + else if (rect.bottom > bottom) guide.scrollTop += rect.bottom-bottom; + revealGuideTime(guide,control); + return true; +} + function focusControl(control: HTMLElement | undefined, revealSection = false): void { if (!control) return; control.focus({ preventScroll: true }); const section = control.closest(SECTION_SELECTOR); + if (revealTvGuideControl(control)) {syncNavigationState(); return;} if (revealItemControl(control, section, revealSection)) { syncNavigationState(); return; @@ -138,6 +181,7 @@ function setInputMode(mode: 'pointer' | 'keyboard' | 'gamepad'): void { /** Refresh section feedback after a DOM patch without changing the user's focus. */ export function syncNavigationState(): void { const active = document.activeElement as HTMLElement | null; + rememberTvGuideFocus(active); const section = active?.closest(SECTION_SELECTOR); const mode = document.body.dataset.inputMode; const showFocus = mode === 'keyboard' || mode === 'gamepad'; @@ -178,6 +222,8 @@ function returnToBrowseTabs(): boolean { /** Restore visible feedback and give a newly opened page a useful starting control. */ export function syncNavigationAfterRender(): void { + const guideTarget = restoreTvGuideFocus(); + if (guideTarget) focusControl(guideTarget); for (const [key, control] of rememberedFocus) { if (!control.isConnected) rememberedFocus.delete(key); } @@ -212,16 +258,28 @@ function adjustFocusedSelect(active: HTMLElement | null, direction: NavigationDi return true; } +function moveTvGuideFocus(active: HTMLElement, direction: NavigationDirection): boolean { + const guideTarget = tvGuideNavigationTarget(active,direction); + if (!guideTarget) return false; + if (guideTarget.target) focusControl(guideTarget.target); + return true; +} + +function moveWithinFocusedControl(active: HTMLElement | null, direction: NavigationDirection): boolean { + if (adjustFocusedSelect(active,direction)) return true; + return direction === 'left' && Boolean(active?.matches('#search-input')) && returnToBrowseTabs(); +} + function moveFocus(direction: NavigationDirection): void { const sections = navigationSections(); const active = document.activeElement as HTMLElement | null; - if (adjustFocusedSelect(active, direction)) return; - if (direction === 'left' && active?.matches('#search-input') && returnToBrowseTabs()) return; + if (moveWithinFocusedControl(active,direction)) return; const section = active?.closest(SECTION_SELECTOR); if (!section || !active || !isVisible(active)) { focusControl(entryControl(sections.find((entry) => entry.dataset.navSection !== 'libraries') ?? sections[0])); return; } + if (moveTvGuideFocus(active,direction)) return; const rect = active.getBoundingClientRect(); const next = findSpatialTarget(rect, directionalControls(section).filter((control) => control !== active), (control) => control.getBoundingClientRect(), direction); if (next) { @@ -293,6 +351,8 @@ function handleNavigationKey(event: KeyboardEvent): void { const target = event.target as HTMLElement | null; if (handleSearchKey(event, target) || handleSectionTab(event, target)) return; if (target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')) return; + const jump = target ? tvGuideJumpTarget(target,event.key) : undefined; + if (jump) {focusControl(jump.target);event.preventDefault();return;} const directions: Record = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' }; if (directions[event.key] && navigationSections().length) { moveFocus(directions[event.key]); @@ -302,14 +362,14 @@ function handleNavigationKey(event: KeyboardEvent): void { /** Bind section-aware navigation for browsing pages, leaving settings and players to their own controls. */ export function bindGlobalInputHandlers(state: Pick): void { - const suspended = () => state.isPlayerOpen || Boolean(state.activeTrailer) || state.route.page === 'settings' || document.hidden || Boolean(document.querySelector('dialog.book-reader[open]')); + const suspended = () => state.isPlayerOpen || Boolean(state.activeTrailer) || state.route.page === 'settings' || document.hidden || Boolean(document.querySelector('dialog[open]')); globalThis.addEventListener('pointermove', (event) => { if (event.movementX || event.movementY) setInputMode('pointer'); }, { passive: true }); - globalThis.addEventListener('pointerdown', () => setInputMode('pointer'), { passive: true }); + globalThis.addEventListener('pointerdown', (event) => { setInputMode('pointer'); rememberTvGuidePointer(event); }, { passive: true }); globalThis.addEventListener('focusin', syncNavigationState); globalThis.addEventListener('keydown', (event) => { - if (document.querySelector('dialog.book-reader[open]')) return; + if (document.querySelector('dialog[open]')) return; if (suspended() || event.altKey || event.ctrlKey || event.metaKey) return; if (event.defaultPrevented) { if ((event.target as HTMLElement | null)?.matches('[data-resume-hold]') && ['Enter', ' '].includes(event.key)) setInputMode('keyboard'); diff --git a/crates/client-web/src/app/liveTv.css b/crates/client-web/src/app/liveTv.css new file mode 100644 index 00000000..680cdd44 --- /dev/null +++ b/crates/client-web/src/app/liveTv.css @@ -0,0 +1,81 @@ +.live-tv-page { padding: 1.5rem; max-width: 1700px; margin: 0 auto; } +.live-tv-header, .live-tv-guide-controls, .live-tv-tabs, .live-tv-actions, .live-tv-inline-form { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; } +.live-tv-header { justify-content: space-between; margin-bottom: 1.5rem; } +.live-tv-header h1 { margin: .25rem 0; } +.live-tv-header p { color: var(--text-muted); } +.live-tv-tabs { border-bottom: 1px solid var(--surface-raised); padding-bottom: 1rem; margin-bottom: 1.5rem; } +.live-tv-tabs button[aria-pressed="false"] { background: var(--surface-raised); color: inherit; } +.live-tv-guide-controls { margin-bottom: 1rem; } +.live-tv-guide-controls form { display: flex; flex-wrap: wrap; gap: .75rem; align-items: end; margin-left: auto; } +:is(.live-tv-page, .live-tv-settings) label { display: flex; flex-direction: column; gap: .4rem; } +:is(.live-tv-page, .live-tv-settings) label:has(input[type="checkbox"]) { flex-direction: row; align-items: center; } +:is(.live-tv-page, .live-tv-settings) input, :is(.live-tv-page, .live-tv-settings) select, :is(.live-tv-page, .live-tv-settings) textarea { min-width: 0; padding: .6rem; color: inherit; background: var(--surface-base); border: 1px solid var(--surface-raised); border-radius: 6px; } +.live-tv-error { color: #ffbcaa; padding: .75rem; border-left: 3px solid #ffbcaa; background: #742c211f; } +.live-tv-recording { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; margin-bottom: .75rem; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-recording h3, .live-tv-recording p { margin: .35rem 0; } +.live-tv-panel { padding: 1rem; margin: 1rem 0; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-panel > summary { cursor: pointer; font-weight: 600; } +.live-tv-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; padding-top: 1rem; } +.live-tv-form > label:has([name="url"]) { grid-column: 1 / -1; } +.live-tv-form-help, .live-tv-guide-account { grid-column: 1 / -1; } +.live-tv-form-help { color: var(--text-muted); margin: 0; } +.live-tv-guide-account label { margin: .75rem 0; } +.live-tv-source-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; } +.live-tv-source { min-width: 0; padding: 1rem; background: var(--surface-panel); border: 1px solid var(--surface-raised); border-radius: var(--radius-card); } +.live-tv-source .live-tv-actions { margin-top: 1rem; } +.live-tv-upload { font-size: .9rem; } +.live-tv-channel-mapping { display: grid; grid-template-columns: minmax(120px, 1fr) auto minmax(150px, 2fr) auto; gap: 1rem; align-items: center; padding: 1rem 0; border-bottom: 1px solid var(--surface-raised); } +.live-tv-group-members { display: grid; gap: .5rem; grid-column: 1 / -1; } +.live-tv-source .live-tv-channel-mapping { grid-template-columns: minmax(0,1fr) auto; } +.live-tv-source .live-tv-channel-mapping label:has(select) { grid-column: 1 / -1; } +.live-tv-source input, .live-tv-source select { max-width: 100%; min-width: 0; } +.tv-source-heading { display:flex;align-items:center;gap:1rem;flex-wrap:wrap; } +.tv-source-heading strong { flex:1;min-width:0;font-size:1.1rem;overflow-wrap:anywhere; } +.tv-source-summary { display:flex;align-items:center;justify-content:space-between;gap:1rem;cursor:pointer;list-style:none; } +.tv-source-summary::-webkit-details-marker,.tv-provider-setting > summary::-webkit-details-marker { display:none; } +.tv-source-overview { display:grid;gap:.5rem;min-width:0; } +.tv-source-overview > :not(.tv-source-heading) { color:var(--text-muted);font-size:.9rem;font-weight:400; } +.tv-source-icon { display:inline-grid;position:relative;place-items:center;flex:0 0 3rem;width:3rem;height:3rem; } +.tv-disclosure-icon { display:inline-flex;flex-shrink:0;transition:transform .15s; } +details[open] > summary > .tv-disclosure-icon { transform:rotate(90deg); } +.tv-disclosure-icon svg { width:1.25rem;height:1.25rem; } +.tv-source-icon .tv-icon-fallback,.tv-source-icon img { grid-area:1 / 1;width:100%;height:100%; } +.tv-icon-fallback { display:grid;place-items:center; } +.tv-icon-fallback svg { width:100%;height:100%; } +.tv-type-symbol { display:grid;place-items:center;width:100%;height:100%; } +.tv-type-symbol svg { width:1.75rem;height:1.75rem; } +.tv-source-icon img { position:absolute;inset:0;object-fit:contain; } +.tv-source-icon img[hidden] { display:none; } +.tv-source-icon:has(img:not([hidden])) .tv-icon-fallback { visibility:hidden; } +.tv-icon-upload { position:relative;cursor:pointer;display:inline-flex;align-items:center;justify-content:center; } +.tv-icon-upload input[type="file"] { position:absolute;inset:0;opacity:0;cursor:pointer;width:100%; } +.tv-provider-setting > summary { display:flex;align-items:center;gap:1rem;flex-wrap:wrap; } +.tv-provider-setting > summary > strong { flex:1; } +.tv-provider-setting > summary > span:not(.tv-source-icon):not(.tv-disclosure-icon) { color:var(--text-muted);font-weight:400; } +.tv-provider-icon-choice { grid-column:1 / -1; } +@media (max-width: 700px) { + .live-tv-page { padding: .75rem; } + .live-tv-form, .live-tv-channel-mapping { grid-template-columns: 1fr; } + .live-tv-recording { flex-direction: column; align-items: stretch; } + .live-tv-guide-controls form { margin-left: 0; } +} + +.live-tv-source-discovery { grid-column: 1 / -1; display: grid; gap: .75rem; min-width: 0; border: 1px solid var(--surface-raised); border-radius: 8px; padding: 1rem; } +.live-tv-guide-account[hidden] { display: none; } +.live-tv-kind-picker { margin: 1rem 0; } +.live-tv-discovery-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem 1rem; } +.live-tv-discovery-toolbar p { margin: 0; color: var(--text-muted); } +.live-tv-discovery-toolbar p:empty { display: none; } +.live-tv-discovery-results { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: .75rem; min-width: 0; } +.live-tv-discovery-results[hidden] { display: none; } +.live-tv-source-discovery .live-tv-discovery-card { flex-direction: row; align-items: start; gap: .75rem; margin: 0; padding: 1rem; border: 1px solid var(--surface-raised); border-radius: var(--radius-card); background: var(--surface-base); cursor: pointer; } +.live-tv-discovery-card:hover { border-color: var(--accent); } +.live-tv-discovery-card:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); } +.live-tv-discovery-card:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 3px; } +.live-tv-discovery-card input[type="radio"] { flex: none; width: 1rem; height: 1rem; margin: .15rem 0 0; padding: 0; accent-color: var(--accent); } +.live-tv-discovery-card-content { display: grid; gap: .5rem; min-width: 0; width: 100%; overflow-wrap: anywhere; } +.live-tv-discovery-card-heading { display: flex; align-items: start; justify-content: space-between; flex-wrap: wrap; gap: .4rem; } +.live-tv-discovery-card-address { font-family: monospace; font-size: .85rem; } +.live-tv-discovery-card-details { font-size: .85rem; color: var(--text-muted); } +.live-tv-discovery-selected { display: none; color: var(--accent); font-size: .75rem; font-weight: 600; } +.live-tv-discovery-card:has(input:checked) .live-tv-discovery-selected { display: inline; } diff --git a/crates/client-web/src/app/liveTv.ts b/crates/client-web/src/app/liveTv.ts new file mode 100644 index 00000000..1d59c3d5 --- /dev/null +++ b/crates/client-web/src/app/liveTv.ts @@ -0,0 +1,69 @@ +/** Renders the Live TV guide, program details, and shared or private recordings. */ +import type { TvRecording } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; +import { state } from './state'; +import { renderIcon } from './ui'; +import type { LiveTvState } from './liveTvActions'; +import { renderTvGuide, renderTvHero } from './liveTvGuide'; +import { tvTime } from './liveTvGuideModel'; +/** Shared guide helpers for source-scoped schedules and safe artwork. */ +export { channelPrograms, tvImage, tvTime } from './liveTvGuideModel'; + +/** Render data only for the account that fetched it. */ +export function renderLiveTvPage(): string { + const data = state.liveTv; + if (!data || data.owner !== state.bootstrap?.current_user?.id) return '

Live TV

Loading channels and guide…

'; + const tab = data.tab ?? 'guide'; + const content = tab === 'recordings' ? renderRecordings(data) : renderTvGuide(data); + return `
${renderTvPinnedHeader(data)}
+
+ ${data.error ? `` : ''} + ${data.message ? `

${escape(data.message)}

` : ''} + ${data.loading ? '

Updating Live TV…

' : ''}${content}
`; +} + +/** Update highlighted program details without rebuilding the channel grid or shared player. */ +export function renderTvPinnedHeader(data: LiveTvState): string { + const admin = state.bootstrap?.current_user?.admin === true; + const tab = data.tab ?? 'guide'; + const tabs = [{ key: 'guide', label: 'Guide' }, { key: 'recordings', label: 'Recordings' }]; + return `
+ +
${admin ? '' : ''}
${renderTvHero(data)}`; +} + +function renderRecording(recording: TvRecording, data: LiveTvState): string { + const active = ['scheduled', 'recording'].includes(recording.state); + const conflict = data.conflicts.includes(recording.id); + const cancelLabel = recording.state === 'recording' ? 'Stop recording' : 'Cancel recording'; + const play = recording.has_video ? `` : ''; + let controls = active + ? `` + : `${play}`; + if (recording.state === 'stopping') controls = 'Stopping recording…'; + if (recording.state === 'deleting') controls = 'Deleting recording…'; + if (recording.user_id !== data.owner) controls = play; + const error = recording.last_error ? `

${escape(recording.last_error)}

` : ''; + const description = recording.metadata?.description ? `

${escape(recording.metadata.description)}

` : ''; + return `

${escape(recording.title)}

${escape(tvTime(recording.starts_at, true))}–${escape(tvTime(recording.ends_at))}

+

${escape(recording.state)} · ${recording.shared ? 'Shared' : 'Private'}${conflict ? ' · Tuner conflict' : ''}

${description}${error}
${controls}
`; +} + +function renderRecordings(data: LiveTvState): string { + const rules = data.rules.map(rule => { + const stop = rule.user_id === data.owner ? `` : ''; + return `
${escape(rule.title)}

${rule.new_only ? 'New episodes only' : 'All episodes'} · ${rule.shared ? 'Shared' : 'Private'} · Channel ${escape(data.channels.find(c => c.id === rule.channel_id)?.name ?? String(rule.channel_id))}

${stop}
`; + }).join(''); + return `

Recordings

${data.conflicts.length ? '

Some recordings overlap beyond the available tuner capacity. Cancel a conflicting recording or increase the source limit if more tuners are available.

' : ''} + ${data.recordings.map(r => renderRecording(r, data)).join('') || '

No recordings scheduled.

'} +

Series

${rules || '

Open a program in the guide and choose Record series.

'} + ${renderManualRecording(data)}`; +} + +function renderManualRecording(data: LiveTvState): string { + if (data.canRecord === false) return '

Recording is disabled for your account.

'; + return `
Schedule by time
+ + +
`; +} diff --git a/crates/client-web/src/app/liveTvActions.ts b/crates/client-web/src/app/liveTvActions.ts new file mode 100644 index 00000000..795f5f38 --- /dev/null +++ b/crates/client-web/src/app/liveTvActions.ts @@ -0,0 +1,331 @@ +/** Loads account-scoped Live TV data and binds source, guide, playback, and DVR actions. */ +import { formDataString } from './formUtils'; +import * as api from '../liveTvApi'; +import type { AppRoute, AppState } from './types'; +import { state } from './state'; +import { startTvPlayback, stopTvPlayback } from './playbackController'; +import { bindTvDiscovery } from './liveTvDiscovery'; +import { bindTvGuideActions } from './liveTvGuideActions'; +import { channelArtwork, channelArtworkFallback, tvImage } from './liveTvGuideModel'; +import { currentGuideWindow, TV_SLOT_SECONDS, TV_WINDOW_SECONDS } from './tvGuideTime'; +import { tvChannelPlayback } from './tvPlayer'; +import { bindTvGuideMappings } from './liveTvMapping'; +import type { TvGuideViewport } from './tvGuideViewport'; +import { bindTvGuideViewport } from './liveTvGuideViewport'; +import { hydrateTvConnections, bindTvSourceSettings } from './liveTvSourceActions'; +import { bindHoldActions } from './holdActions'; +import { openTvGuideOptions, openTvRecordingOptions, syncTvDialogs } from './liveTvDialogs'; + +/** Live TV data belongs to the account that fetched it; setup data is administrator-only. */ +export interface LiveTvState { + owner: number; channels: api.TvChannel[]; programs: api.TvProgram[]; stations: api.TvStation[]; + recordings: api.TvRecording[]; rules: api.TvSeriesRule[]; sources: api.TvSource[]; conflicts: number[]; + windowStart: number; tab: 'guide' | 'recordings'; query?: string; group?: string; + loading: boolean; error?: string; message?: string; + canRecord?: boolean; dvrSettings?: api.TvDvrSettings; + setupKind?: string; setupAdding?: boolean; regions?: string[]; + sourceGroups?:api.TvSourceGroup[]; sourceGroupId?:string; + connections?:Record;connectionOpen?:number;connectionLoading?:number; + sourceCardsOpen?:number[]; + connectionDrafts?:Record>; + mappingSource?:number;mappingQuery?:string;mappingPage?:number; + mappingProvider?:string;providerEditor?:string;providerView?:string;providers?:api.TvProvider[]; + providerDrafts?:Record; + guideSources?: api.TvGuideSource[]; preferences?: api.TvPreferences; + sourceId?: number; category?: string; channelView?: 'all' | 'favorites' | 'recent'; + selectedProgramId?: number; selectedChannelId?: number; + filtersOpen?: boolean; + guideViewport?: TvGuideViewport; +} + +/** Identify the administrator's dedicated Live TV settings section. */ +export function isLiveTvSettings(route: AppRoute): boolean { return route.page === 'settings' && route.section === 'live-tv'; } + +let loadGeneration = 0; +let nextGuideRefreshAt = 0; +let guideClockOwner: number | undefined; +type LiveTvApi = Pick & Partial>; + +/** Ignore older guide windows as well as responses belonging to previous accounts. */ +export async function loadLiveTv(app: AppState, isCurrent: () => boolean, source: LiveTvApi = api): Promise { + const generation = ++loadGeneration; + const owner = app.bootstrap?.current_user?.id; + if (owner === undefined) { app.liveTv = undefined; return; } + const previous = app.liveTv?.owner === owner ? app.liveTv : undefined; + const data: LiveTvState = previous ?? { owner, channels: [], programs: [], stations: [], recordings: [], rules: [], sources: [], conflicts: [], windowStart: currentGuideWindow(), tab: 'guide', loading: true }; + data.windowStart = currentGuideWindow(data.windowStart); + app.liveTv = data; + data.loading = true; + const admin = app.bootstrap?.current_user?.admin === true; + const setup = admin && isLiveTvSettings(app.route); + try { + const [channels, guide, recordings, rules, sources, dvrSettings] = await Promise.all([ + source.getTvChannels(setup), source.getTvGuide(data.windowStart, data.windowStart + TV_WINDOW_SECONDS), + source.getTvRecordings(), source.getTvRules(), setup ? source.getTvSources() : Promise.resolve([]), + setup ? source.getTvDvrSettings?.() : undefined, + ]); + if (generation !== loadGeneration || !isCurrent() || app.bootstrap?.current_user?.id !== owner) return; + app.liveTv = { ...data, channels, programs: guide.programs, stations: guide.stations, guideSources:guide.sources, sourceGroups:guide.source_groups ?? [], providers:guide.providers ?? [], preferences:guide.preferences, recordings: recordings.recordings, conflicts: recordings.conflicts, canRecord: recordings.can_record, dvrSettings, rules, sources, error: undefined, loading: false }; + } catch (error) { + if (generation === loadGeneration && isCurrent() && app.bootstrap?.current_user?.id === owner) { + data.error = error instanceof Error ? error.message : 'Live TV could not be loaded.'; + data.loading = false; + } + } +} + +function value(form: FormData, name: string): string { return formDataString(form.get(name)).trim(); } +/** Read editable fields while retaining saved settings for inactive guide modes. */ +export function sourceConfig(form: FormData, original?: api.TvSourceConfig): api.TvSourceConfig { + const selection = value(form, 'guide_provider'); + const zap2it = selection ? JSON.parse(selection) as api.TvGuideSelection : undefined; + if (value(form, 'guide_kind') === 'zap2it' && (zap2it?.country !== value(form, 'guide_country') || zap2it.postal_code !== value(form, 'guide_postal').toUpperCase().replace(/ /g, ''))) throw new Error('Search for and select a Zap2It provider for this postal code.'); + const config:api.TvSourceConfig = { url: value(form, 'url'), pin: value(form, 'pin'), preserve_pin:value(form,'preserve_pin') === 'true', xmltv_url: value(form, 'xmltv_url'), guide_kind: value(form, 'guide_kind'), artwork_provider:value(form,'artwork_provider'), + iptv_mode:value(form,'iptv_mode'),provider_grouping:form.has('provider_grouping'),bridge_capacity:Number(value(form,'bridge_capacity')), + zap2it: zap2it ? { ...zap2it, days: Number(value(form, 'guide_days')) } : undefined, refresh_hours: Number(value(form, 'refresh_hours')), + native_device: value(form, 'native_device'), delivery_system: value(form, 'delivery_system'), region: value(form, 'region'), tuning_options: value(form, 'tuning_options'), + adapter: Number(value(form, 'adapter')), frontend: Number(value(form, 'frontend')), zap_path: value(form, 'zap_path'), + multiplex_urls: value(form, 'multiplex_urls').split(/\r?\n/).map(s => s.trim()).filter(Boolean) }; + const saved = original as Record | undefined; + const merged = config as unknown as Record; + for (const key of Object.keys(saved ?? {})) { + if (key==='provider_grouping'&&form.has('provider_grouping_present')) continue; + if (key === 'zap2it' ? !selection : !form.has(key)) merged[key]=saved![key]; + } + return config; +} +function padding(form: FormData): { padding_before: number; padding_after: number } { + return { padding_before: Number(value(form, 'before')) * 60, padding_after: Number(value(form, 'after')) * 60 }; +} + +interface Context { navigateTo: (path: string) => void; render: () => void; renderTvSelection?: () => void } +/** Stop TV streams when leaving Live TV or changing accounts, including minimized playback. */ +export function syncLiveTvAccount(): void { + syncTvDialogs(); + if (state.route.page !== 'live-tv' || guideClockOwner !== state.bootstrap?.current_user?.id) { nextGuideRefreshAt = 0; guideClockOwner = undefined; } + const tv = state.activeTvPlayback; + if (tv && (tv.owner !== state.bootstrap?.current_user?.id || state.route.page !== 'live-tv')) stopTvPlayback(); +} + +function advanceGuideWindow(context: Context): void { + const data = state.liveTv; + if (!data) return; + const start = currentGuideWindow(data.windowStart); + if (start === data.windowStart) return; + const scroll = document.querySelector('#tv-guide-scroll'); + const width = scroll?.querySelector('.tv-guide-time-track')?.getBoundingClientRect().width ?? 0; + if (scroll) scroll.scrollLeft = Math.max(0,scroll.scrollLeft-(start-data.windowStart)/TV_WINDOW_SECONDS*width); + data.windowStart = start; + // Reclip cached listings immediately, including while the guide request is slow. + context.render(); +} + +function bindGuideClock(signal: AbortSignal, owner: number | undefined, reload: () => Promise): void { + if (isLiveTvSettings(state.route)) return; + const now = Date.now(); + if (guideClockOwner !== owner || !nextGuideRefreshAt) { guideClockOwner = owner; nextGuideRefreshAt = now+60000; } + const slotExpires = (currentGuideWindow()+TV_SLOT_SECONDS)*1000+25; + // A fixed deadline survives focus-triggered redraws; resetting a full minute + // on every render would postpone both guide refreshes and half-hour rollover. + const timer = globalThis.setTimeout(() => { + nextGuideRefreshAt = Date.now()+60000; + void reload(); + },Math.max(0,Math.min(60000,nextGuideRefreshAt-now,slotExpires-now))); + signal.addEventListener('abort',() => globalThis.clearTimeout(timer),{once:true}); +} + +function play(kind: 'channels' | 'recordings', id: number): void { + const data = state.liveTv; + if (!data || data.owner !== state.bootstrap?.current_user?.id) return; + if (kind === 'channels') { + const channel = data.channels.find(c => c.id === id); + if (!channel) return; + data.selectedChannelId = id; data.selectedProgramId = undefined; + void startTvPlayback(tvChannelPlayback(channel)); + return; + } + const recording = data.recordings.find(r => r.id === id); + const channel = data.channels.find(c => c.id === recording?.channel_id); + const title = recording?.title; + if (!title) return; + const image = tvImage(recording?.metadata.image); + const logo = channel ? channelArtwork(channel,data) : undefined; + const logoFallback = channel ? channelArtworkFallback(channel,data) : undefined; + void startTvPlayback({kind,id,title,image,logo,logoFallback,url:api.tvStreamUrl(kind,id)}); +} +/** Bind route controls once per render and cancel listeners when the app replaces its DOM. */ +export function bindLiveTvActions(signal: AbortSignal, context: Context): void { + document.querySelectorAll('[data-nav-live-tv]').forEach(button => button.addEventListener('click', () => context.navigateTo('/live-tv'), { signal })); + syncLiveTvAccount(); + const setup = isLiveTvSettings(state.route); + if ((!setup && state.route.page !== 'live-tv') || state.liveTv?.owner !== state.bootstrap?.current_user?.id) return; + const owner = state.liveTv?.owner; + const current = () => (setup ? isLiveTvSettings(state.route) : state.route.page === 'live-tv') && state.bootstrap?.current_user?.id === owner; + const reload = async () => { + if (!current()) return; + advanceGuideWindow(context); + await loadLiveTv(state, current); + if (current()) context.render(); + }; + const run = async (operation: () => Promise, button?: HTMLButtonElement) => { + if (button) button.disabled = true; + try { + await operation(); + if (current() && state.liveTv) state.liveTv.error = undefined; + if (current()) await reload(); + } catch (error) { + if (current() && state.liveTv) { state.liveTv.error = error instanceof Error ? error.message : 'The TV operation failed.'; context.render(); } + } finally { if (button) button.disabled = false; } + }; + document.querySelector('[data-tv-refresh]')?.addEventListener('click', () => { void reload(); }, { signal }); + document.querySelectorAll('[data-tv-tab]').forEach(button => button.addEventListener('click', () => { + if (!state.liveTv) return; + state.liveTv.tab = button.dataset.tvTab as LiveTvState['tab']; context.render(); void reload(); + }, { signal })); + document.querySelectorAll('[data-tv-window]').forEach(button => button.addEventListener('click', () => { + if (!state.liveTv) return; + const step = Number(button.dataset.tvWindow); + state.liveTv.windowStart = currentGuideWindow(step ? state.liveTv.windowStart + step * TV_WINDOW_SECONDS : 0); + const scroll = document.querySelector('#tv-guide-scroll'); if (scroll) scroll.scrollLeft = 0; + void reload(); + }, { signal })); + document.querySelector('#tv-filter-form')?.addEventListener('submit', event => { + event.preventDefault(); if (!state.liveTv) return; + const form = new FormData(event.currentTarget as HTMLFormElement); + state.liveTv.query = value(form, 'query'); state.liveTv.group = value(form, 'group'); context.render(); + }, { signal }); + bindPlayback(signal,()=>{void reload();}); + bindTvGuideActions(signal, context, run); + bindTvGuideViewport(signal); + bindRecordingForms(signal, run); + bindRecordingButtons(signal, run); + if (setup && state.bootstrap?.current_user?.admin) bindSetup(signal, run, context); + bindGuideClock(signal,owner,reload); +} + +type Run = (operation: () => Promise, button?: HTMLButtonElement) => Promise; + +function bindGuideChoices(signal: AbortSignal): void { + document.querySelectorAll('select[name="guide_kind"]').forEach(select => { + const update = () => select.closest('form')?.querySelectorAll('[data-tv-guide-option]').forEach(fieldset => { + fieldset.hidden = fieldset.dataset.tvGuideOption !== select.value; + fieldset.disabled = fieldset.hidden; + }); + update(); select.addEventListener('change', update, { signal }); + }); +} + +function loadTunerRegions(signal: AbortSignal): void { + const selects = [...document.querySelectorAll('[data-tv-frequency-region]')]; + if (!selects.length || state.liveTv?.regions?.length) return; + void api.getTvTunerRegions().then(regions => { + if (signal.aborted || !state.liveTv) return; + state.liveTv.regions = regions; + for (const select of selects) { + const saved = select.value; + select.replaceChildren(); + for (const region of regions) { const option = document.createElement('option'); option.value = region; option.textContent = region; option.selected = saved ? region === saved : region.toLowerCase() === 'north-america'; select.append(option); } + if (saved && !regions.includes(saved)) {const option=document.createElement('option');option.value=saved;option.textContent=saved;option.selected=true;select.append(option);} + select.disabled = false; + } + }).catch((error: unknown) => { + if (signal.aborted) return; + document.querySelectorAll('[data-tv-regions-status]').forEach(status => { status.textContent = error instanceof Error ? error.message : 'Unable to load frequency regions.'; }); + }); +} + +function bindPlayback(signal: AbortSignal, changed:()=>void): void { + const watch=(id:number)=>play('channels',id); + bindHoldActions('[data-tv-guide-activate]',button=>watch(Number(button.dataset.tvSelectChannel)),button=>openTvGuideOptions(Number(button.dataset.tvSelectChannel),Number(button.dataset.tvSelectProgram)||undefined,{play:watch,changed}),signal); + document.addEventListener('click', event => { + const button = (event.target as Element | null)?.closest('[data-tv-play], [data-tv-recording-play], [data-tv-record-options]'); + if (button?.dataset.tvPlay) play('channels',Number(button.dataset.tvPlay)); + else if (button?.dataset.tvRecordingPlay) play('recordings',Number(button.dataset.tvRecordingPlay)); + else if(button?.dataset.tvRecordOptions)openTvRecordingOptions(Number(button.dataset.tvRecordOptions),Number(button.dataset.tvRecordProgram),changed); + },{signal}); +} +function bindRecordingForms(signal: AbortSignal, run: Run): void { + document.querySelector('#tv-manual-record-form')?.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(event.currentTarget as HTMLFormElement); + void run(() => api.scheduleTvRecording({ channel_id: Number(value(input, 'channel')), title: value(input, 'title'), starts_at: Math.floor(new Date(value(input, 'start')).getTime() / 1000), ends_at: Math.floor(new Date(value(input, 'end')).getTime() / 1000), series: false, new_only: false, private: input.has('private'), ...padding(input) })); + }, { signal }); +} +function bindRecordingButtons(signal: AbortSignal, run: Run): void { + const actions = [ + { selector: '[data-tv-cancel]', key: 'tvCancel', action: api.cancelTvRecording }, + { selector: '[data-tv-delete]', key: 'tvDelete', action: api.deleteTvRecording }, + { selector: '[data-tv-delete-rule]', key: 'tvDeleteRule', action: api.deleteTvRule }, + { selector: '[data-tv-source-refresh]', key: 'tvSourceRefresh', action: api.refreshTvSource }, + { selector: '[data-tv-source-scan]', key: 'tvSourceScan', action: api.rescanTvTuner }, + ]; + for (const item of actions) document.querySelectorAll(item.selector).forEach(button => button.addEventListener('click', () => { void run(() => item.action(Number(button.dataset[item.key])), button); }, { signal })); +} +function bindSetup(signal: AbortSignal, run: Run, context: Context): void { + hydrateTvConnections(); + bindTvSourceSettings(signal,context,run); + document.querySelector('#tv-source-kind')?.addEventListener('change', event => { + if (state.liveTv) { state.liveTv.setupKind = (event.currentTarget as HTMLSelectElement).value; state.liveTv.setupAdding = true; } + context.render(); + }, { signal }); + document.querySelector('#tv-add-source-panel')?.addEventListener('toggle', event => { + if (state.liveTv) state.liveTv.setupAdding = (event.currentTarget as HTMLDetailsElement).open; + }, { signal }); + bindGuideChoices(signal); + bindTvGuideMappings(document, signal, state.liveTv?.stations ?? []); + bindTvDiscovery(signal); + loadTunerRegions(signal); + document.querySelector('#tv-dvr-settings')?.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(event.currentTarget as HTMLFormElement); + void run(() => api.setTvDvrSettings(value(input, 'recording_dir'))); + }, { signal }); + document.querySelectorAll('[data-tv-permission]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvRecordingPermission(Number(form.dataset.tvPermission), input.has('can_record'))); + }, { signal })); + document.querySelectorAll('[data-tv-provider-search]').forEach(button => button.addEventListener('click', () => { + const form = button.closest('form'); if (!form) return; + const input = new FormData(form); const country = value(input, 'guide_country'); const postal = value(input, 'guide_postal').toUpperCase().replace(/ /g, ''); + const select = form.querySelector('[name="guide_provider"]'); const status = form.querySelector('[data-tv-provider-status]'); + if (!select || !status) return; + button.disabled = true; status.textContent = 'Finding providers…'; select.replaceChildren(); + void api.findTvGuideProviders(country, postal).then(providers => { + if (signal.aborted) return; + for (const provider of providers) { + const option = document.createElement('option'); + const location = provider.location ? ' · ' + provider.location : ''; + option.textContent = `${provider.type} · ${provider.name}${location}`; + option.value = JSON.stringify({ country, postal_code: postal, lineup_id: provider.lineupId, headend_id: provider.headendId, device: provider.device, timezone: provider.timezone }); select.append(option); + } + status.textContent = providers.length ? 'Select your provider, then save the source.' : 'No providers found.'; + }).catch((error: unknown) => { if (!signal.aborted) status.textContent = error instanceof Error ? error.message : 'Provider lookup failed.'; }).finally(() => { button.disabled = false; }); + }, { signal })); + document.querySelectorAll('[data-tv-replace-source]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(async () => { + const id=Number(form.dataset.tvReplaceSource); + await api.replaceTvSource(id, { name: value(input, 'name'), kind: value(input, 'kind'), capacity: Number(value(input, 'capacity')), config: sourceConfig(input,state.liveTv?.connections?.[id]?.source.config) }); + if (state.liveTv) {delete state.liveTv.connections?.[id];delete state.liveTv.connectionDrafts?.[id];state.liveTv.connectionOpen=undefined;} + }, (event as SubmitEvent).submitter as HTMLButtonElement | undefined); + }, { signal })); + document.querySelector('#tv-source-form')?.addEventListener('submit', event => { + event.preventDefault(); const form = event.currentTarget as HTMLFormElement; + const input = new FormData(form); + void run(async () => { + const result = await api.addTvSource({ name: value(input, 'name'), kind: value(input, 'kind'), capacity: Number(value(input, 'capacity')), config: sourceConfig(input) }); + form.reset(); + if (state.liveTv) state.liveTv.setupAdding = false; + if (state.liveTv) state.liveTv.message = result.refresh_error ?? 'TV source added. Channels and guide will refresh in the background.'; + }, (event as SubmitEvent).submitter as HTMLButtonElement | undefined); + }, { signal }); + document.querySelectorAll('[data-tv-source-update]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvSource({ id: Number(form.dataset.tvSourceUpdate), enabled: input.has('enabled'), capacity: Number(value(input, 'capacity')) })); + }, { signal })); + document.querySelectorAll('[data-tv-channel-update]').forEach(form => form.addEventListener('submit', event => { + event.preventDefault(); const input = new FormData(form); + void run(() => api.setTvChannel(Number(form.dataset.tvChannelUpdate), input.has('enabled'), value(input, 'guide') || null)); + }, { signal })); + document.querySelectorAll('[data-tv-guide-upload]').forEach(input => input.addEventListener('change', () => { + const file = input.files?.[0]; if (file) void run(() => api.uploadTvGuide(Number(input.dataset.tvGuideUpload), file)); + }, { signal })); +} diff --git a/crates/client-web/src/app/liveTvDialogs.ts b/crates/client-web/src/app/liveTvDialogs.ts new file mode 100644 index 00000000..166cea37 --- /dev/null +++ b/crates/client-web/src/app/liveTvDialogs.ts @@ -0,0 +1,89 @@ +/** Accessible Live TV action and recording dialogs that preserve drafts across guide refreshes. */ +import { createIcons, icons } from 'lucide'; +import { scheduleTvRecording, setTvFavorite, type TvChannel, type TvProgram } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; +import { channelPrograms, tvTime } from './liveTvGuideModel'; +import { state } from './state'; +import { renderButtonContent, renderIcon } from './ui'; + +interface Actions {play:(id:number)=>void;changed:()=>void} +interface OpenDialog {dialog:HTMLDialogElement;owner:number;close:()=>void} +let active:OpenDialog|undefined; + +/** Close account-scoped options when navigation or sign-in changes, without losing refresh drafts. */ +export function syncTvDialogs():void { + if(active&&(state.route.page!=='live-tv'||state.bootstrap?.current_user?.id!==active.owner))active.close(); +} + +function selection(channelId:number,programId?:number):{channel:TvChannel;program?:TvProgram}|undefined { + const data=state.liveTv; + if(!data||data.owner!==state.bootstrap?.current_user?.id||state.route.page!=='live-tv')return; + const channel=data.channels.find(c=>c.id===channelId);if(!channel)return; + const now=Date.now()/1000,program=programId?data.programs.find(p=>p.id===programId&&p.source_id===channel.source_id):channelPrograms(channel,data.programs).find(p=>p.starts_at<=now&&p.ends_at>now); + return {channel,program}; +} + +function openDialog(title:string,channel:TvChannel,program:TvProgram|undefined,content:string):OpenDialog|undefined { + const owner=state.bootstrap?.current_user?.id;if(owner==null)return; + active?.close(); + const previous=document.activeElement as HTMLElement|null; + const dialog=document.createElement('dialog');dialog.className='tv-program-dialog';dialog.id='tv-program-dialog'; + dialog.setAttribute('aria-labelledby','tv-dialog-title'); + const details=program?`

${escape(program.title)}

${escape(tvTime(program.starts_at,true))} – ${escape(tvTime(program.ends_at))}

`:''; + dialog.innerHTML=`

${escape(channel.number)} · ${escape(channel.name)}

${escape(title)}

${details}
${content}`; + const events=new AbortController();let closed=false; + const close=()=>{ + if(closed){return;}closed=true;events.abort();dialog.close();dialog.remove(); + if(active?.dialog===dialog)active=undefined; + if(state.route.page!=='live-tv'||state.bootstrap?.current_user?.id!==owner)return; + const replacement=previous?.id?document.getElementById(previous.id):null; + const restore=previous?.isConnected?previous:replacement; + (restore??document.querySelector('#tv-guide-scroll'))?.focus({preventScroll:true}); + }; + dialog.addEventListener('cancel',event=>{event.preventDefault();close();},{signal:events.signal}); + dialog.addEventListener('close',close,{signal:events.signal}); + dialog.addEventListener('click',event=>{ + if((event.target as Element).closest('[data-tv-dialog-close]')){close();return;} + const box=dialog.getBoundingClientRect(); + if(event.target===dialog&&(event.clientXbox.right||event.clientYbox.bottom))close(); + },{signal:events.signal}); + globalThis.addEventListener('popstate',close,{signal:events.signal}); + active={dialog,owner,close};document.body.append(dialog);createIcons({icons,root:dialog});dialog.showModal(); + dialog.querySelector('[data-tv-dialog-close]')?.focus(); + return active; +} + +async function perform(open:OpenDialog,operation:()=>Promise,changed:()=>void):Promise { + if(open.dialog.dataset.busy||state.bootstrap?.current_user?.id!==open.owner)return; + open.dialog.dataset.busy='true'; + const buttons=open.dialog.querySelectorAll('button:not([data-tv-dialog-close])');buttons.forEach(b=>{b.disabled=true;}); + try{await operation();open.close();changed();} + catch(error){const notice=open.dialog.querySelector('[role="alert"]')!;notice.textContent=error instanceof Error?error.message:'The TV operation failed.';notice.hidden=false;} + finally{delete open.dialog.dataset.busy;buttons.forEach(b=>{b.disabled=false;});} +} + +/** Offer padding, series rules, and shared/private ownership on an opaque modal surface. */ +export function openTvRecordingOptions(channelId:number,programId:number,changed:()=>void):void { + const selected=selection(channelId,programId),program=selected?.program; + if(!selected||!program||program.ends_at<=Date.now()/1000||state.liveTv?.canRecord===false)return; + const open=openDialog('Record program or series',selected.channel,program,`

Recordings are shared with all accounts unless you choose Private.

`); + if(!open)return; + const form=open.dialog.querySelector('form')!; + form.addEventListener('submit',event=>{ + event.preventDefault();if(!form.reportValidity())return; + const input=new FormData(form),series=(event.submitter as HTMLButtonElement|null)?.value==='series'; + void perform(open,()=>scheduleTvRecording({channel_id:channelId,title:program.title,starts_at:program.starts_at,ends_at:program.ends_at,series,new_only:input.has('new-only'),private:input.has('private'),padding_before:Number(input.get('before'))*60,padding_after:Number(input.get('after'))*60}),changed); + }); +} + +/** Hold or right-click a guide cell to access recording and favorite actions without tuning. */ +export function openTvGuideOptions(channelId:number,programId:number|undefined,actions:Actions):void { + const selected=selection(channelId,programId);if(!selected)return; + const {channel,program}=selected,favorite=state.liveTv?.preferences?.favorites.includes(channelId)===true; + const record=program&&program.ends_at>Date.now()/1000&&state.liveTv?.canRecord!==false?``:''; + const open=openDialog('Channel options',channel,program,`
${record}
`); + if(!open)return; + open.dialog.querySelector('[data-tv-option-watch]')?.addEventListener('click',()=>{open.close();actions.play(channelId);}); + open.dialog.querySelector('[data-tv-option-record]')?.addEventListener('click',()=>{open.close();if(program)openTvRecordingOptions(channelId,program.id,actions.changed);}); + open.dialog.querySelector('[data-tv-option-favorite]')?.addEventListener('click',()=>{void perform(open,()=>setTvFavorite(channelId,!favorite),actions.changed);}); +} diff --git a/crates/client-web/src/app/liveTvDiscovery.ts b/crates/client-web/src/app/liveTvDiscovery.ts new file mode 100644 index 00000000..eddcf60b --- /dev/null +++ b/crates/client-web/src/app/liveTvDiscovery.ts @@ -0,0 +1,95 @@ +/** Presents discovered TV servers and local tuners as selectable connection cards. */ +import * as api from '../liveTvApi'; +import { escapeHtml as escape } from './format'; + +interface Choice { name: string; address: string; details: string; capacity?: number } +interface DiscoveryForm { + form: HTMLFormElement; input: HTMLInputElement; results: HTMLElement; + status: HTMLElement; kind: string; signal: AbortSignal; suggestedName?: string; viewKey?: string; choices?: Choice[]; +} + +const forms = new WeakMap(); + +async function findChoices(kind: string): Promise { + if (kind === 'native') { + return (await api.discoverTvTuners()).map(device => ({ + name: device.name, address: device.id, details: `${device.info || 'Local tuner'} · 1 simultaneous stream`, + })); + } + const serverLabel = kind === 'nextpvr' ? 'NextPVR server' : 'Tvheadend server'; + return (await api.discoverTvSources(kind)).map(source => ({ + name: source.name, address: source.url, capacity: source.capacity, + details: kind === 'hdhomerun' ? `Tuners: ${source.capacity}` : serverLabel, + })); +} + +function useChoice(context: DiscoveryForm, choice: Choice): void { + const name = context.form.querySelector('[name="name"]'); + const capacity = context.form.querySelector('[name="capacity"]'); + context.input.value = choice.address; + if (name && (!name.value.trim() || name.value === context.suggestedName)) name.value = choice.name; + if (capacity && choice.capacity !== undefined) capacity.value = String(choice.capacity); + context.suggestedName = choice.name; + context.status.textContent = `${choice.name} selected. You can review its connection settings below.`; +} + +function showChoices(context: DiscoveryForm, choices: Choice[]): void { + context.choices = choices; + context.results.innerHTML = choices.map(choice => ``).join(''); + context.results.hidden = choices.length === 0; +} + +async function search(context: DiscoveryForm, button: HTMLButtonElement): Promise { + button.disabled = true; + context.results.hidden = true; + context.results.replaceChildren(); + context.status.textContent = 'Searching for devices…'; + try { + const choices = await findChoices(context.kind); + if (context.signal.aborted) return; + showChoices(context, choices); + const noun = context.kind === 'native' ? 'tuner' : 'source'; + const foundLabel = choices.length === 1 ? noun : `${noun}s`; + context.status.textContent = choices.length + ? `${choices.length} ${foundLabel} found. Select a card to use that device.` + : `No ${noun}s found. Check the device and connection, or enter its location manually below.`; + } catch (error) { + if (!context.signal.aborted) context.status.textContent = error instanceof Error ? error.message : 'Discovery failed. Enter the location manually.'; + } finally { + button.disabled = false; + } +} + +function bindForm(form: HTMLFormElement, signal: AbortSignal): void { + const kind = form.dataset.tvSourceKind ?? ''; + const button = form.querySelector('[data-tv-discover-source], [data-tv-discover-tuners]'); + const results = form.querySelector('[data-tv-discovery-results]'); + const status = form.querySelector('[data-tv-discovery-status], [data-tv-tuner-status]'); + const input = form.querySelector(kind === 'native' ? '[name="native_device"]' : '[name="url"]'); + if (!button || !results || !status || !input) return; + // Refresh the signal on a retained form so pending discovery can populate newly bound controls. + const fields = { form, input, results, status, kind, signal, viewKey: form.dataset.tvFormKey }; + const previous = forms.get(form); + const context: DiscoveryForm = previous?.kind === kind && previous.viewKey === fields.viewKey ? Object.assign(previous, fields) : fields; + forms.set(form, context); + results.addEventListener('change', event => { + const radio = (event.target as Element | null)?.closest('input[type="radio"]'); + const choice = radio?.checked ? context.choices?.find(item => item.address === radio.value) : undefined; + if (choice) useChoice(context, choice); + }, { signal }); + button.addEventListener('click', () => { void search(context, button); }, { signal }); + input.addEventListener('input', () => { + const checked = results.querySelector('input:checked'); + if (!checked) return; + checked.checked = false; + status.textContent = 'Using the location entered manually below. Select a card to use a discovered device again.'; + }, { signal }); +} + +/** Bind card selection and discovery while retaining editable manual connection fields. */ +export function bindTvDiscovery(signal: AbortSignal): void { + document.querySelectorAll('[data-tv-source-kind]').forEach(form => bindForm(form, signal)); +} diff --git a/crates/client-web/src/app/liveTvGuide.css b/crates/client-web/src/app/liveTvGuide.css new file mode 100644 index 00000000..e228d073 --- /dev/null +++ b/crates/client-web/src/app/liveTvGuide.css @@ -0,0 +1,157 @@ +/** Shared TV timeline, program hero, and the minimized shared video player. */ +.tv-program-dialog { width:min(38rem,calc(100vw - 2rem));max-height:calc(100dvh - 2rem);margin:auto;padding:1.5rem;overflow:auto;border:1px solid var(--surface-raised);border-radius:var(--radius-card);background:var(--surface-panel);color:inherit;font:inherit;box-shadow:0 1rem 4rem #0008; } +.tv-program-dialog::backdrop { background:#000b;backdrop-filter:blur(3px); } +.tv-program-dialog header { display:flex;align-items:start;justify-content:space-between;gap:1rem; } +.tv-program-dialog header h2 { margin:.25rem 0;font-size:1.35rem; } +.tv-program-dialog header .eyebrow { margin:0;color:var(--text-muted); } +.tv-program-dialog header button { flex:none;padding:.5rem; } +.tv-program-dialog button { min-height:44px; } +.tv-program-dialog button svg { width:1.2rem;height:1.2rem; } +.tv-dialog-program { margin:1rem 0; } +.tv-dialog-program h3 { margin:0 0 .4rem;font-size:1.1rem; } +.tv-dialog-program p { color:var(--text-muted);margin:.25rem 0; } +.tv-dialog-actions,.tv-record-form { display:grid;gap:1rem; } +.tv-dialog-actions button { display:flex;align-items:center;justify-content:center;gap:.5rem; } +.tv-record-padding { display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem; } +.tv-record-form label { display:grid;gap:.5rem; } +.tv-record-form input[type="number"] { min-width:0;width:100%;padding:.75rem;border:1px solid var(--surface-raised);border-radius:.5rem;background:var(--surface-base);color:inherit;font:inherit; } +.tv-record-form .tv-dialog-checkbox { display:flex;align-items:center;gap:.6rem; } +.tv-dialog-checkbox input { flex:none;width:1.1rem;height:1.1rem;margin:0;accent-color:var(--accent); } +.tv-record-form p { margin:0;color:var(--text-muted);font-size:.9rem; } +.tv-program-dialog footer { display:flex;justify-content:flex-end;flex-wrap:wrap;gap:.75rem;margin-top:.5rem; } +.tv-program-dialog [hidden] { display:none; } +.tv-guide-toolbar, #tv-filter-form, .tv-guide-filters, .tv-guide-views { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem; } +.tv-hero-channel { display: flex; align-items: center; gap: .75rem; } +.tv-artwork-attribution { display: flex; align-items: center; gap: .5rem; font-size: .75rem; color: var(--text-muted); margin-top: .5rem; } +.tv-artwork-attribution img { max-width: 70px; max-height: 22px; } +.tv-hero-channel-logo { width: 52px; max-height: 40px; object-fit: contain; flex: none; } +.home-feature img[hidden] { display: none; } +.tv-guide-toolbar { justify-content: space-between; margin: 1rem 0; } +.tv-guide-toolbar form { min-width: 0; } +.tv-guide-toolbar input { width: 230px; } +.tv-guide-filters, .tv-guide-views { margin: .75rem 0; } +.tv-guide-filters button, .tv-guide-views button, .tv-source-rail button { background: var(--surface-panel); color: var(--text-muted); border: 1px solid var(--surface-raised); } +.tv-guide-filters button[aria-pressed="true"], .tv-guide-views button[aria-pressed="true"], .tv-source-rail button[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } +.tv-guide-layout { display: grid; min-width: 0; } +.tv-guide-layout.has-source-rail { grid-template-columns: 10rem minmax(0, 1fr); gap: 1rem; } +.tv-source-rail { display: flex; flex-direction: column; gap: .5rem; max-height:min(580px,max(160px,calc(100dvh - var(--home-pinned-header-height,0px) - 130px)));overflow-y:auto;align-self:start; } +.tv-source-rail button { padding: .75rem; text-align: left; display: flex;align-items:center;gap:.75rem;justify-content:space-between;overflow-wrap:anywhere;flex-shrink:0; } +.tv-source-count { font-size:1.1rem;font-weight:700;font-variant-numeric:tabular-nums; } +.browse-workspace .tv-source-rail button:focus-visible,body[data-input-mode='gamepad'] .browse-workspace .tv-source-rail button:focus { outline-offset:-3px;box-shadow:inset 0 0 0 4px var(--surface-base); } +.tv-source-rail small { font-weight: 400; } +.tv-guide-board { min-width: 0; } +.tv-guide-scroll { --tv-channel-width: 220px; --tv-timeline-width: 1440px; max-height: min(580px, max(160px, calc(100dvh - var(--home-pinned-header-height, 0px) - 130px))); overflow: auto; border: 1px solid var(--surface-raised); border-radius: 10px; scrollbar-gutter: stable; overflow-anchor: none; } +.tv-guide-grid { position: relative; width: calc(var(--tv-channel-width) + var(--tv-timeline-width)); isolation: isolate; } +.tv-guide-row, .tv-guide-time-row { display: grid; grid-template-columns: var(--tv-channel-width) var(--tv-timeline-width); } +.tv-guide-row { height: 88px; } +.tv-guide-time-row { position: sticky; top: 0; z-index: 6; height: 48px; background: var(--surface-base); border-bottom: 1px solid var(--surface-raised); } +.tv-guide-corner { position: sticky; left: 0; display: flex; align-items: center; min-width: 0; background: var(--surface-base); padding: 0 1rem; z-index: 7; font-size: .85rem; border-right: 1px solid var(--surface-raised); } +.tv-guide-time-track { display: grid; grid-template-columns: repeat(12, 1fr); } +.tv-guide-tick { display: flex; align-items: center; min-width: 0; padding: 0 .75rem; border-left: 1px solid var(--surface-raised); font-size: .85rem; white-space: nowrap; } +.tv-guide-tick:first-child { border-left: 0; } +.tv-guide-channel { position: sticky; left: 0; z-index: 4; min-height: 88px; border-bottom: 1px solid var(--surface-raised); border-right: 1px solid var(--surface-raised); background: var(--surface-panel); padding: .5rem; display: flex; align-items: center; gap: .25rem; } +.tv-channel-select { flex: 1; min-width: 0; display: flex; align-items: center; gap: .5rem; background: none; color: inherit; padding: .25rem; text-align: left; } +.tv-guide-program.is-holding,.tv-channel-select.is-holding { box-shadow:inset 0 0 0 2px var(--accent-soft); } +.tv-channel-select > .tv-channel-logo-slot { display: flex; align-items: center; justify-content: center; width: 72px; height: 60px; flex: 0 0 72px; } +.tv-channel-logo-slot img { width: 100%; max-height: 100%; object-fit: contain; } +.tv-channel-logo-slot img[hidden], .player-channel-logo[hidden] { display: none; } +.tv-channel-select > span { display: grid; gap: .25rem; min-width: 0; font-size: .8rem; } +.tv-channel-select > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tv-channel-actions { display: flex; flex-direction: column; gap: .25rem; } +.tv-channel-actions button { padding: .25rem; background: none; color: var(--text-muted); } +.tv-channel-actions button svg { width: 16px; height: 16px; } +.tv-channel-actions [aria-pressed="true"] { color: var(--accent); } +.tv-channel-actions [aria-pressed="true"] svg { fill: currentColor; } +.tv-guide-track { position: relative; height: 88px; background: repeating-linear-gradient(to right, transparent, transparent 119px, var(--surface-raised) 119px, var(--surface-raised) 120px); border-bottom: 1px solid var(--surface-raised); } +.tv-guide-program { position: absolute; top: 0; bottom: 0; display: flex; flex-direction: column; align-items: start; justify-content: start; gap: .35rem; padding: .65rem .75rem; overflow: hidden; text-align: left; background: var(--surface-panel); color: inherit; border: 0; border-right: 1px solid var(--surface-raised); border-radius: 0; } +.tv-guide-program strong { font-size: .85rem; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tv-guide-subtitle { font-size: .75rem; font-weight: 400; color: var(--text-muted); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tv-guide-program.is-on-air { background: color-mix(in srgb, var(--accent) 8%, var(--surface-panel)); } +.browse-workspace .tv-guide-program[aria-pressed="true"], .browse-workspace .tv-guide-program:focus-visible, body[data-input-mode='gamepad'] .browse-workspace .tv-guide-program:focus { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: -2px; z-index: 1; } +.tv-guide-program.is-dimmed { opacity: .4; } +.tv-guide-badges { display: flex; gap: .25rem; flex-wrap: nowrap; } +.tv-program-badge { font-size: .65rem; font-weight: 700; border-radius: 3px; padding: .15rem .3rem; background: var(--surface-raised); color: var(--accent); white-space: nowrap; } +.tv-badge-live { color: #79cfff; } +.tv-badge-premiere, .tv-badge-finale { color: #ffdb80; } +.tv-guide-no-listings { display: block; padding: 1.5rem; color: var(--text-muted); font-size: .85rem; } +.tv-guide-now { position: absolute; top: 0; bottom: 0; z-index: 3; width: 2px; background: #f17c86; pointer-events: none; } +.tv-guide-now > span { display: block; position: sticky; top: 1px; font-size: .65rem; background: #f17c86; color: #121621; width: max-content; padding: .15rem .25rem; } +.tv-guide-hint, .tv-guide-empty { color: var(--text-muted); font-size: .85rem; padding: .75rem; } +.home-feature-media .player-overlay.tv-preview-player { position: absolute; inset: 0; z-index: 0; padding: 0; margin: 0; border-radius: 0; overflow: hidden; background: #000; pointer-events: none; mask-image: linear-gradient(to right, transparent, black 18%); } +.tv-preview-player .media-player-shell video { object-fit: cover; } +.tv-preview-player .player-shell { width: 100%; height: 100%; max-width: none; min-height: 0; border: 0; border-radius: 0; } +.tv-preview-player .player-title-block h2 { font-size: .85rem; } +.tv-preview-player .player-title-block .eyebrow { display: none; } +.tv-preview-player .player-top-controls { padding: .5rem; } +.tv-preview-player .player-bottom-controls { padding: .4rem; } +.tv-preview-player .player-icon-button { width: 30px; height: 30px; min-width: 30px; padding: .35rem; } +.tv-preview-player .player-volume, .tv-preview-player .player-progress, .tv-preview-player [data-player-seek], .tv-preview-player .player-time-cluster { display: none; } +.player-channel-heading { display: flex; align-items: center; gap: .75rem; } +.player-channel-logo { width: 64px; max-height: 56px; object-fit: contain; flex: none; } +#player-keep-tv { color: #f17c86; } +#player-keep-tv svg { fill: currentColor; } +#player-keep-tv.is-recording { opacity: 1; } +#player-live-status[hidden] { display: none; } +.tv-player-guide[hidden], .tv-player-channel[hidden], .tv-player-channel-logo img[hidden], #player-tv-guide-empty[hidden] { display: none; } +#player-tv-program { max-width: 700px; margin-top: .6rem; } +#player-tv-program h3 { font-size: 1.4rem; margin: 0 0 .3rem; } +.tv-player-subtitle, .tv-player-description, .tv-player-meta { margin: .3rem 0; font-size: .9rem; } +.tv-player-meta { color: var(--text-muted); } +.tv-player-description { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.tv-player-guide { position: absolute; top: 0; right: 0; bottom: 0; z-index: 5; width: min(430px,36vw); display: flex; flex-direction: column; gap: .65rem; padding: 1rem; background: var(--surface-base); border-left: 1px solid var(--surface-raised); } +.tv-player-guide header { display: flex; align-items: center; justify-content: space-between; gap: .5rem; } +.tv-player-guide header h3 { margin: 0; } +.tv-player-guide label, .tv-player-guide input, .tv-player-guide select { width: 100%; min-width: 0; } +.tv-player-guide-channels { min-height: 0; overflow: auto; } +.tv-player-channel { width: 100%; display: flex; align-items: center; gap: .65rem; padding: .8rem .5rem; text-align: left; background: transparent; color: inherit; border-bottom: 1px solid var(--surface-raised); border-radius: 0; } +.tv-player-channel > span:last-child { display: grid; gap: .3rem; min-width: 0; } +.tv-player-channel span span, .tv-player-channel small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tv-player-channel small { color: var(--text-muted); } +.tv-player-channel small.tv-player-flags { color: var(--accent); } +.tv-player-channel[aria-pressed="true"] { background: color-mix(in srgb,var(--accent) 12%,var(--surface-panel)); } +.media-player-overlay .tv-player-channel:focus-visible, body[data-input-mode='gamepad'] .media-player-overlay .tv-player-channel:focus { outline-offset: -3px; } +.tv-player-channel-logo { width: 45px; height: 40px; flex: 0 0 45px; display: flex; align-items: center; justify-content: center; } +.tv-player-channel-logo img { width: 100%; max-height: 100%; object-fit: contain; } +.media-player-shell.has-tv-guide video { width: calc(100% - min(430px,36vw)); } +.has-tv-guide .player-top-controls, .has-tv-guide .player-bottom-controls { right: min(430px,36vw); } +.has-tv-guide .player-control-row { grid-template-columns: minmax(0,1fr) auto minmax(0,1fr); gap: .65rem; } +.has-tv-guide .player-time-cluster { justify-content: flex-start; } +.has-tv-guide .player-transport-cluster { justify-content: center; } +.has-tv-guide .player-tool-cluster { grid-column: 1 / -1; justify-content: center; flex-wrap: wrap; } +.tv-preview-player .player-control-row { justify-content: space-between; } +@media (max-width: 850px) { + .tv-player-guide { top: 40%; bottom: var(--tv-player-controls-height,210px); width: 100%; border-left: 0; border-top: 1px solid var(--surface-raised); padding: .5rem; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: auto auto minmax(0,1fr); gap: .35rem; } + .tv-player-guide header, .tv-player-guide-channels, #player-tv-guide-empty { grid-column: 1 / -1; } + .tv-player-guide input, .tv-player-guide select { padding: .45rem .6rem; font-size: .8rem; } + .media-player-shell.has-tv-guide video { width: 100%; height: 40%; } + .has-tv-guide .player-top-controls, .has-tv-guide .player-bottom-controls { right: 0; } + .has-tv-guide .player-control-row { grid-template-columns: 1fr; } + .has-tv-guide .player-time-cluster, .has-tv-guide .player-transport-cluster { justify-content: center; } + #player-tv-program .tv-player-description { display: none; } + .tv-guide-layout.has-source-rail { grid-template-columns: minmax(0, 1fr); } + .tv-source-rail { flex-direction: row; flex-wrap: wrap; } + .tv-source-rail button { padding: .5rem .75rem; } + .tv-guide-scroll { --tv-channel-width: 180px; } + .tv-channel-select > .tv-channel-logo-slot { width: 52px; height:52px;flex-basis:52px; } + .tv-guide-toolbar input { width: 100%; } + .tv-guide-toolbar form { width: 100%; } +} +.tv-provider-tabs { display:flex;flex-direction:column;gap:.4rem;margin-left:.6rem;padding:0 0 .5rem .6rem;border-left:2px solid var(--surface-raised); } +.tv-source-label,.tv-provider-label { color:var(--text-muted);font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;padding:.25rem; } +.tv-source-label { width:100%; } +.tv-provider-tabs button { display:flex;gap:.65rem;align-items:center;flex-shrink:0;background:var(--surface-panel);color:var(--text-muted);border:1px solid var(--surface-raised); } +.tv-provider-tabs button[aria-pressed="true"] { border-color:var(--accent);color:var(--accent); } +.tv-provider-tabs button > span:last-child { display:grid;gap:.25rem;text-align:left; min-width:0;overflow-wrap:anywhere; } +.tv-provider-tabs small { color:var(--text-muted);font-weight:400; } +.tv-provider-tabs .tv-source-icon { width:2.5rem;height:2.5rem;flex-basis:2.5rem; } +.tv-source-rail .tv-provider-tabs button { display:flex;padding:.5rem;gap:.5rem;background:var(--surface-base);border-radius:.6rem; } +.tv-source-rail .tv-provider-tabs .tv-source-icon { width:3rem;height:3rem;flex-basis:3rem; } +.tv-source-rail .tv-source-icon { width:4rem;height:4rem;flex-basis:4rem; } +.tv-source-rail .tv-type-symbol svg { width:2.5rem;height:2.5rem; } +.tv-guide-lineup { display:flex;align-items:center;gap:.75rem;margin-bottom:.75rem; } +@media(max-width:700px) { + .tv-source-rail { max-height:180px; } + .tv-provider-tabs { flex-direction:row;flex-wrap:wrap;width:calc(100% - .6rem); } + .tv-provider-label { width:100%; } + .tv-source-rail .tv-provider-tabs button { flex-shrink:0; } +} diff --git a/crates/client-web/src/app/liveTvGuide.ts b/crates/client-web/src/app/liveTvGuide.ts new file mode 100644 index 00000000..3dcc86ba --- /dev/null +++ b/crates/client-web/src/app/liveTvGuide.ts @@ -0,0 +1,140 @@ +/** Renders the TV hero and a single scrollable, time-aligned electronic program guide. */ +import type { TvChannel, TvProgram } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; +import { state } from './state'; +import { renderButtonContent, renderIcon } from './ui'; +import { renderFeatureHero } from './featureHero'; +import { renderPlayerOverlay } from './playbackController'; +import type { LiveTvState } from './liveTvActions'; +import { activeGuideProvider, guideProviders, activeGuideView, channelArtwork, channelArtworkFallback, channelPrograms, guideSelection, guideViews, matchesTvCategory, programBadges, programPosition, tvImage, tvTime, TV_CATEGORIES, visibleGuideChannels } from './liveTvGuideModel'; +import { currentGuideWindow, TV_SLOT_SECONDS } from './tvGuideTime'; +import { renderMetadataSearchProviderAttribution } from './providers'; +import { tvGuideRowRange, tvGuideViewKey, TV_ROW_HEIGHT } from './tvGuideViewport'; +import { renderTvProviderIcon,renderTvSourceIcon,renderTvAllProvidersIcon } from './liveTvIcons'; + +function badges(program: TvProgram): string { + return programBadges(program.metadata).map(tag => `${tag}`).join(''); +} + +function channelLogo(channel: TvChannel, data: LiveTvState, hero = false): string { + const logo = channelArtwork(channel,data); + if (!logo) return ''; + const fallback = channelArtworkFallback(channel,data); + return `${hero ? escape(channel.name)+' logo' : ''}`; +} + +function recordingChoices(channel: TvChannel, program: TvProgram): string { + if (state.liveTv?.canRecord === false) return '

Recording is disabled for your account.

'; + return ``; +} + +function heroPlaybackActions(preview: boolean): string { + if (!preview) return ''; + return ``; +} + +function artworkAttribution(program: TvProgram | undefined): string { + const providers = program?.metadata.artwork_sources ?? []; + if (!providers.length) return ''; + return `
Artwork providers: ${providers.map(renderMetadataSearchProviderAttribution).join(' ')}
`; +} + +/** Display program artwork and descriptions, reserving the artwork area for minimized playback. */ +export function renderTvHero(data: LiveTvState): string { + const { channel, program } = guideSelection(data); + const fallbackImage = channel ? channelArtwork(channel,data) : ''; + const image = tvImage(program?.metadata.image) || fallbackImage; + const poster = tvImage(program?.metadata.poster) || tvImage(program?.metadata.image); + const now = Date.now() / 1000; + const onAir = program && program.starts_at <= now && program.ends_at > now; + const label = onAir ? 'On air now' : 'Program guide'; + const title = program?.title ?? channel?.name ?? 'Live TV'; + const description = program?.metadata.description ?? 'Choose a channel or program to see its details. Channels without listings can still be watched live.'; + const time = program ? `${tvTime(program.starts_at, true)} – ${tvTime(program.ends_at)} · ${Math.round((program.ends_at - program.starts_at)/60)} min` : ''; + const tags = [...(program?.metadata.categories ?? []), program?.metadata.rating, program?.metadata.episode].filter(Boolean).map(tag => `${escape(tag!)}`).join(''); + const channelLabel = channel ? ` · ${escape(channel.number)} ${escape(channel.name)}` : ''; + const subtitle = program?.metadata.subtitle ? `

${escape(program.metadata.subtitle)}

` : ''; + const watch = channel ? `` : ''; + const timeMarkup = time ? `${escape(time)}` : ''; + const preview = Boolean(state.activeTvPlayback && !state.isPlayerOpen); + const playbackActions = heroPlaybackActions(preview); + const logo = channel ? channelLogo(channel,data,true) : ''; + return renderFeatureHero({id:'tv-guide-hero',label:'Program details',image, + poster:poster ? {url:poster,label:`${title} poster`} : undefined, + copy:`
${logo}

${label}${channelLabel}

${escape(title)}

${subtitle} +

${escape(description)}

${program ? badges(program) : ''}${tags}${timeMarkup}
${artworkAttribution(program)}`, + actions:`
${playbackActions}${watch}${program && channel && program.ends_at > now ? recordingChoices(channel,program) : ''}
`, + media:{id:'tv-hero-media',active:preview,content:state.activeTvPlayback ? renderPlayerOverlay() : ''}, + }); +} + +function programCell(channel: TvChannel, program: TvProgram, data: LiveTvState): string { + const position = programPosition(program, data.windowStart); + if (!position) return ''; + const now = Date.now()/1000, onAir = program.starts_at <= now && program.ends_at > now; + const selected = data.selectedProgramId === program.id && data.selectedChannelId === channel.id; + const dimmed = !matchesTvCategory(program, data.category); + const subtitle = program.metadata.subtitle ? `${escape(program.metadata.subtitle)}` : ''; + return ``; +} + +function channelRow(channel: TvChannel, data: LiveTvState, index: number): string { + const favorite = data.preferences?.favorites.includes(channel.id) === true; + const programs = channelPrograms(channel, data.programs).map(p => programCell(channel, p, data)).join(''); + return `
+
+
+
${programs || 'No listings available · Watch this channel live'}
`; +} + +/** Render only the viewport's channel rows, with spacers for the remainder of the lineup. */ +export function renderTvGuideRows(data: LiveTvState, channels = visibleGuideChannels(data)): string { + const viewport = data.guideViewport?.key === tvGuideViewKey(data) ? data.guideViewport : undefined; + const {start,end} = tvGuideRowRange(channels.length,viewport?.top,viewport?.height); + const selection = guideSelection(data); + const selected = {...data,selectedProgramId:selection.program?.id,selectedChannelId:selection.channel?.id}; + return `${channels.slice(start,end).map((c,i)=>channelRow(c,selected,start+i)).join('')}`; +} + +/** Align channel rows beneath one sticky time header and one shared horizontal scrollbar. */ +export function renderTvGuide(data: LiveTvState): string { + const sources = guideViews(data), source = activeGuideView(data), channels = visibleGuideChannels(data); + const ids = new Set(source?.sourceIds); + const groups = [...new Set(data.channels.filter(c => ids.has(c.source_id)).map(c => c.group_name).filter(Boolean))].sort((a,b) => a.localeCompare(b)); + const providers=guideProviders(data),provider=activeGuideProvider(data); + const providerButtons=providers.map(p=>{ + const key=p.source_id+':'+p.id,count=data.channels.filter(c=>c.source_id===p.source_id&&c.provider_id===p.id&&c.enabled&&c.available).length; + const label=`${p.name}, ${count} ${count===1?'channel':'channels'}`; + return ``; + }).join(''); + const providerTabs=providers.length?'':''; + const sourceTabs = sources.map(s => { + const attribute = s.groupId ? 'data-tv-source-group="'+escape(s.groupId)+'"' : 'data-tv-source="'+s.sourceIds[0]+'"'; + const connection=data.guideSources?.find(source=>source.id===s.sourceIds[0]); + const children=s.key===source?.key?providerTabs:''; + const label=`${s.name}, ${s.count} ${s.count===1?'channel':'channels'}`; + return `${children}`; + }).join(''); + const hasRail=sources.length>1||providers.length>0; + const connection=data.guideSources?.find(s=>s.id===source?.sourceIds[0]); + const lineup=!hasRail&&source?'
'+renderTvSourceIcon(connection ?? {kind:'tv'})+''+escape(source.name)+'
':''; + const categories = TV_CATEGORIES.map(category => ``).join(''); + const ticks = Array.from({length:12}, (_,i) => `
${escape(tvTime(data.windowStart + i*TV_SLOT_SECONDS))}
`).join(''); + const now = (Date.now()/1000 - data.windowStart) / (6*3600) * 100; + const nowLine = now >= 0 && now < 100 ? `` : ''; + const views = [['all','All channels'],['favorites','Favorites'],['recent','Recently played']].map(([key,label]) => ``).join(''); + const filterCount = [data.query,data.group,data.category && data.category !== 'All programs',data.channelView && data.channelView !== 'all'].filter(Boolean).length; + const filterLabel = filterCount ? `Filters · ${filterCount} active` : 'Filters'; + return `
${escape(tvTime(data.windowStart,true))}
+
+
+
+
+ ${lineup}
${hasRail ? `` : ''} +
${channels.length ? `
+
Channels
${ticks}
${renderTvGuideRows(data,channels)}
${nowLine}
` + : '

No channels match this view. Try All channels or All programs, or add a TV source in Settings → Live TV.

'} +

Click or press Enter to watch. Hold or right-click for channel and recording options.

${data.category && data.category !== 'All programs' ? '

Channels with matching programs are shown. Other programs are dimmed to preserve the schedule.

' : ''}
`; +} diff --git a/crates/client-web/src/app/liveTvGuideActions.ts b/crates/client-web/src/app/liveTvGuideActions.ts new file mode 100644 index 00000000..3d666afa --- /dev/null +++ b/crates/client-web/src/app/liveTvGuideActions.ts @@ -0,0 +1,62 @@ +/** Binds guide selection, source navigation, categories, favorites, and recently played views. */ +import { setTvFavorite } from '../liveTvApi'; +import { state } from './state'; +import { guideSelection } from './liveTvGuideModel'; +import { recoverTvSourceImage } from './liveTvIcons'; + +/** Update the guide without restarting a playing or minimized TV stream. */ +export function bindTvGuideActions(signal: AbortSignal, context: {render: () => void;renderTvSelection?:()=>void}, run: (operation: () => Promise, button?: HTMLButtonElement) => Promise): void { + const recoverImage = (image: HTMLImageElement) => { + if (image.closest('#media-player-overlay')) return; + const fallback = () => { + const url = image.dataset.fallbackSrc; + if (url && image.getAttribute('src') !== url) image.src = url; + else image.hidden = true; + }; + fallback(); + }; + document.addEventListener('error',event=>{ + const image = event.target as HTMLImageElement; + if (image.matches?.('[data-tv-source-image]')) recoverTvSourceImage(image); + else if (image.matches?.('[data-tv-image], #tv-guide-hero .home-feature-poster')) recoverImage(image); + },{signal,capture:true}); + document.querySelectorAll('[data-tv-image], #tv-guide-hero .home-feature-poster').forEach(image=>{ + if (image.complete && !image.naturalWidth) recoverImage(image); + }); + document.querySelectorAll('[data-tv-source-image]').forEach(image=>{ + if(image.complete&&!image.naturalWidth) recoverTvSourceImage(image); + }); + document.querySelector('#toggle-tv-filters')?.addEventListener('click',()=>{ + if (state.liveTv) {state.liveTv.filtersOpen = !state.liveTv.filtersOpen; context.render();} + },{signal}); + document.querySelectorAll('[data-tv-provider]').forEach(button=>button.addEventListener('click',()=>{ + const data=state.liveTv;if(!data){return;}data.providerView=button.dataset.tvProvider || undefined;data.selectedChannelId=undefined;data.selectedProgramId=undefined;data.group=undefined;context.render(); + },{signal})); + document.querySelectorAll('[data-tv-source], [data-tv-source-group], [data-tv-category], [data-tv-channel-view]').forEach(button => button.addEventListener('click', () => { + const data = state.liveTv; if (!data) return; + if (button.dataset.tvSource) { data.sourceId = Number(button.dataset.tvSource); data.sourceGroupId = undefined; data.group = undefined;data.providerView=undefined; } + if (button.dataset.tvSourceGroup) { data.sourceGroupId = button.dataset.tvSourceGroup; data.sourceId = undefined; data.group = undefined;data.providerView=undefined; } + if (button.dataset.tvCategory) data.category = button.dataset.tvCategory; + if (button.dataset.tvChannelView) data.channelView = button.dataset.tvChannelView as 'all' | 'favorites' | 'recent'; + data.selectedProgramId = undefined; data.selectedChannelId = undefined; + context.render(); + }, {signal})); + const select = (event: Event) => { + const button = (event.target as Element | null)?.closest('[data-tv-select-channel]'); + if (!button) return; + const data = state.liveTv; if (!data) return; + const channel = Number(button.dataset.tvSelectChannel), program = Number(button.dataset.tvSelectProgram) || undefined; + if (data.selectedChannelId === channel && data.selectedProgramId === program) return; + data.selectedChannelId = channel; data.selectedProgramId = program; + const selection = guideSelection(data); + document.querySelectorAll('.tv-guide-program').forEach(cell=>cell.setAttribute('aria-pressed',String(Number(cell.dataset.tvSelectChannel) === selection.channel?.id && Number(cell.dataset.tvSelectProgram) === selection.program?.id))); + if (context.renderTvSelection) context.renderTvSelection(); else context.render(); + }; + document.addEventListener('focusin',select,{signal}); + document.addEventListener('click', event => { + const button = (event.target as Element | null)?.closest('[data-tv-favorite]'); + if (!button) return; + const id = Number(button.dataset.tvFavorite); + void run(() => setTvFavorite(id, !state.liveTv?.preferences?.favorites.includes(id)), button); + }, {signal}); +} diff --git a/crates/client-web/src/app/liveTvGuideModel.ts b/crates/client-web/src/app/liveTvGuideModel.ts new file mode 100644 index 00000000..5004adf3 --- /dev/null +++ b/crates/client-web/src/app/liveTvGuideModel.ts @@ -0,0 +1,187 @@ +/** Source selection, category matching, and time-aligned geometry for the TV guide. */ +import { resolveApiUrl } from '../api'; +import type { TvChannel, TvMetadata, TvProgram, TvStation, TvProvider } from '../liveTvApi'; +import type { LiveTvState } from './liveTvActions'; + +import { TV_WINDOW_SECONDS } from './tvGuideTime'; +/** Viewer-facing program categories supported by common XMLTV and tuner guides. */ +export const TV_CATEGORIES = ['All programs', 'Sports', 'Movies', 'News', 'Kids', 'Music', 'Documentaries'] as const; +const CATEGORY_PATTERNS: Record = { + Sports: /sport|football|baseball|basketball|soccer|hockey|tennis|racing|golf|wrestling/i, + Movies: /movie|film|cinema/i, News: /news|current affairs|weather/i, + Kids: /child|kids|youth|animation|cartoon/i, Music: /music|concert/i, Documentaries: /documentar/i, +}; + +const schedules = new WeakMap>}>(); +const stationIndexes = new WeakMap>>(); +const channelViews = new WeakMap(); +const timeFormat = new Intl.DateTimeFormat(undefined,{hour:'numeric',minute:'2-digit'}); +const dateFormat = new Intl.DateTimeFormat(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); + +/** Match source-scoped station IDs, respecting an explicit channel mapping. */ +export function channelPrograms(channel: TvChannel, programs: TvProgram[]): TvProgram[] { + const key = channel.guide_override ?? channel.guide_key; + let index = schedules.get(programs); + if (index?.length !== programs.length) { + const sources = new Map>(); + for (const program of programs) { + const stations = sources.get(program.source_id) ?? new Map(); + const rows = stations.get(program.guide_key) ?? []; + rows.push(program); stations.set(program.guide_key,rows); sources.set(program.source_id,stations); + } + for (const stations of sources.values()) for (const rows of stations.values()) rows.sort((a,b)=>a.starts_at-b.starts_at); + index = {length:programs.length,sources}; schedules.set(programs,index); + } + return index.sources.get(channel.source_id)?.get(key) ?? []; +} + +/** Format UTC seconds in the viewer's local timezone. */ +export function tvTime(seconds: number, date = false): string { + return (date ? dateFormat : timeFormat).format(seconds*1000); +} + +/** Accept remote artwork and the authenticated Koko program-artwork endpoint. */ +export function tvImage(value?: string | null): string { + if (!value) return ''; + if (/^\/api\/v1\/live-tv\/(?:programs|channels)\/\d+\/artwork(?:\?kind=poster)?$/.test(value)) return resolveApiUrl(value); + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return ''; + if (url.hostname === 'tmsimg.com' || url.hostname.endsWith('.tmsimg.com')) url.searchParams.set('w',url.pathname.includes('/h3/NowShowing/') ? '320' : '960'); + return url.href.replace(/'/g,'%27'); + } + catch { return ''; } +} + +/** Prefer the channel's guide station logo when the tuner supplies no artwork. */ +export function channelArtwork(channel: TvChannel, data: LiveTvState): string { + return tvImage(channel.logo) || channelArtworkFallback(channel,data); +} + +/** Guide station artwork can replace an unavailable tuner logo in both guide and player. */ +export function channelArtworkFallback(channel: TvChannel, data: LiveTvState): string { + const key = channel.guide_override ?? channel.guide_key; + let index = stationIndexes.get(data.stations); + if (!index) { + index = new Map(); + for (const station of data.stations) { + const rows = index.get(station.source_id) ?? new Map(); + if (!rows.has(station.guide_key)) rows.set(station.guide_key,station); + index.set(station.source_id,rows); + } + stationIndexes.set(data.stations,index); + } + return tvImage(index.get(channel.source_id)?.get(key)?.logo); +} + +/** Distinguish actual live broadcasts from programs merely airing at the current time. */ +export function programBadges(metadata: TvMetadata): string[] { + const badges: string[] = []; + if (metadata.is_live) badges.push('LIVE'); + if (metadata.is_premiere) badges.push('PREMIERE'); + if (metadata.is_finale) badges.push('FINALE'); + if (metadata.is_new) badges.push('NEW'); + return badges; +} + +/** Match program categories without guessing from the title or conflating NEW and LIVE. */ +export function matchesTvCategory(program: TvProgram, category?: string): boolean { + return !category || category === 'All programs' || (program.metadata.categories ?? []).some(c => CATEGORY_PATTERNS[category]?.test(c)); +} + +/** Clip broadcasts to the visible time range while preserving schedule gaps and duration. */ +export function programPosition(program: TvProgram, start: number): { left: number; width: number } | undefined { + const from = Math.max(start, program.starts_at), to = Math.min(start + TV_WINDOW_SECONDS, program.ends_at); + if (to <= from) return; + return { left:(from - start) / TV_WINDOW_SECONDS * 100, width:(to - from) / TV_WINDOW_SECONDS * 100 }; +} + +/** List available sources without exposing server setup or connection credentials. */ +export function guideSources(data: LiveTvState): { id: number; name: string; count: number }[] { + const ids = [...new Set(data.channels.filter(c => c.enabled && c.available).map(c => c.source_id))]; + return ids.map(id => ({ id, name:data.guideSources?.find(s => s.id === id)?.name ?? `TV source ${id}`, + count:data.channels.filter(c => c.source_id === id && c.enabled && c.available).length })); +} + +/** Keep source navigation stable when devices disappear or channel setup changes. */ +export function activeGuideSource(data: LiveTvState): number | undefined { + return activeGuideView(data)?.sourceIds[0]; +} + +/** One rail entry represents either a named group or an ungrouped source. */ +export interface TvGuideView { key:string; name:string; count:number;sourceIds:number[];groupId?:string } + +/** Group connected sources without deduplicating channels or combining tuner limits. */ +export function guideViews(data: LiveTvState): TvGuideView[] { + const sources = guideSources(data), counts = new Map(sources.map(s=>[s.id,s.count])); + const grouped = new Set(); + const views:TvGuideView[] = []; + for (const group of data.sourceGroups ?? []) { + const ids = group.source_ids.filter(id=>counts.has(id)); + ids.forEach(id=>grouped.add(id)); + if (ids.length) views.push({key:`group:${group.id}`,groupId:group.id,name:group.name,sourceIds:ids,count:ids.reduce((sum,id)=>sum+(counts.get(id) ?? 0),0)}); + } + for (const source of sources) if (!grouped.has(source.id)) views.push({key:`source:${source.id}`,name:source.name,count:source.count,sourceIds:[source.id]}); + return views; +} + +/** Retain the selected group, including when a previously standalone member is grouped. */ +export function activeGuideView(data: LiveTvState): TvGuideView | undefined { + const views = guideViews(data); + return views.find(v=>data.sourceGroupId && v.groupId === data.sourceGroupId) + ?? views.find(v=>!v.groupId && v.sourceIds[0] === data.sourceId) + ?? views.find(v=>v.sourceIds.includes(data.sourceId ?? -1)) ?? views[0]; +} + +/** Provider navigation stays within the selected connection or named source group. */ +export function guideProviders(data:LiveTvState):TvProvider[] { + const ids=new Set(activeGuideView(data)?.sourceIds); + return (data.providers ?? []).filter(p=>p.enabled&&ids.has(p.source_id)); +} + +/** Ignore a stale provider selection when its connection/group is no longer displayed. */ +export function activeGuideProvider(data:LiveTvState):TvProvider|undefined { + return guideProviders(data).find(p=>`${p.source_id}:${p.id}`===data.providerView); +} + +/** Filter within a source while retaining all time positions for the displayed channels. */ +export function visibleGuideChannels(data: LiveTvState): TvChannel[] { + const view = activeGuideView(data), query = (data.query ?? '').toLocaleLowerCase(); + const sourceIds = new Set(view?.sourceIds); + const favorites = data.preferences?.favorites ?? [], recent = data.preferences?.recent ?? []; + const provider=activeGuideProvider(data); + const key = [data.channels,data.channels.length,data.programs,data.programs.length,view?.key,data.sourceGroups,data.providers,data.providerView,query,data.group,data.category,data.channelView,data.windowStart,data.preferences?.favorites,data.preferences?.recent]; + const cached = channelViews.get(data); + if (cached && key.every((value,i)=>Object.is(value,cached.key[i]))) return cached.channels; + const favoriteIds = new Set(favorites), recentOrder = new Map(recent.map((id,i)=>[id,i])); + const channels = data.channels.filter(c => { + if (!c.enabled || !c.available || !sourceIds.has(c.source_id) || (data.group && c.group_name !== data.group)) return false; + if (provider&&(c.source_id!==provider.source_id || c.provider_id!==provider.id)) return false; + if (data.channelView === 'favorites' && !favoriteIds.has(c.id)) return false; + if (data.channelView === 'recent' && !recentOrder.has(c.id)) return false; + if (!query && (!data.category || data.category === 'All programs')) return true; + const programs = channelPrograms(c, data.programs).filter(p => programPosition(p, data.windowStart) && matchesTvCategory(p, data.category)); + if (data.category && data.category !== 'All programs' && !programs.length) return false; + return !query || `${c.number} ${c.name}`.toLocaleLowerCase().includes(query) || programs.some(p => p.title.toLocaleLowerCase().includes(query)); + }); + if (data.channelView === 'recent') channels.sort((a,b) => recentOrder.get(a.id)!-recentOrder.get(b.id)!); + channelViews.set(data,{key,channels}); + return channels; +} + +/** Resolve the highlighted program for the hero, falling back to the current broadcast. */ +export function guideSelection(data: LiveTvState): { channel?: TvChannel; program?: TvProgram } { + const channels = visibleGuideChannels(data); + const selectedChannel = channels.find(c => c.id === data.selectedChannelId); + const now = Date.now() / 1000; + let first: {channel:TvChannel;program:TvProgram} | undefined; + for (const channel of selectedChannel ? [selectedChannel] : channels) { + const rows = channelPrograms(channel,data.programs).filter(p=>programPosition(p,data.windowStart) && matchesTvCategory(p,data.category)); + const selected = rows.find(p=>p.id === data.selectedProgramId); + if (selected) return {channel,program:selected}; + const current = rows.find(p=>p.starts_at <= now && p.ends_at > now); + if (current) return {channel,program:current}; + if (!first && rows[0]) first = {channel,program:rows[0]}; + } + return first ?? {channel:selectedChannel ?? channels[0]}; +} diff --git a/crates/client-web/src/app/liveTvGuideViewport.ts b/crates/client-web/src/app/liveTvGuideViewport.ts new file mode 100644 index 00000000..fca8fcd6 --- /dev/null +++ b/crates/client-web/src/app/liveTvGuideViewport.ts @@ -0,0 +1,49 @@ +/** Mounts only nearby channel rows while preserving scroll geometry, focus, and the player. */ +import { createIcons, icons } from 'lucide'; +import { state } from './state'; +import { setElementHtml } from './domPatcher'; +import { visibleGuideChannels } from './liveTvGuideModel'; +import { renderTvGuideRows } from './liveTvGuide'; +import { registerTvGuideViewport, tvGuideRowRange, tvGuideViewKey, TV_ROW_HEIGHT } from './tvGuideViewport'; + +/** Refresh channel rows on coalesced scroll/resize events, leaving the hero and timeline mounted. */ +export function bindTvGuideViewport(signal: AbortSignal): void { + const data = state.liveTv; + const guide = document.querySelector('#tv-guide-scroll'); + const rows = guide?.querySelector('#tv-guide-rows'); + if (!data || !guide || !rows) return; + const channels = visibleGuideChannels(data), key = tvGuideViewKey(data); + guide.scrollTop = data.guideViewport?.key === key ? data.guideViewport.top : 0; + let pending = 0; + const update = () => { + if (signal.aborted || state.liveTv !== data) return; + data.guideViewport = {key,top:guide.scrollTop,height:guide.clientHeight || 580}; + const range = tvGuideRowRange(channels.length,guide.scrollTop,data.guideViewport.height); + const stamp = `${range.start}:${range.end}`; + if (rows.dataset.tvRange === stamp) return; + const active = document.activeElement as HTMLElement | null; + const activeRow = active?.closest('[data-tv-row-index]'); + if (activeRow && rows.contains(activeRow)) { + const index = Number(activeRow.dataset.tvRowIndex); + if (index < range.start || index >= range.end) guide.focus({preventScroll:true}); + } + setElementHtml(rows,renderTvGuideRows(data,channels)); + rows.dataset.tvRange = stamp; + createIcons({icons,root:rows}); + }; + const schedule = () => { + if (signal.aborted || state.liveTv !== data) return; + data.guideViewport = {key,top:guide.scrollTop,height:guide.clientHeight || 580}; + if (!pending) pending = requestAnimationFrame(()=>{pending=0;update();}); + }; + guide.addEventListener('scroll',schedule,{signal,passive:true}); + const observer = new ResizeObserver(schedule); observer.observe(guide); + signal.addEventListener('abort',()=>{observer.disconnect();if (pending) cancelAnimationFrame(pending);},{once:true}); + registerTvGuideViewport(guide,index=>{ + if (index < 0 || index >= channels.length) return; + const id = `#tv-channel-row-${channels[index].id}`; + if (!rows.querySelector(id)) { guide.scrollTop = index*TV_ROW_HEIGHT; update(); } + return rows.querySelector(id) ?? undefined; + },signal); + update(); +} diff --git a/crates/client-web/src/app/liveTvIcons.ts b/crates/client-web/src/app/liveTvIcons.ts new file mode 100644 index 00000000..1a3c344d --- /dev/null +++ b/crates/client-web/src/app/liveTvIcons.ts @@ -0,0 +1,44 @@ +/** Bundled application and provider logos, with uploaded or channel-linked image overrides. */ +import type { TvGuideSource, TvProvider } from '../liveTvApi'; +import { resolveApiUrl } from '../api'; +import { escapeHtml as escape } from './format'; +import { renderIcon } from './ui'; +import type { AppIconName } from './types'; + +const PROVIDERS:Record={adultswim:'png',amazon:'svg',bally:'svg',cbs:'png',espn:'png',foxone:'ico',freelivesports:'png',fubo:'png',hallmark:'png',hulu:'png',lg:'svg',localnow:'png',nfl:'png',plex:'svg',pluto:'png',reelz:'png',roku:'svg',samsung:'svg',sling:'svg',spectrum:'png',stirr:'png',tcl:'png',tubi:'png',vizio:'png',whale:'svg',wsn:'png',xumo:'svg',youtube:'png'}; +const ALIASES:Record={adult_swim:'adultswim',amazon_prime_free:'amazon',ballysports:'bally',cbs_sports:'cbs',charter:'spectrum',fox_one:'foxone',fubotv:'fubo','lg-channels':'lg',pluto_tv:'pluto',prime:'amazon',reelz_tve:'reelz',roku_channel:'roku',samsungtvplus:'samsung',sling_freestream:'sling',slingtv:'sling',watchfree:'vizio',youtube_tv:'youtube',youtubetv:'youtube'}; +const SOURCE_NAMES:Record={fastchannels:'FastChannels',hdhomerun:'HDHomeRun',nextpvr:'NextPVR',m3u:'IPTV',tvheadend:'Tvheadend',native:'Local tuner',dvb:'Local tuner'}; +const SOURCE_LOGOS:Record={fastchannels:'fastchannels.svg',hdhomerun:'hdhomerun.ico',nextpvr:'nextpvr.ico',tvheadend:'tvheadend.png'}; +/** Use authenticated Koko artwork or an ordinary HTTP image, never arbitrary markup or data URLs. */ +export function tvSourceImage(value?:string|null):string { + if (!value) return ''; + if (/^\/api\/v1\/live-tv\/sources\/\d+\/image(?:\?[^#]*)?$/.test(value)) return resolveApiUrl(value); + try {const url=new URL(value);return ['http:','https:'].includes(url.protocol)&&!url.username&&!url.password?url.href:'';}catch{return '';} +} +function frame(fallback:string,image:string,label:string,backup=''):string { + const backupAttribute=backup?' data-tv-source-fallback="'+escape(backup)+'"':''; + const picture=image?'':''; + return `${fallback}${picture}`; +} +/** Prefer uploaded artwork, then the bundled application logo, then a Lucide symbol. */ +export function renderTvSourceIcon(source:Pick):string { + const icons:Record={hdhomerun:'tv',nextpvr:'database-zap',m3u:'link-2',tvheadend:'radio',native:'layers',dvb:'layers'}; + const type=source.kind==='m3u'&&source.iptv_mode==='fastchannels'?'fastchannels':source.kind; + const uploaded=tvSourceImage(source.image_url),logo=SOURCE_LOGOS[type]?resolveApiUrl('/assets/providers/'+SOURCE_LOGOS[type]):''; + return frame(renderIcon(icons[source.kind] ?? 'tv','tv-type-symbol'),uploaded||logo,`${SOURCE_NAMES[type] ?? 'TV'} source icon`,uploaded?logo:''); +} +/** Keep the same icon slot for the aggregate provider view. */ +export function renderTvAllProvidersIcon():string {return frame(renderIcon('layers','tv-type-symbol'),'','All providers icon');} +/** Failed uploads fall back to the application logo; failed logos reveal the Lucide symbol. */ +export function recoverTvSourceImage(image:HTMLImageElement):void { + if(!image.matches?.('[data-tv-source-image]')) return; + const backup=image.dataset.tvSourceFallback; + if(backup){delete image.dataset.tvSourceFallback;image.src=backup;image.hidden=false;} + else image.hidden=true; +} +/** Prefer custom/channel artwork, then the real provider logo, with Lucide for unknown brands. */ +export function renderTvProviderIcon(provider:Pick):string { + const id=ALIASES[provider.id] ?? provider.id,extension=PROVIDERS[id]; + const logo=extension?resolveApiUrl(`/assets/providers/tv-${id}.${extension}`):'',custom=tvSourceImage(provider.image_url); + return frame(renderIcon('tv','tv-type-symbol'),custom||logo,`${provider.name} provider icon`,custom?logo:''); +} diff --git a/crates/client-web/src/app/liveTvMapping.ts b/crates/client-web/src/app/liveTvMapping.ts new file mode 100644 index 00000000..c68c7633 --- /dev/null +++ b/crates/client-web/src/app/liveTvMapping.ts @@ -0,0 +1,41 @@ +/** Keeps channel mapping dropdowns compact until the user opens an individual station list. */ +import type { TvChannel, TvStation } from '../liveTvApi'; +import { escapeHtml as escape } from './format'; + +function stationOption(station: TvStation): string { + const label = `${station.number} ${station.name} (${station.guide_key})`; + return ``; +} + +/** Render only Automatic and the current override, avoiding channels × stations options. */ +export function renderTvGuideMapping(channel: TvChannel, stations: TvStation[]): string { + const selected = channel.guide_override; + const station = selected ? stations.find(s => s.source_id === channel.source_id && s.guide_key === selected) : undefined; + const option = selected ? stationOption(station ?? {source_id:channel.source_id,guide_key:selected,name:selected,number:'',aliases:[]}).replace('${option}`; +} + +/** Populate the focused native select and discard its other options when focus leaves it. */ +export function bindTvGuideMappings(root: ParentNode, signal: AbortSignal, stations: TvStation[]): void { + const sources = new Map(); + for (const station of stations) { + const list = sources.get(station.source_id) ?? []; + list.push(station); sources.set(station.source_id, list); + } + const choices = new Map(); + root.querySelectorAll('[data-tv-guide-source]').forEach(select => { + select.addEventListener('focus', () => { + const current = select.value; + const source = Number(select.dataset.tvGuideSource); + const rows = sources.get(source) ?? []; + let options = choices.get(source); + if (options === undefined) { options = rows.map(stationOption).join(''); choices.set(source, options); } + const missing = current && !rows.some(s => s.guide_key === current) ? select.selectedOptions[0]?.outerHTML ?? '' : ''; + select.innerHTML = select.options[0].outerHTML + options + missing; + select.value = current; + }, {signal}); + select.addEventListener('blur', () => { + for (const option of Array.from(select.options)) if (option.value && !option.selected) option.remove(); + }, {signal}); + }); +} diff --git a/crates/client-web/src/app/liveTvProviders.ts b/crates/client-web/src/app/liveTvProviders.ts new file mode 100644 index 00000000..dff7eb79 --- /dev/null +++ b/crates/client-web/src/app/liveTvProviders.ts @@ -0,0 +1,46 @@ +/** Renders bounded provider editors, including live channel-logo references and image uploads. */ +import type { TvSource, TvProvider } from '../liveTvApi'; +import type { LiveTvState } from './liveTvActions'; +import { escapeHtml as escape } from './format'; +import { renderTvProviderIcon,renderTvSourceIcon } from './liveTvIcons'; +import { renderIcon } from './ui'; + +/** Avatar-style image picker shared by connection and provider appearance controls. */ +export function tvIconPicker(source:number,name:string,provider?:string):string { + const attribute=provider?'data-tv-icon-provider="'+escape(provider)+'"':''; + return ``; +} +/** Source identity and image controls span the card instead of squeezing connection fields. */ +export function renderTvSourceHeading(source:TvSource):string { + return `${renderTvSourceIcon(source)}${escape(source.name)}`; +} +/** Keep upload and reset actions outside the source card's disclosure button. */ +export function renderTvSourceAppearance(source:TvSource):string { + const reset=source.image_url?'':''; + return `
${tvIconPicker(source.id,source.name)}${reset}
`; +} +function editor(source:TvSource,provider:TvProvider,data:LiveTvState):string { + const channels=data.channels.filter(c=>c.source_id===source.id&&c.provider_id===provider.id&&c.logo); + let choice=provider.has_custom_icon?'upload':'default'; + if(provider.icon_channel_id) choice='channel:'+provider.icon_channel_id; + const uploadSelected=choice==='upload'?'selected':''; + const uploadChoice=provider.has_custom_icon?'':''; + let channelChoices=channels.map(c=>{ + const selected=choice==='channel:'+c.id; + return ``; + }).join(''); + if(provider.icon_channel_id&&!channels.some(c=>c.id===provider.icon_channel_id)) channelChoices+=''; + return `
+ + +

A channel icon stays linked to the channel and updates when its logo changes.

${tvIconPicker(source.id,provider.name,provider.id)}
`; +} +/** Provider preferences survive upstream refreshes; only the expanded icon selector mounts its channels. */ +export function renderTvProviders(source:TvSource,data:LiveTvState):string { + const providers=source.providers ?? [];if(!providers.length) {return '';} + const cards=providers.map(p=>{ + const key=source.id+':'+p.id,open=data.providerEditor===key,content=open?editor(source,p,data):''; + return `
${renderTvProviderIcon(p)}${escape(p.name)}${p.channel_count} ${p.channel_count===1?'channel':'channels'} · ${p.enabled?'Shown':'Hidden'}${renderIcon('chevron-right','tv-disclosure-icon')}${content}
`; + }).join(''); + return `

Providers

Choose which providers appear in the guide, rename them, and select their icons.

${cards}
`; +} diff --git a/crates/client-web/src/app/liveTvSetup.ts b/crates/client-web/src/app/liveTvSetup.ts new file mode 100644 index 00000000..0a01861c --- /dev/null +++ b/crates/client-web/src/app/liveTvSetup.ts @@ -0,0 +1,114 @@ +/** Renders administrator Live TV source, guide, channel, and DVR settings. */ +import type { TvSource } from '../liveTvApi'; +import { escapeHtml as escape, formatFileSize } from './format'; +import { state } from './state'; +import type { LiveTvState } from './liveTvActions'; +import { renderTvSourceChannels,renderTvSourceGroups } from './liveTvSourceLists'; +import { renderTvSourceHeading,renderTvSourceAppearance,renderTvProviders } from './liveTvProviders'; +import { renderIcon } from './ui'; + +/** Keep Live TV administration inside the Settings workspace. */ +export function renderLiveTvSettings(): string { + const data = state.liveTv; + if (!data || data.owner !== state.bootstrap?.current_user?.id) return '

Loading Live TV settings…

'; + const error = data.error ? `` : ''; + const message = data.message ? `

${escape(data.message)}

` : ''; + return `
${error}${message}${data.loading ? '

Updating Live TV settings…

' : ''}${renderSetup(data)}
`; +} +function renderSource(source: TvSource, data: LiveTvState): string { + const scan = source.kind === 'native' ? `` : ''; + const count = source.channel_count ?? data.channels.filter(c=>c.source_id === source.id && c.available).length; + const sizes = [source.playlist_bytes == null ? '' : `M3U ${formatFileSize(source.playlist_bytes)}`,source.guide_bytes == null ? '' : `XMLTV ${formatFileSize(source.guide_bytes)}`].filter(Boolean).join(' · '); + const open = data.connectionOpen === source.id; + let editor = ''; + if (open) editor = data.connections?.[source.id] ? renderAddSource(source) : '

Loading saved connection settings…

'; + const sizeMarkup = sizes ? 'Last imported: '+escape(sizes)+'' : ''; + const expanded=data.sourceCardsOpen?.includes(source.id) ?? false; + return `
${renderTvSourceHeading(source)}${source.enabled ? 'Enabled' : 'Disabled'} · ${count} ${count === 1 ? 'channel' : 'channels'}${source.last_error?' · Refresh failed':''}${sizeMarkup}${renderIcon('chevron-right','tv-disclosure-icon')} +
${renderTvSourceAppearance(source)} + ${source.last_error ? `

${escape(source.last_error)}

` : '

Channels and guide refresh automatically.

'} +
+
+
${scan} +
+
Edit connection settings

Edit the saved connection and guide settings. Leave a saved PIN blank to keep it.

${editor}
${renderTvProviders(source,data)}${renderTvSourceChannels(source,data)} +
`; +} + +function renderSetup(data: LiveTvState): string { + return `

TV sources

Add HDHomeRun, an IPTV M3U playlist, or a local Hauppauge or other tuner directly. NextPVR and Tvheadend are also available as optional sources.

+
${data.sources.map(source=>renderSource(source,data)).join('')}
+
Add a TV source${renderAddSource()}
+ ${renderTvSourceGroups(data)} + ${renderDvrSettings(data)}`; +} + +const SOURCE_TYPES = [['hdhomerun', 'HDHomeRun'], ['m3u', 'IPTV (M3U)'], ['native', 'Local tuner (Windows BDA / Linux DVB)'], ['nextpvr', 'NextPVR'], ['tvheadend', 'Tvheadend'], ['dvb', 'Linux DVB channel file (v4l-utils)']]; + +function renderAddSource(source?: TvSource): string { + const kind = source?.kind ?? state.liveTv?.setupKind ?? 'hdhomerun'; + const identity = source ? `data-tv-replace-source="${source.id}"` : 'id="tv-source-form"'; + const capacity = ['native', 'dvb'].includes(kind) ? '' + : ``; + const savedPin = Boolean(source && state.liveTv?.connections?.[source.id]?.has_pin); + return `
+ ${capacity} + ${renderConnection(kind,savedPin)}${renderGuideSettings(kind)}
`; +} + +function renderConnection(kind: string, savedPin = false): string { + if (kind === 'native') return renderLocalTuner(); + if (kind === 'dvb') return ` + +

Install v4l-utils and generate this channel file with dvbv5-scan. For automatic channel scanning, choose Local tuner instead.

`; + if (kind === 'm3u') return ` + + + +

Works with FastChannels, E+TV, and other playlists that include provider prefixes. Provider names and icons can be customized after importing.

+ `; + const label = SOURCE_TYPES.find(([id]) => kind === id)?.[1] ?? 'TV server'; + const port = ({ nextpvr: ':8866', tvheadend: ':9981' } as Record)[kind] ?? ''; + const pinAttributes = savedPin ? 'placeholder="Leave blank to keep the saved PIN"' : 'required'; + const pin = kind === 'nextpvr' ? `` : ''; + return `
${escape(label)} connection +

+ + +

Discovery searches from the Koko server. You can also enter the address manually, including a custom port or HTTPS.

${pin}`; +} + +function renderLocalTuner(): string { + const regions = state.liveTv?.regions ?? []; + const options = regions.length ? regions.map(region => ``).join('') : ''; + return `
Local tuner

+ +

Select a discovered tuner above, or enter its location manually.

+ +

+ +

Install the manufacturer's driver. Koko scans channels when you add the tuner; scanning can take several minutes. Each tuner allows one simultaneous stream.

`; +} + +function renderGuideSettings(kind: string): string { + const broadcast = ['native', 'dvb', 'hdhomerun'].includes(kind); + const multiplex = ['native', 'dvb'].includes(kind) ? '' : '

Use physical RF channels or frequencies for HDHomeRun broadcast guide scans.

'; + return ` +

Choose a guide for this source. Automatic uses the tuner or server guide when available.

+ +

Artwork uses your enabled providers and saved API keys from Settings → Metadata providers. Schedules and program information still come from your selected guide. If a match is missing or ambiguous, Koko uses the guide image.

+ + + ${broadcast ? `` : ''} +

Zero uses the provider default: daily for Zap2It.

`; +} + +function renderDvrSettings(data: LiveTvState): string { + const settings = data.dvrSettings; if (!settings) return ''; + const permissions = settings.users.map(user => `
${escape(user.username)}
`).join(''); + return `
DVR storage and recording permissions +

Use an absolute folder path on the server. This applies to new captures; existing recordings keep their locations.

+

Recordings are shared by default. Anyone can watch shared recordings; the account that scheduled a recording manages it.

+ ${permissions}
`; +} diff --git a/crates/client-web/src/app/liveTvSourceActions.ts b/crates/client-web/src/app/liveTvSourceActions.ts new file mode 100644 index 00000000..0a9e54c1 --- /dev/null +++ b/crates/client-web/src/app/liveTvSourceActions.ts @@ -0,0 +1,184 @@ +/** Loads saved connection editors and binds source-specific channel pages and guide groups. */ +import * as api from '../liveTvApi'; +import { state } from './state'; +import { formDataString } from './formUtils'; +import { confirmAction } from './confirmationDialog'; + +type Run = (operation:()=>Promise,button?:HTMLButtonElement)=>Promise; + +function setField(form: HTMLFormElement, name: string, value: string): void { + const control = form.elements.namedItem(name) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null; + if (!control) return; + if (control.tagName==='INPUT' && (control as HTMLInputElement).type==='checkbox') {(control as HTMLInputElement).checked=value==='true';return;} + if (control.tagName === 'SELECT' && !Array.from((control as HTMLSelectElement).options).some(o=>o.value === value)) { + const option = document.createElement('option'); option.value=value; option.textContent=value; control.append(option); + } + if (control.value !== value) control.value=value; +} + +/** Reapply saved values and pending drafts after a Settings DOM patch. */ +export function hydrateTvConnections(): void { + document.querySelectorAll('[data-tv-replace-source]').forEach(form=>{ + const id = Number(form.dataset.tvReplaceSource), connection = state.liveTv?.connections?.[id]; + if (!connection) return; + const source = connection.source, config = source.config; + const fields:Record = {name:source.name,capacity:String(source.capacity)}; + for (const [name,value] of Object.entries(config)) if (['string','number','boolean'].includes(typeof value)) fields[name]=String(value); + fields.multiplex_urls = config.multiplex_urls?.join('\n') ?? ''; + if (config.zap2it?.lineup_id) { + fields.guide_country=config.zap2it.country; + fields.guide_postal=config.zap2it.postal_code; fields.guide_days=String(config.zap2it.days ?? 14); + fields.guide_provider=JSON.stringify(config.zap2it); + } + if (!fields.guide_kind) fields.guide_kind='automatic'; + if (!fields.iptv_mode) fields.iptv_mode='playlist'; + if (!Number(fields.bridge_capacity)) fields.bridge_capacity='1'; + if (!fields.artwork_provider) fields.artwork_provider='automatic'; + Object.assign(fields,state.liveTv?.connectionDrafts?.[id]); + for (const [name,value] of Object.entries(fields)) setField(form,name,value); + const provider = form.elements.namedItem('guide_provider') as HTMLSelectElement | null; + if (provider?.selectedOptions[0] && config.zap2it) provider.selectedOptions[0].textContent=`Saved lineup · ${config.zap2it.lineup_id}`; + }); +} + +/** Capture connection form values without discarding inactive guide fields or partial edits. */ +function rememberDraft(form: HTMLFormElement): void { + const data=state.liveTv; if (!data) return; + const fields:Record={}; + form.querySelectorAll('[name]').forEach(control=>{fields[control.name]=control.tagName==='INPUT'&&(control as HTMLInputElement).type==='checkbox'?String((control as HTMLInputElement).checked):control.value;}); + data.connectionDrafts ??= {}; data.connectionDrafts[Number(form.dataset.tvReplaceSource)]=fields; +} + +function providerKey(form:HTMLFormElement):string {return form.dataset.tvProviderSource+':'+form.dataset.tvProviderForm;} +function providerDraft(form:HTMLFormElement):{name:string;enabled:boolean;icon_channel:string} { + const input=new FormData(form);return {name:formDataString(input.get('name'), ''),enabled:input.has('enabled'),icon_channel:formDataString(input.get('icon_channel'), 'default')}; +} + +/** Server-selected icons and unsaved provider edits survive DOM patches and image uploads. */ +export function hydrateTvProviders():void { + document.querySelectorAll('[data-tv-provider-form]').forEach(form=>{ + const data=state.liveTv,provider=data?.sources.find(s=>s.id===Number(form.dataset.tvProviderSource))?.providers?.find(p=>p.id===form.dataset.tvProviderForm); + if(!provider){return;} + let choice=provider.has_custom_icon?'upload':'default';if(provider.icon_channel_id) choice='channel:'+provider.icon_channel_id; + const fields={name:provider.name,enabled:provider.enabled,icon_channel:choice,...data?.providerDrafts?.[providerKey(form)]}; + for(const [name,value] of Object.entries(fields)) setField(form,name,String(value)); + }); +} + +/** Bind durable grouping and bounded source editors without exposing a stored PIN. */ +export function bindTvSourceSettings(signal: AbortSignal, context:{render:()=>void}, run:Run): void { + hydrateTvProviders(); + document.querySelectorAll('details[data-tv-source-card]').forEach(details=>details.addEventListener('toggle',()=>{ + const data=state.liveTv;if(!data) return; + const id=Number(details.dataset.tvSourceCard),open=new Set(data.sourceCardsOpen ?? []); + if(open.has(id)===details.open) return; + if(details.open) open.add(id);else open.delete(id); + data.sourceCardsOpen=[...open]; + },{signal})); + document.querySelectorAll('[data-tv-source-remove]').forEach(button=>button.addEventListener('click',async()=>{ + const id=Number(button.dataset.tvSourceRemove),source=state.liveTv?.sources.find(s=>s.id===id); + if(!source) return; + const owner=state.liveTv?.owner; + const accepted=await confirmAction({title:`Remove ${source.name}?`,message:'This removes its channels, guide, future recording schedules, and series rules. Saved recordings and their files will be kept.',confirmLabel:'Remove source'}); + if(!accepted||state.liveTv?.owner!==owner||state.route.page!=='settings'||state.route.section!=='live-tv') return; + void run(async()=>{ + await api.removeTvSource(id); + const data=state.liveTv;if(!data) return; + delete data.connections?.[id];delete data.connectionDrafts?.[id]; + data.sourceCardsOpen=data.sourceCardsOpen?.filter(source=>source!==id); + if(data.connectionOpen===id) data.connectionOpen=undefined; + if(data.mappingSource===id) {data.mappingSource=undefined;data.mappingProvider='';} + data.providerEditor=undefined; + for(const key of Object.keys(data.providerDrafts ?? {})) if(key.startsWith(id+':')) delete data.providerDrafts![key]; + data.message=`${source.name} was removed. Saved recordings were kept.`; + },button); + },{signal})); + document.querySelectorAll('select[name="iptv_mode"]').forEach(select=>{ + const sync=()=>{ + const form=select.form;if(!form) {return;} + const fast=select.value==='fastchannels',label=form.querySelector('[data-tv-iptv-address-label]'),input=form.elements.namedItem('url') as HTMLInputElement; + if(label) label.textContent=fast?'FastChannels server address':'IPTV playlist URL'; + input.placeholder=fast?'http://fastchannels.example:5523':'https://provider.example/channels.m3u'; + form.querySelector('[data-tv-iptv-server-help]')?.toggleAttribute('hidden',!fast); + const bridge=form.querySelector('[data-tv-iptv-bridge]');if(bridge){bridge.hidden=!fast;bridge.disabled=!fast;} + }; + sync();select.addEventListener('change',()=>{const group=select.form?.elements.namedItem('provider_grouping') as HTMLInputElement|null;if(select.value==='fastchannels'&&group) {group.checked=true;}sync();if(select.form?.dataset.tvReplaceSource) rememberDraft(select.form);},{signal}); + }); + document.querySelectorAll('[data-tv-provider-editor]').forEach(details=>details.addEventListener('toggle',()=>{ + const data=state.liveTv;if(!data) {return;}const key=details.dataset.tvProviderEditor; + if(details.open&&data.providerEditor!==key){data.providerEditor=key;context.render();} + else if(!details.open&&data.providerEditor===key){data.providerEditor=undefined;context.render();} + },{signal})); + document.querySelectorAll('[data-tv-provider-form]').forEach(form=>{ + form.addEventListener('input',()=>{if(state.liveTv){state.liveTv.providerDrafts??={};state.liveTv.providerDrafts[providerKey(form)]=providerDraft(form);}},{signal}); + form.addEventListener('submit',event=>{ + event.preventDefault();const input=new FormData(form),choice=formDataString(input.get('icon_channel'), 'default'); + void run(async()=>{ + await api.setTvProvider(Number(form.dataset.tvProviderSource),form.dataset.tvProviderForm!,{name:formDataString(input.get('name'), ''),enabled:input.has('enabled'),icon_channel_id:choice.startsWith('channel:')?Number(choice.slice(8)):null,remove_icon:choice==='default'}); + if(state.liveTv?.providerDrafts) delete state.liveTv.providerDrafts[providerKey(form)]; + }); + },{signal});}); + document.querySelectorAll('[data-tv-icon-source]').forEach(input=>input.addEventListener('change',()=>{ + const file=input.files?.[0];if(file) void run(async()=>{ + await api.setTvSourceImage(Number(input.dataset.tvIconSource),input.dataset.tvIconProvider,file); + if(input.form?.dataset.tvProviderForm&&state.liveTv){ + state.liveTv.providerDrafts??={};state.liveTv.providerDrafts[providerKey(input.form)]={...providerDraft(input.form),icon_channel:'upload'}; + } + }); + },{signal})); + document.querySelectorAll('[data-tv-icon-reset]').forEach(button=>button.addEventListener('click',()=>{void run(()=>api.setTvSourceImage(Number(button.dataset.tvIconReset),undefined));},{signal})); + document.querySelectorAll('[data-tv-provider-channels]').forEach(button=>button.addEventListener('click',()=>{ + if(!state.liveTv){return;}state.liveTv.mappingSource=Number(button.dataset.tvProviderSource);state.liveTv.mappingProvider=button.dataset.tvProviderChannels;state.liveTv.mappingQuery='';state.liveTv.mappingPage=0;context.render(); + document.querySelector(`[data-tv-mapping-source="${state.liveTv.mappingSource}"]`)?.scrollIntoView({block:'start'}); + },{signal})); + document.querySelectorAll('[data-tv-connection-source]').forEach(details=>details.addEventListener('toggle',()=>{ + const data=state.liveTv; if (!data) return; + const id=Number(details.dataset.tvConnectionSource); + if (!details.open) { + if (data.connectionOpen === id) {data.connectionOpen=undefined;} + return; + } + if (data.connectionOpen === id && (data.connections?.[id] || data.connectionLoading === id)) return; + data.connectionOpen=id; + if (data.connections?.[id]) {context.render();return;} + data.connectionLoading=id; context.render(); + const owner=data.owner; + void api.getTvSourceConnection(id).then(connection=>{ + if (state.liveTv?.owner !== owner || state.route.page !== 'settings' || state.route.section !== 'live-tv') return; + state.liveTv.connections ??= {}; state.liveTv.connections[id]=connection; state.liveTv.connectionLoading=undefined; context.render(); + }).catch((error:unknown)=>{ + if (state.liveTv?.owner === owner) {state.liveTv.connectionLoading=undefined;state.liveTv.error=error instanceof Error ? error.message : 'Unable to load source settings';context.render();} + }); + },{signal})); + document.querySelectorAll('[data-tv-replace-source]').forEach(form=>{ + form.addEventListener('input',()=>rememberDraft(form),{signal}); + form.addEventListener('change',()=>rememberDraft(form),{signal}); + }); + document.querySelectorAll('[data-tv-mapping-source]').forEach(details=>details.addEventListener('toggle',()=>{ + const data=state.liveTv; if (!data) return; + const id=Number(details.dataset.tvMappingSource); + if (details.open && data.mappingSource !== id) {data.mappingSource=id;data.mappingProvider='';data.mappingQuery='';data.mappingPage=0;context.render();} + else if (!details.open && data.mappingSource === id) {data.mappingSource=undefined;context.render();} + },{signal})); + document.querySelectorAll('[data-tv-mapping-search]').forEach(form=>form.addEventListener('submit',event=>{ + event.preventDefault();if (!state.liveTv) return; + const input=new FormData(form);state.liveTv.mappingQuery=formDataString(input.get('query'), '').trim();state.liveTv.mappingProvider=formDataString(input.get('provider'), '');state.liveTv.mappingPage=0;context.render(); + },{signal})); + document.querySelectorAll('[data-tv-mapping-page]').forEach(button=>button.addEventListener('click',()=>{ + if (state.liveTv) {state.liveTv.mappingPage=Number(button.dataset.tvMappingPage);context.render();} + },{signal})); + document.querySelectorAll('[data-tv-group-form]').forEach(form=>form.addEventListener('submit',event=>{ + event.preventDefault(); + void run(async()=>{ + const input=new FormData(form), id=form.dataset.tvGroupForm || crypto.randomUUID(); + const group={id,name:formDataString(input.get('name'), '').trim(),source_ids:input.getAll('source_id').map(Number)}; + if (new Set(group.source_ids).size < 2) throw new Error('Choose at least two sources for this group.'); + const groups=[...(state.liveTv?.sourceGroups ?? [])], index=groups.findIndex(g=>g.id === id); + if (index < 0) groups.push(group); else groups[index]=group; + await api.setTvSourceGroups(groups); + }); + },{signal})); + document.querySelectorAll('[data-tv-group-delete]').forEach(button=>button.addEventListener('click',()=>{ + void run(()=>api.setTvSourceGroups((state.liveTv?.sourceGroups ?? []).filter(g=>g.id !== button.dataset.tvGroupDelete))); + },{signal})); +} diff --git a/crates/client-web/src/app/liveTvSourceLists.ts b/crates/client-web/src/app/liveTvSourceLists.ts new file mode 100644 index 00000000..3b97254c --- /dev/null +++ b/crates/client-web/src/app/liveTvSourceLists.ts @@ -0,0 +1,48 @@ +/** Renders source-specific channel pages and shared guide group settings. */ +import type { TvChannel, TvSource, TvSourceGroup } from '../liveTvApi'; +import type { LiveTvState } from './liveTvActions'; +import { escapeHtml as escape } from './format'; +import { renderTvGuideMapping } from './liveTvMapping'; + +/** Limit the channel editor's mounted forms without limiting imported channels. */ +export const TV_MAPPING_PAGE_SIZE = 50; + +function channelForm(channel: TvChannel, data: LiveTvState): string { + return `
${escape(channel.number)} ${escape(channel.name)} + + ${channel.available ? '' : 'Missing from source'}
`; +} + +/** Open one source at a time, with search and explicit pages for large channel lineups. */ +export function renderTvSourceChannels(source: TvSource, data: LiveTvState): string { + const open = data.mappingSource === source.id; + let content = ''; + if (open) { + const query = (data.mappingQuery ?? '').toLocaleLowerCase(); + const channels = data.channels.filter(c=>c.source_id === source.id && (!data.mappingProvider || c.provider_id===data.mappingProvider) && (!query || `${c.number} ${c.name}`.toLocaleLowerCase().includes(query))); + const page = Math.min(Math.max(0,data.mappingPage ?? 0),Math.max(0,Math.ceil(channels.length/TV_MAPPING_PAGE_SIZE)-1)); + const start = page*TV_MAPPING_PAGE_SIZE; + const providers=source.providers ?? []; + const providerOptions=providers.map(p=>``).join(''); + const providerFilter=providers.length?'':''; + content = `
${providerFilter}
+
${channels.length ? start+1 : 0}–${Math.min(channels.length,start+TV_MAPPING_PAGE_SIZE)} of ${channels.length} channels
+ ${channels.slice(start,start+TV_MAPPING_PAGE_SIZE).map(c=>channelForm(c,data)).join('') || '

No channels match this search.

'}`; + } + return `
Channel visibility and guide mapping

Show or hide channels and choose guide stations for ${escape(source.name)}.

${content}
`; +} + +function groupForm(data: LiveTvState, group?: TvSourceGroup): string { + const members = new Set(group?.source_ids); + const choices = data.sources.map(source=>``).join(''); + const remove = group ? `` : ''; + return `
Sources${choices}
${remove}
`; +} + +/** Group source connections into one guide view while retaining their individual tuner limits. */ +export function renderTvSourceGroups(data: LiveTvState): string { + if (data.sources.length < 2 && !data.sourceGroups?.length) return ''; + return `

Guide source groups

Combine two or more sources into one guide lineup. Each source keeps its connection settings and simultaneous stream limit.

+ ${(data.sourceGroups ?? []).map(group=>`
${escape(group.name)}${groupForm(data,group)}
`).join('')} +
Add a source group${groupForm(data)}
`; +} diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts index 5b6018f5..85e55b08 100644 --- a/crates/client-web/src/app/playbackController.ts +++ b/crates/client-web/src/app/playbackController.ts @@ -5,6 +5,7 @@ import { createIcons, icons } from 'lucide'; import type { AppIconName, ThemeSongSource, TrailerOption, YouTubePlayer } from './types'; import type { MediaAudioTrack, MediaItemDetail, PlaybackSession } from '../api'; import { + ApiError, createPlaybackSession, deletePlaybackSession, getArtworkUrl, @@ -21,6 +22,11 @@ import { YOUTUBE_PLAYER_STATE, YOUTUBE_THEME_PLACEHOLDER_VIDEO_ID } from './cons import { escapeHtml, formatDuration, formatMediaTime } from './format'; import { currentThemeSongTarget } from './mediaTargets'; import { state } from './state'; +import * as tvApi from '../liveTvApi'; +import { TvPlaybackWatchdog } from './tvPlaybackRecovery'; +import { bindTvPlaybackHeadroom, tvLivePosition } from './tvPlaybackHeadroom'; +import { configureTvMultiview, finishTvMultiviewOnExit, renderTvMultiviewButton, renderTvStage, retryTvMultiviewMain, stopTvMultiview, syncTvMultiview } from './tvMultiview'; +import { bindTvPlayerGuide, refreshTvPlayerGuide, refreshTvPlayerProgram, renderTvPlayerGuide, renderTvPlayerProgram, tvChannelPlayback } from './tvPlayer'; import { audiobookRestartTarget, type AudiobookRestartScope } from './audiobooks'; import { renderButtonContent, renderIcon, subtitleLanguage } from './ui'; import { buildYouTubeWatchUrl, extractYouTubeVideoId, loadYouTubeIframeApi } from './youtube'; @@ -29,11 +35,16 @@ type RenderCallback = (preserveScroll?: boolean) => void; type RefreshDataCallback = (showLoading?: boolean) => Promise; let playbackRequest = 0; +let closingTvBuffer: Promise = Promise.resolve(); +let tuningTvBuffer: Promise = Promise.resolve(); const compatiblePlaybackSources = new Set(); const reportedPlaybackErrors = new WeakMap(); const initializedMediaSources = new WeakMap void; + source: string; seekApplied: boolean; settingsApplied: boolean; started: boolean; bindingKey?: string; cleanup?: () => void; }>(); +let mediaEvents: AbortController | undefined; +let cleanupMediaBinding: (() => void) | undefined; +const tvPlaybackWatchdogs = new WeakMap, TvPlaybackWatchdog>(); let renderApp: RenderCallback = () => undefined; let refreshAppData: RefreshDataCallback = () => Promise.resolve(); @@ -42,6 +53,7 @@ let refreshAppData: RefreshDataCallback = () => Promise.resolve(); export function configurePlaybackController(callbacks: { render: RenderCallback; refreshData: RefreshDataCallback }): void { renderApp = callbacks.render; refreshAppData = callbacks.refreshData; + configureTvMultiview({render:()=>render(),beforeMainChange:()=>{cleanupMediaBinding?.();mediaEvents?.abort();cleanupMediaBinding=undefined;},stop:()=>closeActivePlaybackSession()}); globalThis.addEventListener('pagehide', finishActivePlaybackOnExit); } @@ -252,7 +264,8 @@ function renderAudioTrackMenuMarkup(isAudio: boolean, audioTracks: MediaAudioTra `; } -function renderPlayerTitleMarkup(logoUrl: string | undefined, title: string): string { +function renderPlayerTitleMarkup(logoUrl: string | undefined, title: string, channel = false, fallback = ''): string { + if (logoUrl && channel) return `

${escapeHtml(title)}

`; return logoUrl ? `` : `

${escapeHtml(title)}

`; @@ -318,75 +331,119 @@ function renderSubtitleSelection(item: MediaItemDetail): string { return ``; } -function renderMediaPlayerOverlay(): string { - const playbackItem = state.activePlaybackItem ?? state.selectedItem; - const playbackSession = state.activePlaybackSession; - if (!state.isPlayerOpen || !playbackItem) { - return ''; - } +interface PlayerView { + isAudio: boolean; title: string; source: string; poster?: string; backdrop?: string; logo?: string; + tracks: string; audioMenu: string; subtitles: string; duration?: number; audioOverride: boolean; + tv?: NonNullable; +} - const isAudio = playbackItem.media_kind === 'audio'; - const selectedAudioStreamIndex = playbackSession ? selectedAudioStreamIndexForPlayback(playbackSession) : state.activeAudioStreamIndex; - const posterUrl = playbackPosterUrl(playbackItem); - const backdropUrl = playbackBackdropUrl(playbackItem, posterUrl); - const logoUrl = playbackItem.logo_url ? resolveApiUrl(playbackItem.logo_url) : undefined; - const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; - const isRemuxingForAudio = isAudioStreamOverride && !playbackSession?.decision.transcode_required; - const source = playbackSession ? getSessionStreamUrl(playbackSession.session_id, mediaStreamStartMs(playbackSession, isRemuxingForAudio), selectedAudioStreamIndex) : ''; - const audioTracks = playbackItem.audio_tracks ?? []; - const activeAudioTrack = activeAudioTrackForSelection(audioTracks, selectedAudioStreamIndex); - const mediaMarkup = source ? renderMediaElementMarkup(isAudio, source, posterUrl, renderSubtitleTrackMarkup(playbackItem, isAudio)) : ''; - const mediaElementMarkup = state.playbackSettings?.paused ? mediaMarkup.replace(' autoplay', '') : mediaMarkup; - const audioTrackMenuMarkup = renderAudioTrackMenuMarkup(isAudio, audioTracks, activeAudioTrack); +function libraryPlayerView(item: MediaItemDetail): PlayerView { + const session = state.activePlaybackSession; + const index = session ? selectedAudioStreamIndexForPlayback(session) : state.activeAudioStreamIndex; + const audioOverride = index !== undefined && index > 0; + const remux = audioOverride && !session?.decision.transcode_required; + const isAudio = item.media_kind === 'audio'; + const poster = playbackPosterUrl(item); + const audio = item.audio_tracks ?? []; + return { isAudio, title:item.display_title, poster, backdrop:playbackBackdropUrl(item,poster), + logo:item.logo_url ? resolveApiUrl(item.logo_url) : undefined, + source:session ? getSessionStreamUrl(session.session_id,mediaStreamStartMs(session,remux),index) : '', + tracks:renderSubtitleTrackMarkup(item,isAudio), audioMenu:renderAudioTrackMenuMarkup(isAudio,audio,activeAudioTrackForSelection(audio,index)), + subtitles:isAudio ? '' : renderSubtitleSelection(item), duration:item.duration_ms, audioOverride }; +} - return ` -
-
- ${mediaElementMarkup} -
- -
-
- ${renderPlaybackProblem(state.playbackProblem)} -
- -
-
- Now playing - ${renderPlayerTitleMarkup(logoUrl, playbackItem.display_title)} -
-
- ${renderTranscodeBadge(isAudioStreamOverride)} - -
-
-
- -
-
- 0:00/${escapeHtml(formatDuration(playbackItem.duration_ms))} -
-
- - - -
-
- - - ${audioTrackMenuMarkup} - ${isAudio ? '' : renderPlaybackQuality(state.activePlaybackQuality ?? 'original')} - ${isAudio ? '' : renderSubtitleSelection(playbackItem)} - ${renderPictureInPictureButton(isAudio)} - -
-
-
-
-
- `; +function currentPlayerView(): PlayerView | undefined { + const tv = state.activeTvPlayback; + if (tv && tv.owner !== state.bootstrap?.current_user?.id) return; + if (tv) return {tv,isAudio:false,title:tv.title,source:tv.url,poster:tv.image,backdrop:tv.image,logo:tv.logo, + tracks:'',audioMenu:'',subtitles:'',audioOverride:false,duration:tv.buffer?.end_ms}; + const item = state.activePlaybackItem ?? state.selectedItem; + return state.isPlayerOpen && item ? libraryPlayerView(item) : undefined; +} + +function playerSeekEnabled(view: PlayerView): boolean { + return !view.tv || view.tv.kind === 'recordings' || Boolean(view.tv.buffer); +} + +function renderPlayerTime(view: PlayerView): string { + if (!playerSeekEnabled(view)) return 'Live TV'; + const live = view.tv?.buffer ? 'Live' : ''; + return `0:00/${escapeHtml(formatDuration(view.duration))}${live}`; +} + +function renderTvKeepControls(view: PlayerView): string { + const buffer = view.tv?.buffer; + if (!buffer?.can_keep) return ''; + const disabled = buffer.recording_id ? 'disabled' : ''; + const label = buffer.recording_id ? 'Recording kept' : 'Keep recording'; + return `
+ +
`; +} + +function renderPlayerSeek(seconds: number): string { + const label = seconds < 0 ? 'Back 10 seconds' : 'Forward 10 seconds'; + const icon = seconds < 0 ? 'skip-back' : 'skip-forward'; + return ``; +} + +function renderTvSkipToEnd(view: PlayerView): string { + return view.tv?.buffer ? `` : ''; +} + +function renderTvGuideButton(view: PlayerView): string { + if(state.tvMultiview)return ''; + return view.tv?.kind === 'channels' ? `` : ''; +} + +function tvPlayerPresentation(view: PlayerView): {program:string;guide:string;className:string} { + if (!view.tv||state.tvMultiview) return {program:'',guide:'',className:''}; + return {program:`
${renderTvPlayerProgram()}
`,guide:renderTvPlayerGuide(),className:view.tv.guideOpen ? 'has-tv-guide' : ''}; } +function playerViewKey(view:PlayerView,preview:boolean):string { + const key=state.tvMultiview?['multiview',view.tv?.owner,preview]:[view.source,preview,view.title,view.logo,view.tv?.logoFallback,state.playbackProblem,view.tv?.buffer?.can_keep]; + return escapeHtml(JSON.stringify(key)); +} + +function playerMediaMarkup(view:PlayerView):string { + const media=view.source?renderMediaElementMarkup(view.isAudio,view.source,view.poster,view.tracks):''; + return renderTvStage(state.playbackSettings?.paused?media.replace(' autoplay',''):media); +} + +function renderMediaPlayerOverlay(): string { + const view = currentPlayerView(); + if (!view) return ''; + const preview = Boolean(view.tv && !state.isPlayerOpen); + const closeLabel = 'Close player'; + const label = view.tv ? 'Live TV' : 'Now playing'; + const mediaMarkup = playerMediaMarkup(view); + const viewKey = playerViewKey(view,preview); + const multiviewClass=state.tvMultiview?'has-tv-multiview':''; + if (preview) return `
+
+ ${mediaMarkup}
${renderPlaybackProblem(state.playbackProblem)}
+
`; + const quality = !view.isAudio && (!view.tv || view.tv.buffer) ? renderPlaybackQuality(state.activePlaybackQuality ?? 'original') : ''; + const seek = playerSeekEnabled(view); + const tvPresentation = tvPlayerPresentation(view); + return `
+
+ ${mediaMarkup}
+
${renderPlaybackProblem(state.playbackProblem)}
+
${label}${renderPlayerTitleMarkup(view.logo,view.title,Boolean(view.tv),view.tv?.logoFallback)}${tvPresentation.program}
+
${renderTranscodeBadge(view.audioOverride)}
+
${seek ? '' : ''} +
${renderPlayerTime(view)}
+
${renderTvKeepControls(view)}${seek ? renderPlayerSeek(-10) : ''}${seek ? renderPlayerSeek(10) : ''}${renderTvSkipToEnd(view)}
+
+ ${renderTvGuideButton(view)}${renderTvMultiviewButton()}${view.audioMenu}${quality}${view.subtitles}${renderPictureInPictureButton(view.isAudio)} +
+ ${tvPresentation.guide}
`; +} function themeSongLayer(): HTMLElement { let layer = document.querySelector('#theme-song-layer'); if (!layer) { @@ -608,9 +665,94 @@ export function syncThemeSongPlayer(): void { }); } +/** Release TV playback when leaving its screen or starting another media item. */ +export function stopTvPlayback(): void { + const tv = state.activeTvPlayback; + closingTvBuffer=Promise.allSettled([closingTvBuffer,stopTvMultiview()]); + if (!tv) return; + const player = document.querySelector('#media-player'); + if (player) initializedMediaSources.get(player)?.cleanup?.(); + mediaEvents?.abort(); cleanupMediaBinding = undefined; + player?.pause(); player?.removeAttribute('src'); player?.load(); + state.activeTvPlayback = undefined; state.isPlayerOpen = false; + if (tv.buffer) closingTvBuffer = Promise.all([closingTvBuffer,tvApi.closeTvBuffer(tv.buffer.session_id)]).catch(error => console.warn('Unable to release TV buffer',error)); + document.body.style.cursor = ''; +} + +/** Start a TV source in the same overlay and controls as library videos. */ +export async function startTvPlayback(input: Omit, 'owner'>): Promise { + const owner = state.bootstrap?.current_user?.id; + if (owner === undefined) return; + const previous = state.activeTvPlayback ? document.querySelector('#media-player') : null; + const volume = previous ? {paused:false,volume:previous.volume,muted:previous.muted} : undefined; + stopMusic(); closeTrailerPlayer(); closeActivePlaybackSession(); + if (volume) state.playbackSettings = volume; + state.activeTvPlayback = {...input,owner}; state.isPlayerOpen = true; + state.activePlaybackQuality = 'original'; + const tv = state.activeTvPlayback; + const request = ++playbackRequest; + if (tv.kind === 'channels') {tv.url = '';tv.buffer=undefined;tv.startMs=undefined;} + render(); + if (tv.kind !== 'channels') return; + // Finish releasing an earlier tune before asking a single-tuner source for another. + const attempt = tuningTvBuffer.then(() => openTvBuffer(tv,owner,request)); + tuningTvBuffer = attempt.catch(() => undefined); + await attempt; +} + +async function openTvBuffer(tv: NonNullable, owner: number, request: number): Promise { + try { + await closingTvBuffer; + if (!tvTuneCurrent(tv,owner,request)) return; + const buffer = await tvApi.startTvBuffer(tv.id); + if (!tvTuneCurrent(tv,owner,request)) { await tvApi.closeTvBuffer(buffer.session_id); return; } + tv.buffer = buffer; tv.startMs = tvLivePosition(tv); + tv.url = tvApi.tvBufferStreamUrl(buffer.session_id,tv.startMs,'original',request); + if (state.liveTv?.owner === owner) { + const preferences = state.liveTv.preferences ?? {favorites:[],recent:[]}; + state.liveTv.preferences = {...preferences,recent:[tv.id,...preferences.recent.filter(id => id !== tv.id)].slice(0,30)}; + } + } catch (error) { + if (!tvTuneCurrent(tv,owner,request)) return; + const problem = error instanceof ApiError && error.status === 400 + ? { message: error.message, retry: true } : playbackRequestProblem(error); + const tile=state.tvMultiview?.tiles.find(t=>t.playback===tv);if(tile)tile.problem=problem; + if(state.activeTvPlayback===tv)state.playbackProblem=problem; + } + const tile=state.tvMultiview?.tiles.find(t=>t.playback===tv);if(tile)tile.pending=false; + render(); +} + +function tvTuneCurrent(tv:NonNullable,owner:number,request:number):boolean { + if(state.bootstrap?.current_user?.id!==owner||state.route.page!=='live-tv')return false; + return state.tvMultiview?.tiles.some(t=>t.playback===tv)===true||(request===playbackRequest&&state.activeTvPlayback===tv); +} + +function seekTvBuffer(position: number, paused?: boolean): void { + const tv = state.activeTvPlayback; + if (!tv?.buffer) return; + const player = document.querySelector('#media-player'); + if (player) rememberPlaybackSettings(player); + if (paused !== undefined && state.playbackSettings) state.playbackSettings.paused = paused; + tv.startMs = Math.max(tv.buffer.start_ms,Math.min(position,tv.buffer.end_ms-250)); + tv.url = tvApi.tvBufferStreamUrl(tv.buffer.session_id,tv.startMs,state.activePlaybackQuality ?? 'original',++playbackRequest); + state.playbackProblem = undefined; render(Boolean(state.tvMultiview)); +} + +/** Return TV playback to its hero slot while library playback closes normally. */ +export function dismissPlayer(): void { + if (state.activeTvPlayback && state.isPlayerOpen) { + const player = document.querySelector('#media-player'); + if (player) rememberPlaybackSettings(player); + state.isPlayerOpen = false; document.body.style.cursor = ''; render(); + } else closeActivePlaybackSession(); +} + /** Stops any active browser playback session and clears playback state. */ export function closeActivePlaybackSession(): void { + cleanupMediaBinding?.(); mediaEvents?.abort(); saveActivePlaybackProgress(); + stopTvPlayback(); state.isPlayerOpen = false; document.body.style.cursor = ''; playbackRequest += 1; @@ -647,6 +789,7 @@ async function startMediaPlayback(item: MediaItemDetail, startMs: number): Promi } async function replaceMediaPlayback(item: MediaItemDetail, startMs: number): Promise { + stopTvPlayback(); const request = ++playbackRequest; const account = state.bootstrap?.current_user?.id; state.activePlaybackCompatible ||= compatiblePlaybackSources.has(playbackSourceKey(item.id)); @@ -704,6 +847,8 @@ function saveActivePlaybackProgress(): void { } function finishActivePlaybackOnExit(): void { + finishTvMultiviewOnExit(); + if (state.activeTvPlayback?.buffer) tvApi.closeTvBufferOnExit(state.activeTvPlayback.buffer.session_id); if (state.activePlaybackSession && state.activePlaybackItem) { finishPlaybackOnExit(state.activePlaybackSession.session_id, state.activePlaybackItem.id, activeProgressRequest()); } @@ -711,6 +856,9 @@ function finishActivePlaybackOnExit(): void { /** Recreates a session at the source position; automatic recovery can preserve the intended pause state. */ export async function restartMediaPlayback(quality?: PlaybackQuality, compatible?: boolean, paused?: boolean): Promise { + if (state.activeTvPlayback) { + await restartTvPlayback(quality);return; + } const item = state.activePlaybackItem; if (!state.isPlayerOpen || !item) return; const player = document.querySelector('#media-player'); @@ -728,6 +876,20 @@ export async function restartMediaPlayback(quality?: PlaybackQuality, compatible await replaceMediaPlayback(item, position); } +async function restartTvPlayback(quality?:PlaybackQuality):Promise { + const tv=state.activeTvPlayback!; + if(tv.kind==='channels'&&(!tv.buffer||tv.buffer.error||(tv.buffer.finished&&!quality))){ + if(state.tvMultiview)await retryTvMultiviewMain();else await startTvPlayback(tv); + return; + } + const player=document.querySelector('#media-player'); + if(player)rememberPlaybackSettings(player); + if(quality)state.activePlaybackQuality=quality; + if(tv.buffer){seekTvBuffer(activePlaybackPositionMs(player));return;} + if(tv.kind==='channels'){await startTvPlayback(tv);return;} + tv.url=`${tvApi.tvStreamUrl(tv.kind,tv.id)}?attempt=${++playbackRequest}`;state.playbackProblem=undefined;render(); +} + function playbackSourceKey(itemId: number): string { return `${state.apiBase}\n${state.bootstrap?.current_user?.id}\n${itemId}`; } @@ -750,9 +912,16 @@ async function reportMediaError(player: HTMLMediaElement, code = player.error?.c const session = state.activePlaybackSession; const request = playbackRequest; const source = player.getAttribute('src'); - const isCurrent = (): boolean => request === playbackRequest && state.isPlayerOpen + const isCurrent = (): boolean => request === playbackRequest && (state.isPlayerOpen || Boolean(state.activeTvPlayback)) && player.isConnected && document.querySelector('#media-player') === player && player.getAttribute('src') === source; - if (!session || !isCurrent() || reportedPlaybackErrors.get(player) === source) return; + if (!isCurrent() || reportedPlaybackErrors.get(player) === source) return; + if (state.activeTvPlayback) { + if (recoverTvPlayback(player, true)) return; + reportedPlaybackErrors.set(player, source); + showPlaybackProblem({message:'TV playback was interrupted. Check the source, available tuners, and server media settings, then retry.',retry:true}); + return; + } + if (!session) return; reportedPlaybackErrors.set(player, source); const copiedVideo = !session.decision.video_transcode_required && (state.activeAudioStreamIndex ?? session.audio_stream_index ?? 0) === 0; @@ -774,25 +943,25 @@ async function reportMediaError(player: HTMLMediaElement, code = player.error?.c } } -function bindPlaybackRecovery(): void { +function bindPlaybackRecovery(mediaSignal: AbortSignal): void { document.querySelector('.media-player-shell .player-error-indicator')?.addEventListener('click', (event) => { const target = (event.target as Element).closest('button'); if (target?.hasAttribute('data-player-retry')) void restartMediaPlayback(); if (target?.hasAttribute('data-player-compatible')) void restartMediaPlayback(undefined, true); if (target?.hasAttribute('data-player-sign-in')) globalThis.location.reload(); - }); + }, {signal:mediaSignal}); document.querySelector('#player-quality')?.addEventListener('change', (event) => { const quality = (event.target as HTMLSelectElement).value; const selected = PLAYBACK_QUALITIES.find((entry) => entry.id === quality); if (selected) void restartMediaPlayback(selected.id); - }); + }, {signal:mediaSignal}); document.querySelector('#player-subtitles')?.addEventListener('change', (event) => { const selected = Number((event.target as HTMLSelectElement).value); const player = document.querySelector('#media-player'); if (!player) return; for (const [index, track] of Array.from(player.textTracks).entries()) track.mode = index === selected ? 'showing' : 'disabled'; rememberPlaybackSettings(player); - }); + }, {signal:mediaSignal}); } /** Start an album in disc/track order, optionally beginning at a selected track. */ @@ -930,10 +1099,19 @@ function updateIconButton( if (!button) { return; } + if (button.dataset.playerIcon === iconName && button.getAttribute('aria-label') === label) return; + button.dataset.playerIcon = iconName; button.innerHTML = renderIcon(iconName, 'player-control-icon'); button.title = label; button.setAttribute('aria-label', label); - createIcons({ icons }); + createIcons({ icons, root:button }); + preservePlayerIcons(); +} + +function preservePlayerIcons(): void { + // Lucide otherwise replaces finished SVGs on each global page refresh, even + // between pointer-down and pointer-up on the same close/volume control. + document.querySelectorAll('#media-player-overlay svg[data-lucide]').forEach(icon => { delete icon.dataset.lucide; }); } /** Creates a seek handler that increases the step when repeated in one direction. */ @@ -1208,12 +1386,171 @@ export function bindTrailerPlayer(): void { }); } +function tvPlaybackWatchdog(tv: NonNullable): TvPlaybackWatchdog { + let watchdog = tvPlaybackWatchdogs.get(tv); + if (!watchdog) { watchdog = new TvPlaybackWatchdog(); tvPlaybackWatchdogs.set(tv,watchdog); } + return watchdog; +} + +function recoverTvPlayback(player: HTMLMediaElement, interrupted = false): boolean { + const tv = state.activeTvPlayback; + const buffer = tv?.buffer; + if (!tv || !buffer || buffer.finished || buffer.error || state.playbackSettings?.paused) return false; + const watchdog = tvPlaybackWatchdog(tv); + const position = activePlaybackPositionMs(player); + const result = watchdog.observe({position,interrupted:interrupted || player.ended || Boolean(player.error), + paused:player.paused && !player.ended && !player.error,unavailable:buffer.end_ms-buffer.start_ms < 4000},Date.now()); + if (result === 'exhausted') return false; + if (result === 'recover') { + const livePosition = tvLivePosition(tv); + // Keep a time-shifted viewer's position on the first retry. If the same point + // repeatedly fails, use the current live edge as Skip to end does. + seekTvBuffer(watchdog.attempts > 1 ? livePosition : Math.min(position,livePosition),false); + } + return true; +} + +function bindTvRecordingMenu(tv: NonNullable): () => void { + const events = new AbortController(); + const signal = events.signal; + const keep = document.querySelector('#player-keep-tv'); + const menu = document.querySelector('#player-recording-menu'); + const setOpen = (open: boolean) => { + if (menu) menu.hidden = !open; + keep?.setAttribute('aria-expanded',String(open)); + }; + keep?.addEventListener('click',() => { + setOpen(Boolean(menu?.hidden)); + if (!menu?.hidden) menu?.querySelector('button')?.focus({preventScroll:true}); + },{signal}); + document.addEventListener('pointerdown',event => { + if (!(event.target as Element).closest('#player-recording-controls')) setOpen(false); + },{signal}); + menu?.addEventListener('keydown',event => { + if (event.key !== 'Escape') return; + event.preventDefault(); event.stopPropagation(); setOpen(false); keep?.focus({preventScroll:true}); + },{signal}); + menu?.querySelectorAll('[data-tv-keep]').forEach(choice => choice.addEventListener('click',() => { + if (!tv.buffer || !keep) return; + setOpen(false); keep.focus({preventScroll:true}); keep.disabled = true; keep.dataset.saving = 'true'; + void tvApi.keepTvBuffer(tv.buffer.session_id,choice.dataset.tvKeep === 'private').then(id => { + delete keep.dataset.saving; + if (state.activeTvPlayback === tv && tv.buffer) { tv.buffer.recording_id = id; updateTvRecordingButton(id); } + }).catch(error => { + if (state.activeTvPlayback !== tv) return; + delete keep.dataset.saving; keep.disabled = false; + showPlaybackProblem({message:error instanceof Error ? error.message : 'Unable to keep this recording.',retry:false}); + }); + },{signal})); + return () => events.abort(); +} + +function updateTvRecordingButton(recordingId?: number | null): void { + const keep = document.querySelector('#player-keep-tv'); + if (!keep) return; + const label = recordingId ? 'Recording kept' : 'Keep recording'; + keep.disabled = Boolean(recordingId) || keep.dataset.saving === 'true'; + keep.title = label; keep.setAttribute('aria-label',label); + keep.classList.toggle('is-recording',Boolean(recordingId)); +} + +function bindTvBufferControls(player: HTMLMediaElement): () => void { + const tv = state.activeTvPlayback; + if (!tv?.buffer) return () => undefined; + let disposed = false, polling = false; + const current = () => !disposed && state.activeTvPlayback === tv; + tvPlaybackWatchdog(tv).attach(activePlaybackPositionMs(player),Date.now()); + const cleanupMenu = bindTvRecordingMenu(tv); + const cleanupHeadroom = bindTvPlaybackHeadroom(player,tv,current,() => Boolean(state.playbackSettings?.paused)); + const goLive = () => { + const session = tv.buffer?.session_id; + if (!session) return; + const request=playbackRequest; + // A user seek survives control rebinding, but a later tune or seek supersedes it. + const isCurrentRequest=()=>state.activeTvPlayback===tv && tv.buffer?.session_id===session && playbackRequest===request; + void tvApi.getTvBuffer(session).then(info => { + if (!isCurrentRequest()) return; + info.recording_id ??= tv.buffer?.recording_id; + tv.buffer = info; + seekTvBuffer(tvLivePosition(tv),false); + }).catch(error => { + if (isCurrentRequest()) showPlaybackProblem({message:error instanceof Error ? error.message : 'Unable to find the live position.',retry:true}); + }); + }; + const live = document.querySelector('#player-skip-to-end'); + live?.addEventListener('click',goLive); + const timer = globalThis.setInterval(() => { + if (!current() || !tv.buffer || polling) return; + polling = true; + void tvApi.getTvBuffer(tv.buffer.session_id).then(info => { + if (!current()) return; + info.recording_id ??= tv.buffer?.recording_id; + tv.buffer = info; + updateTvRecordingButton(info.recording_id); + if (info.error) showPlaybackProblem({message:info.error,retry:true}); + const position = activePlaybackPositionMs(player); + if (!player.paused && position < info.start_ms && info.end_ms > info.start_ms) seekTvBuffer(info.start_ms); + else if (!state.playbackProblem && !recoverTvPlayback(player) && !info.finished && !player.paused) { + showPlaybackProblem({message:'TV playback could not reconnect automatically. Try Skip to end or retry playback.',retry:true}); + } + }).catch(error => { if (current()) console.warn('Unable to refresh the TV buffer',error); }).finally(() => { polling = false; }); + },2000); + return () => { disposed = true; globalThis.clearInterval(timer); cleanupMenu(); cleanupHeadroom(); live?.removeEventListener('click',goLive); }; +} + +/** Refresh the main tile's shared controls while preserving the other video decoders. */ +function refreshMultiviewPlayer():void { + const tv=state.activeTvPlayback,root=document.querySelector('#media-player-overlay'); + if(!state.tvMultiview||!tv||!root)return; + const tile=root.querySelector(`#tv-stream-${tv.id}`);if(!tile)return; + const key=JSON.stringify([tv.id,tv.url,state.isPlayerOpen,state.playbackProblem,tv.buffer?.can_keep]); + if(root.dataset.multiControlsKey===key)return; + cleanupMediaBinding?.();mediaEvents?.abort();cleanupMediaBinding=undefined; + root.dataset.multiControlsKey=key; + const shell=root.querySelector('#media-player-shell')!; + const template=document.createElement('template');template.innerHTML=renderMediaPlayerOverlay(); + for(const selector of ['.player-bottom-controls']){ + const previous=root.querySelector(selector),next=template.content.querySelector(selector); + if(next){if(previous)previous.replaceWith(next);else shell.append(next);}else previous?.remove(); + } + replaceMultiviewMedia(root,tile,tv.url,tv.image); + for(const selector of ['.player-loading-indicator','.player-error-indicator']){ + const element=root.querySelector(selector);if(element)tile.append(element); + } + const error=tile.querySelector('.player-error-indicator');if(error)error.innerHTML=renderPlaybackProblem(state.playbackProblem); + shell.classList.toggle('has-media-error',Boolean(state.playbackProblem)); +} + +function replaceMultiviewMedia(root:Element,tile:Element,url:string,image?:string):void { + const player=root.querySelector('#media-player'); + if(!url){player?.pause();player?.removeAttribute('src');player?.load();player?.remove();return;} + if(player?.getAttribute('src')===url)return; + const media=document.createElement('template');media.innerHTML=renderMediaElementMarkup(false,url,image,''); + const next=media.content.firstElementChild!;if(player){player.pause();player.replaceWith(next);}else tile.prepend(next); +} + /** Binds controls for the browser playback overlay in the current DOM tree. */ export function bindPlayerProgress(): void { - bindPlaybackRecovery(); + syncTvMultiview(); + refreshMultiviewPlayer(); + syncTvMultiview(); + preservePlayerIcons(); + refreshTvPlayerGuide(); + refreshTvPlayerProgram(); const player = document.querySelector('#media-player'); + const overlay=document.querySelector('#media-player-overlay'); + const bindingKey = (overlay?.dataset.playerViewKey ?? '')+(overlay?.dataset.multiControlsKey??''); + const bound = player ? initializedMediaSources.get(player) : undefined; + if (bound?.bindingKey === bindingKey && cleanupMediaBinding === bound.cleanup && !mediaEvents?.signal.aborted) return; + cleanupMediaBinding?.(); + mediaEvents?.abort(); + mediaEvents = new AbortController(); + const mediaSignal = mediaEvents.signal; + bindPlaybackRecovery(mediaSignal); const playbackItem = state.activePlaybackItem ?? state.selectedItem; - if (!player || !playbackItem) { + const tv = state.activeTvPlayback; + if (!player || (!playbackItem && !tv)) { + cleanupMediaBinding = undefined; return; } @@ -1231,9 +1568,9 @@ export function bindPlayerProgress(): void { const selectedAudioStreamIndex = state.activeAudioStreamIndex ?? state.activePlaybackSession?.audio_stream_index; const currentAudioTrackIndex = selectedAudioStreamIndex ?? 0; const isAudioStreamOverride = selectedAudioStreamIndex !== undefined && selectedAudioStreamIndex > 0; - const isTranscoding = (state.activePlaybackSession?.decision.transcode_required ?? false) || isAudioStreamOverride; - const sourceDurationSeconds = (playbackItem.duration_ms ?? 0) / 1000; - const requestedPlaybackStartSeconds = Math.max(0, state.activePlaybackStartMs / 1000); + const isTranscoding = Boolean(tv?.buffer) || (state.activePlaybackSession?.decision.transcode_required ?? false) || isAudioStreamOverride; + const sourceDurationSeconds = tv ? 0 : (playbackItem?.duration_ms ?? 0) / 1000; + const requestedPlaybackStartSeconds = Math.max(0, (tv?.startMs ?? state.activePlaybackStartMs) / 1000); const playbackBaseOffsetSeconds = isTranscoding ? requestedPlaybackStartSeconds : 0; const initialDirectSeekSeconds = isTranscoding ? 0 : requestedPlaybackStartSeconds; player.dataset.playbackOffsetMs = String(playbackBaseOffsetSeconds * 1000); @@ -1245,14 +1582,22 @@ export function bindPlayerProgress(): void { initializedMediaSources.set(player, initialization); } const mediaInitialization = initialization; + mediaInitialization.bindingKey = bindingKey; let controlsHideHandle: number | undefined; let isScrubbing = false; + let keepFocusedControlsVisible = false; + let pointerPosition: {x:number;y:number} | undefined; let hasAppliedInitialDirectSeek = initialDirectSeekSeconds <= 0 || mediaInitialization.seekApplied; let stallHandle: number | undefined; + const cleanupTvBuffer = bindTvBufferControls(player); + const cleanupTvGuide = bindTvPlayerGuide(channel => { void startTvPlayback(tvChannelPlayback(channel,true)); }); mediaInitialization.cleanup = () => { + cleanupTvBuffer(); + cleanupTvGuide(); if (controlsHideHandle !== undefined) globalThis.clearTimeout(controlsHideHandle); if (stallHandle !== undefined) globalThis.clearTimeout(stallHandle); }; + cleanupMediaBinding = mediaInitialization.cleanup; const trackPlaybackStall = (loading: boolean): void => { if (!loading || player.paused || state.playbackProblem) { @@ -1271,6 +1616,7 @@ export function bindPlayerProgress(): void { }; const playbackDurationSeconds = (): number => { + if (tv?.buffer) return tv.buffer.end_ms / 1000; if (sourceDurationSeconds > 0) { return sourceDurationSeconds; } @@ -1281,14 +1627,14 @@ export function bindPlayerProgress(): void { }; const setPlayerLoading = (loading: boolean): void => { - const shouldShowLoading = loading && !player.ended && player.readyState < player.HAVE_FUTURE_DATA; + const shouldShowLoading = loading && !player.ended && (player.readyState < player.HAVE_FUTURE_DATA || Boolean(tv && player.playbackRate===0)); shell?.classList.toggle('is-media-loading', shouldShowLoading); trackPlaybackStall(shouldShowLoading); if (!state.playbackProblem) shell?.classList.remove('has-media-error'); }; const refreshPlayerLoading = (): void => { - setPlayerLoading(!player.paused && player.readyState < player.HAVE_FUTURE_DATA); + setPlayerLoading(!player.paused && (player.readyState < player.HAVE_FUTURE_DATA || Boolean(tv && player.playbackRate===0))); }; const updatePlayButtons = (): void => { @@ -1323,9 +1669,12 @@ export function bindPlayerProgress(): void { const updateTimeline = (): void => { const duration = playbackDurationSeconds(); + const start = (tv?.buffer?.start_ms ?? 0)/1000; const currentPosition = Math.min(duration || Number.POSITIVE_INFINITY, playbackBaseOffsetSeconds + player.currentTime); + refreshTvPlayerProgram(currentPosition*1000); if (progress && !isScrubbing) { - progress.value = duration > 0 ? String(Math.min(1000, Math.max(0, (currentPosition / duration) * 1000))) : '0'; + progress.value = duration > start ? String(Math.min(1000, Math.max(0, ((currentPosition-start) / (duration-start)) * 1000))) : '0'; + progress.disabled = duration <= start; } if (currentTimeLabel) { currentTimeLabel.textContent = formatMediaTime(currentPosition); @@ -1333,6 +1682,8 @@ export function bindPlayerProgress(): void { if (durationLabel) { durationLabel.textContent = formatMediaTime(duration); } + const liveStatus = document.querySelector('#player-live-status'); + if (liveStatus) liveStatus.hidden = duration-currentPosition >= 8; }; const applyInitialDirectSeek = (): void => { @@ -1355,7 +1706,12 @@ export function bindPlayerProgress(): void { } }; - const showControls = (): void => { + const showControls = (event?: Event): void => { + if (event instanceof MouseEvent && ['mousemove','pointermove'].includes(event.type)) { + if (pointerPosition?.x === event.clientX && pointerPosition.y === event.clientY) return; + pointerPosition = {x:event.clientX,y:event.clientY}; + } + if (event && ['mousemove','mousedown','touchstart','pointermove'].includes(event.type)) keepFocusedControlsVisible = false; shell?.classList.add('is-controls-visible'); shell?.classList.remove('is-controls-hidden'); document.body.style.cursor = ''; @@ -1363,15 +1719,22 @@ export function bindPlayerProgress(): void { globalThis.clearTimeout(controlsHideHandle); } controlsHideHandle = globalThis.setTimeout(() => { - if (!player.paused && !isScrubbing && !state.playbackProblem && document.activeElement?.closest('.player-controls') === null) { + const focusedControl = document.activeElement?.closest('.player-controls'); + const menuOpen = document.querySelector('#media-player-overlay [aria-haspopup="menu"][aria-expanded="true"]'); + const selecting = document.activeElement instanceof HTMLSelectElement && focusedControl; + const guideOpen = state.activeTvPlayback?.guideOpen; + if (!player.paused && player.readyState >= player.HAVE_CURRENT_DATA && !isScrubbing && !state.playbackProblem && !menuOpen && !guideOpen && !selecting && !(keepFocusedControlsVisible && focusedControl)) { + if (document.activeElement?.closest('.player-controls')) shell?.focus({preventScroll:true}); shell?.classList.remove('is-controls-visible'); shell?.classList.add('is-controls-hidden'); - document.body.style.cursor = 'none'; + if (!state.activeTvPlayback || state.isPlayerOpen) document.body.style.cursor = 'none'; } }, 3200); }; const seekBy = (seconds: number): void => { + if (tv?.buffer) { seekTvBuffer((playbackBaseOffsetSeconds + player.currentTime + seconds)*1000); return; } + if (tv?.kind === 'channels') return; const currentPosition = playbackBaseOffsetSeconds + player.currentTime; const limit = sourceDurationSeconds > 0 ? Math.max(0, sourceDurationSeconds - 0.25) : Number.POSITIVE_INFINITY; const targetPosition = Math.min(limit, Math.max(0, currentPosition + seconds)); @@ -1409,12 +1772,14 @@ export function bindPlayerProgress(): void { void shell?.requestFullscreen?.(); }; - shell?.focus({ preventScroll: true }); - ['mousemove', 'mousedown', 'touchstart', 'pointermove'].forEach((eventName) => { - shell?.addEventListener(eventName, showControls, { passive: true }); + if (!tv || state.isPlayerOpen) shell?.focus({ preventScroll: true }); + ['mousemove', 'mousedown', 'touchstart', 'pointermove', 'click'].forEach((eventName) => { + shell?.addEventListener(eventName, showControls, {signal:mediaSignal, passive: true }); }); shell?.addEventListener('keydown', (event) => { - if (event.target instanceof HTMLInputElement) { + keepFocusedControlsVisible = true; + if((event.target as Element).closest('.tv-stream-heading,.tv-multiview-toolbar,.tv-stream-add'))return; + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLSelectElement || (event.target as Element).closest('[role="menu"]')) { return; } if (event.key === ' ' || event.key === 'k') { @@ -1435,15 +1800,15 @@ export function bindPlayerProgress(): void { toggleFullscreen(); } else if (event.key === 'Escape') { event.preventDefault(); - closeActivePlaybackSession(); + dismissPlayer(); } showControls(); - }); + }, {signal:mediaSignal}); playButtons.forEach((button) => button.addEventListener('click', () => { togglePlayback(); showControls(); - })); + }, {signal:mediaSignal})); document.querySelectorAll('[data-player-seek]').forEach((button) => { button.addEventListener('click', () => { const requestedSeconds = Number(button.dataset.playerSeek); @@ -1452,19 +1817,19 @@ export function bindPlayerProgress(): void { seekWithEscalation(direction); } showControls(); - }); + }, {signal:mediaSignal}); }); muteButton?.addEventListener('click', () => { player.muted = !player.muted; updateMuteButton(); showControls(); - }); + }, {signal:mediaSignal}); volume?.addEventListener('input', () => { player.volume = Number(volume.value); player.muted = player.volume === 0; updateMuteButton(); showControls(); - }); + }, {signal:mediaSignal}); volume?.addEventListener('wheel', (event) => { event.preventDefault(); const delta = event.deltaY < 0 ? 0.05 : -0.05; @@ -1472,15 +1837,15 @@ export function bindPlayerProgress(): void { player.muted = player.volume === 0; updateMuteButton(); showControls(); - }, { passive: false }); + }, {signal:mediaSignal, passive: false }); fullscreenButton?.addEventListener('click', () => { toggleFullscreen(); showControls(); - }); + }, {signal:mediaSignal}); audioTrackToggle?.addEventListener('click', () => { setAudioTrackMenuOpen(!state.isAudioTrackMenuOpen); showControls(); - }); + }, {signal:mediaSignal}); document.querySelectorAll('[data-player-audio-track-index]').forEach((button) => { button.addEventListener('click', () => { const nextAudioTrackIndex = Number(button.dataset.playerAudioTrackIndex); @@ -1497,7 +1862,7 @@ export function bindPlayerProgress(): void { state.activePlaybackStartMs = Math.floor((playbackBaseOffsetSeconds + player.currentTime) * 1000); setAudioTrackMenuOpen(false); render(false); - }); + }, {signal:mediaSignal}); }); pipButton?.addEventListener('click', async () => { if (!(player instanceof HTMLVideoElement) || !document.pictureInPictureEnabled) { @@ -1520,33 +1885,36 @@ export function bindPlayerProgress(): void { state.error = error instanceof Error ? error.message : 'Failed to enter picture in picture.'; render(); } - }); + }, {signal:mediaSignal}); player.addEventListener('leavepictureinpicture', () => { shell?.classList.remove('is-picture-in-picture'); showControls(); - }); + }, {signal:mediaSignal}); progress?.addEventListener('input', () => { isScrubbing = true; const duration = playbackDurationSeconds(); if (duration > 0) { - const previewSeconds = (Number(progress.value) / 1000) * duration; + const start = (tv?.buffer?.start_ms ?? 0)/1000; + const previewSeconds = start + (Number(progress.value) / 1000) * (duration-start); if (currentTimeLabel) { currentTimeLabel.textContent = formatMediaTime(previewSeconds); } } showControls(); - }); + }, {signal:mediaSignal}); progress?.addEventListener('wheel', (event) => { event.preventDefault(); const direction = event.deltaY < 0 ? 1 : -1; seekWithEscalation(direction); updateTimeline(); showControls(); - }, { passive: false }); + }, {signal:mediaSignal, passive: false }); progress?.addEventListener('change', () => { const duration = playbackDurationSeconds(); if (duration > 0) { - const targetPosition = Math.min(Math.max(0, duration - 0.25), (Number(progress.value) / 1000) * duration); + const start = (tv?.buffer?.start_ms ?? 0)/1000; + const targetPosition = Math.min(Math.max(start, duration - 0.25), start + (Number(progress.value) / 1000) * (duration-start)); + if (tv?.buffer) { seekTvBuffer(targetPosition*1000); return; } if (isTranscoding) { rememberPlaybackSettings(player); state.activePlaybackStartMs = Math.floor(targetPosition * 1000); @@ -1558,46 +1926,48 @@ export function bindPlayerProgress(): void { isScrubbing = false; updateTimeline(); showControls(); - }); + }, {signal:mediaSignal}); let lastSentSeconds = -1; - player.addEventListener('loadstart', () => setPlayerLoading(true)); - player.addEventListener('waiting', refreshPlayerLoading); - player.addEventListener('stalled', refreshPlayerLoading); - player.addEventListener('loadeddata', () => setPlayerLoading(false)); + player.addEventListener('loadstart', () => setPlayerLoading(true), {signal:mediaSignal}); + player.addEventListener('waiting', refreshPlayerLoading, {signal:mediaSignal}); + player.addEventListener('stalled', refreshPlayerLoading, {signal:mediaSignal}); + player.addEventListener('loadeddata', () => {setPlayerLoading(false);showControls();}, {signal:mediaSignal}); player.addEventListener('canplay', () => { applyInitialDirectSeek(); - setPlayerLoading(false); - }); + refreshPlayerLoading(); + }, {signal:mediaSignal}); player.addEventListener('playing', () => { - setPlayerLoading(false); - if (state.activePlaybackCompatible && state.activePlaybackItem?.id === playbackItem.id) { + refreshPlayerLoading(); + if (!tv && playbackItem && state.activePlaybackCompatible && state.activePlaybackItem?.id === playbackItem.id) { compatiblePlaybackSources.add(playbackSourceKey(playbackItem.id)); } - }); + }, {signal:mediaSignal}); player.addEventListener('error', () => { void reportMediaError(player); console.error('Media playback failed', player.error); - }); + }, {signal:mediaSignal}); player.addEventListener('loadedmetadata', () => { applyInitialDirectSeek(); applyPlaybackSettings(); updateTimeline(); - }); + }, {signal:mediaSignal}); player.addEventListener('play', () => { updatePlayButtons(); refreshPlayerLoading(); showControls(); - }); + }, {signal:mediaSignal}); player.addEventListener('pause', () => { updatePlayButtons(); refreshPlayerLoading(); showControls(); - }); - player.addEventListener('volumechange', updateMuteButton); + }, {signal:mediaSignal}); + player.addEventListener('volumechange', updateMuteButton, {signal:mediaSignal}); + player.addEventListener('ratechange', refreshPlayerLoading, {signal:mediaSignal}); player.addEventListener('timeupdate', () => { refreshPlayerLoading(); updateTimeline(); + if (tv || !playbackItem) return; const currentSeconds = Math.floor(player.currentTime); if (lastSentSeconds >= 0 && Math.abs(currentSeconds - lastSentSeconds) < 15) { return; @@ -1609,9 +1979,15 @@ export function bindPlayerProgress(): void { duration_ms: playbackItem.duration_ms ?? (Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : undefined), completed: false, }).catch((error) => console.warn('Failed to save playback position', error)); - }); + }, {signal:mediaSignal}); player.addEventListener('ended', () => { + if (tv) { + updatePlayButtons(); showControls(); + if (tv.kind === 'channels' && !tv.buffer?.finished) void reportMediaError(player); + return; + } + if (!playbackItem) return; if (sourceDurationSeconds > playbackBaseOffsetSeconds + player.currentTime + 2) { void reportMediaError(player); return; @@ -1623,7 +1999,7 @@ export function bindPlayerProgress(): void { duration_ms: playbackItem.duration_ms ?? (Number.isFinite(player.duration) ? Math.floor(player.duration * 1000) : undefined), completed: true, }).catch((error) => console.warn('Failed to save completed playback', error)); - }); + }, {signal:mediaSignal}); updatePlayButtons(); updateMuteButton(); @@ -1631,10 +2007,6 @@ export function bindPlayerProgress(): void { updateTimeline(); setPlayerLoading(player.readyState < player.HAVE_FUTURE_DATA); showControls(); - if (state.playbackSettings) { - player.volume = state.playbackSettings.volume; - player.muted = state.playbackSettings.muted; - } const applyPlaybackSettings = (): void => { if (mediaInitialization.settingsApplied) return; mediaInitialization.settingsApplied = true; diff --git a/crates/client-web/src/app/routeData.ts b/crates/client-web/src/app/routeData.ts index 7a5cccd0..e4b8fcb8 100644 --- a/crates/client-web/src/app/routeData.ts +++ b/crates/client-web/src/app/routeData.ts @@ -1,6 +1,7 @@ /** Loads route-specific data while rejecting stale responses after navigation. */ import * as api from '../api'; +import { isLiveTvSettings, loadLiveTv } from './liveTvActions'; import { loadPodcasts } from './podcastActions'; import type { AppRoute, AppState } from './types'; import { currentLogFilterRequest } from './activities'; @@ -128,6 +129,8 @@ export async function loadRouteData( } else if (route.page === 'podcasts') { tasks.push(loadPodcasts(state, isCurrent, render)); loadGlobalSearch(state, source, load); + } else if (route.page === 'live-tv' || isLiveTvSettings(route)) { + tasks.push(loadLiveTv(state, isCurrent).then(render)); } else if (route.page === 'person') { load('person details', source.getPerson(route.personId), (value) => { state.selectedPerson = value; }); } else if (route.section === 'dashboard') { diff --git a/crates/client-web/src/app/routes.ts b/crates/client-web/src/app/routes.ts index 59409865..4193f0fe 100644 --- a/crates/client-web/src/app/routes.ts +++ b/crates/client-web/src/app/routes.ts @@ -31,8 +31,9 @@ export function parseRoute(): AppRoute { const normalizedPath = normalizePath(globalThis.location.pathname); if (normalizedPath === '/podcasts') return { page: 'podcasts' }; + if (normalizedPath === '/live-tv') return { page: 'live-tv' }; - const settingsMatch = /^\/settings(?:\/(libraries|providers|scheduled|users|dashboard|logs))?$/.exec(normalizedPath); + const settingsMatch = /^\/settings(?:\/(libraries|live-tv|providers|scheduled|users|dashboard|logs))?$/.exec(normalizedPath); if (settingsMatch) { return { page: 'settings', section: (settingsMatch[1] as SettingsSection | undefined) ?? 'general' }; } diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index a1464131..10f921f8 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -9,6 +9,7 @@ import { canManageUsers, renderUserManagement } from './auth'; import { providerAttributionLogo, providerDisplayName, libraryProviderOptions } from './providers'; import { persistedLibraryForSettings } from './selectors'; import { state } from './state'; +import { renderLiveTvSettings } from './liveTvSetup'; import type { AppIconName, SettingsSection } from './types'; import { renderButtonContent, renderIcon, renderPageNavbar } from './ui'; @@ -20,6 +21,7 @@ export function activeSettingsSection(): SettingsSection { const SETTINGS_SECTIONS: Array<{ id: SettingsSection; label: string; description: string; icon: AppIconName }> = [ { id: 'general', label: 'General', description: 'Server, storage and media tools', icon: 'settings' }, { id: 'libraries', label: 'Libraries', description: 'Media folders, scanning and access', icon: 'layers' }, + { id: 'live-tv', label: 'Live TV', description: 'Tuners, IPTV, program guides and DVR', icon: 'tv' }, { id: 'providers', label: 'Metadata providers', description: 'Credentials and metadata sources', icon: 'link-2' }, { id: 'scheduled', label: 'Scheduled tasks', description: 'Automated refresh and maintenance', icon: 'refresh-cw' }, { id: 'users', label: 'Users', description: 'Accounts and permissions', icon: 'user-plus' }, @@ -724,6 +726,7 @@ function renderAddLibraryForm(): string { } function renderSettingsSectionContent(section: SettingsSection, settings: SettingsSnapshot | undefined): string { + if (section === 'live-tv') return renderLiveTvSettings(); if (section === 'logs') { return '
' + renderLogViewer() + '
'; } diff --git a/crates/client-web/src/app/tvGuideNavigation.ts b/crates/client-web/src/app/tvGuideNavigation.ts new file mode 100644 index 00000000..8964d911 --- /dev/null +++ b/crates/client-web/src/app/tvGuideNavigation.ts @@ -0,0 +1,121 @@ +/** Keeps keyboard and gamepad guide navigation at a stable broadcast time across channel rows. */ +import type { NavigationDirection } from './spatialNavigation'; +import { programAtTime, TV_WINDOW_SECONDS } from './tvGuideTime'; +import { ensureTvGuideRow, TV_ROW_HEIGHT, TV_HEADER_HEIGHT } from './tvGuideViewport'; + +interface GuidePosition { + source: string; + key: string; + time: number; + programColumn: boolean; + control: HTMLElement; +} +let position: GuidePosition | undefined; + +function key(control: HTMLElement): string { + return `${control.dataset.tvSelectChannel}:${control.dataset.tvSelectProgram ?? ''}`; +} + +function clampTime(guide: HTMLElement, time: number): number { + const start = Number(guide.dataset.tvWindowStart); + return Math.max(start,Math.min(time,start+TV_WINDOW_SECONDS-1)); +} + +function remember(guide: HTMLElement, control: HTMLElement, time: number, programColumn = control.classList.contains('tv-guide-program')): void { + position = {source:guide.dataset.tvGuideSource ?? '',key:key(control),time:clampTime(guide,time),control,programColumn}; +} + +/** Initialize an anchor for direct focus without resetting one established by directional input. */ +export function rememberTvGuideFocus(control: HTMLElement | null): void { + const guide = control?.closest('#tv-guide-scroll'); + if (!guide || control?.dataset.tvSelectChannel === undefined) { position = undefined; return; } + if (position && position.source === guide.dataset.tvGuideSource && position.key === key(control)) { + remember(guide,control,position.time,position.programColumn); + } else remember(guide,control,Number(control.dataset.tvStart ?? guide.dataset.tvWindowStart)); +} + +/** A pointer can select a time within a long broadcast, rather than always choosing its beginning. */ +export function rememberTvGuidePointer(event: PointerEvent): void { + const control = (event.target as Element | null)?.closest('.tv-guide-program'); + const guide = control?.closest('#tv-guide-scroll'); + const track = control?.closest('.tv-guide-track')?.getBoundingClientRect(); + if (control && guide && track?.width) remember(guide,control,Number(guide.dataset.tvWindowStart)+(event.clientX-track.left)/track.width*TV_WINDOW_SECONDS); +} + +function rowPrograms(row: Element): HTMLElement[] { + return Array.from(row.querySelectorAll('.tv-guide-program')); +} + +function programForRow(row: Element, time: number): HTMLElement | undefined { + const programs = rowPrograms(row).map(control => ({control,starts_at:Number(control.dataset.tvStart),ends_at:Number(control.dataset.tvEnd)})); + return programAtTime(programs,time)?.control ?? row.querySelector('.tv-channel-select') ?? undefined; +} + +function horizontalTarget(guide: HTMLElement, row: Element, control: HTMLElement, direction: 'left' | 'right', time: number): {target?:HTMLElement} | undefined { + if (!control.classList.contains('tv-guide-program')) return; + const programs = rowPrograms(row), index = programs.indexOf(control); + let target: HTMLElement | undefined = programs[index+(direction === 'right' ? 1 : -1)]; + if (!target && direction === 'left') target = row.querySelector('.tv-channel-select') ?? undefined; + if (target?.dataset.tvStart) time = direction === 'right' ? Number(target.dataset.tvStart) : Number(target.dataset.tvEnd)-1; + if (target) remember(guide,target,time); + return {target}; +} + +/** Resolve a row or adjacent broadcast using the remembered time, never a card's center. */ +export function tvGuideNavigationTarget(control: HTMLElement, direction: NavigationDirection): {target?:HTMLElement} | undefined { + const guide = control.closest('#tv-guide-scroll'), row = control.closest('.tv-guide-row'); + if (!guide || !row || control.dataset.tvSelectChannel === undefined) return; + rememberTvGuideFocus(control); + const time = position!.time; + const programColumn = position!.programColumn; + if (direction === 'left' || direction === 'right') return horizontalTarget(guide,row,control,direction,time); + const step = direction === 'down' ? 1 : -1; + const rows = Array.from(guide.querySelectorAll('.tv-guide-row')); + const rowIndex = (row as HTMLElement).dataset.tvRowIndex; + const nextRow = rowIndex === undefined ? rows[rows.indexOf(row)+step] + : ensureTvGuideRow(guide,Number(rowIndex)+step) ?? guide.querySelector(`[data-tv-row-index="${Number(rowIndex)+step}"]`); + if (!nextRow) return; + const target = programColumn ? programForRow(nextRow,time) : nextRow.querySelector('.tv-channel-select') ?? undefined; + if (target) remember(guide,target,time,programColumn); + return {target}; +} + +/** Return the time whose horizontal visibility should be preserved for a focused cell. */ +export function tvGuideNavigationTime(control: HTMLElement): number | undefined { + const guide = control.closest('#tv-guide-scroll'); + return guide && position?.key === key(control) ? clampTime(guide,position.time) : undefined; +} + +/** Page through long lineups, or jump to their first/last channel, at the same schedule time. */ +export function tvGuideJumpTarget(control: HTMLElement, keyName: string): {target?:HTMLElement} | undefined { + if (!['PageUp','PageDown','Home','End'].includes(keyName)) return; + const guide=control.closest('#tv-guide-scroll'), row=control.closest('[data-tv-row-index]'); + if (!guide || !row || control.dataset.tvSelectChannel === undefined) return; + rememberTvGuideFocus(control); + const {time,programColumn}=position!; + const count=Number(guide.dataset.tvChannelCount), index=Number(row.dataset.tvRowIndex); + const page=Math.max(1,Math.floor((guide.clientHeight-TV_HEADER_HEIGHT)/TV_ROW_HEIGHT)); + let next=index+(keyName === 'PageDown' ? page : -page); + if (keyName === 'Home') next=0; + if (keyName === 'End') next=count-1; + const nextRow=ensureTvGuideRow(guide,Math.max(0,Math.min(count-1,next))); + if (!nextRow) return {target:undefined}; + const target=programColumn ? programForRow(nextRow,time) : nextRow.querySelector('.tv-channel-select') ?? undefined; + if (target) remember(guide,target,time,programColumn); + return {target}; +} + +/** Replace an expired focused program with a program on the same channel after a guide update. */ +export function restoreTvGuideFocus(): HTMLElement | undefined { + // Moving a still-mounted keyed cell can blur it too, even when isConnected + // remains true. Restore only when no newer control has received focus. + if (!position || (document.activeElement && document.activeElement !== document.body)) return; + const guide = document.querySelector('#tv-guide-scroll'); + if (guide?.dataset.tvGuideSource !== position.source) { position = undefined; return; } + const row = Array.from(guide.querySelectorAll('.tv-guide-row')).find(candidate => + candidate.querySelector('.tv-channel-select')?.dataset.tvSelectChannel === position!.control.dataset.tvSelectChannel); + const time = clampTime(guide,position.time); + const target = row ? programForRow(row,time) : undefined; + if (target) remember(guide,target,time,position.programColumn); + return target; +} diff --git a/crates/client-web/src/app/tvGuideTime.ts b/crates/client-web/src/app/tvGuideTime.ts new file mode 100644 index 00000000..713f6b7b --- /dev/null +++ b/crates/client-web/src/app/tvGuideTime.ts @@ -0,0 +1,19 @@ +/** Defines TV guide windows and resolves broadcasts at an absolute schedule time. */ + +/** Six hours share one horizontal timeline. */ +export const TV_WINDOW_SECONDS = 6 * 3600; +/** Guide columns represent half-hour intervals in UTC seconds. */ +export const TV_SLOT_SECONDS = 1800; + +/** Keep the current half hour visible, while allowing browsing into future windows. */ +export function currentGuideWindow(start = 0, now = Date.now()): number { + return Math.max(start,Math.floor(now / (TV_SLOT_SECONDS * 1000)) * TV_SLOT_SECONDS); +} + +/** Prefer the broadcast containing a time; schedule gaps retain the nearest program. */ +export function programAtTime(programs: readonly T[], time: number): T | undefined { + const current = programs.find(p => p.starts_at <= time && p.ends_at > time); + if (current) return current; + return [...programs].sort((a,b) => Math.min(Math.abs(a.starts_at-time),Math.abs(a.ends_at-time)) + - Math.min(Math.abs(b.starts_at-time),Math.abs(b.ends_at-time)))[0]; +} diff --git a/crates/client-web/src/app/tvGuideViewport.ts b/crates/client-web/src/app/tvGuideViewport.ts new file mode 100644 index 00000000..c6462a79 --- /dev/null +++ b/crates/client-web/src/app/tvGuideViewport.ts @@ -0,0 +1,39 @@ +/** Defines the bounded channel row window and exposes offscreen rows to guide navigation. */ +import type { LiveTvState } from './liveTvActions'; +import { activeGuideView } from './liveTvGuideModel'; + +/** Fixed guide row geometry shared by spacers, scrolling, and keyboard navigation. */ +export const TV_ROW_HEIGHT = 88; +/** The sticky timeline occupies the first 48 pixels of the guide scroller. */ +export const TV_HEADER_HEIGHT = 48; +const OVERSCAN = 6; + +/** Scroll state is scoped to the current source and channel filters, rather than selection. */ +export interface TvGuideViewport { key:string; top:number; height:number } + +/** A new source/filter starts at the beginning; time-window changes retain the channel position. */ +export function tvGuideViewKey(data: LiveTvState): string { + return JSON.stringify([data.owner,activeGuideView(data)?.key,data.providerView,data.query,data.group,data.category,data.channelView]); +} + +/** Keep a few neighboring rows mounted while preserving the full lineup's scroll height. */ +export function tvGuideRowRange(count: number, top = 0, height = 580): {start:number;end:number} { + if (count <= 40) return {start:0,end:count}; + const visible = Math.max(1,Math.ceil((height-TV_HEADER_HEIGHT)/TV_ROW_HEIGHT)+1); + const first = Math.min(Math.max(0,count-visible),Math.max(0,Math.floor(top/TV_ROW_HEIGHT))); + return {start:Math.max(0,first-OVERSCAN),end:Math.min(count,first+visible+OVERSCAN)}; +} + +type EnsureRow = (index:number) => HTMLElement | undefined; +const viewports = new WeakMap(); + +/** Register the current scroller without keeping removed pages or accounts alive. */ +export function registerTvGuideViewport(guide: HTMLElement, ensure: EnsureRow, signal: AbortSignal): void { + viewports.set(guide,ensure); + signal.addEventListener('abort',()=>{if (viewports.get(guide) === ensure) viewports.delete(guide);},{once:true}); +} + +/** Materialize a row before keyboard/gamepad input moves beyond the mounted window. */ +export function ensureTvGuideRow(guide: HTMLElement, index: number): HTMLElement | undefined { + return viewports.get(guide)?.(index); +} diff --git a/crates/client-web/src/app/tvMultiview.css b/crates/client-web/src/app/tvMultiview.css new file mode 100644 index 00000000..00867365 --- /dev/null +++ b/crates/client-web/src/app/tvMultiview.css @@ -0,0 +1,71 @@ +/** Multiview uses the shared video player, with persistent tiles and one audio/transport focus. */ +#tv-player-stage { position:absolute;inset:0; } +.tv-stream-tile { position:relative;min-width:0;min-height:0;overflow:hidden;background:#000; } +#tv-player-stage:not(.tv-multiview-stage) > .tv-stream-tile { width:100%;height:100%; } +.tv-stream-heading,.tv-stream-problem { display:none; } +.has-tv-multiview .player-idle-hit-area { pointer-events:none; } +.has-tv-multiview .player-title-block { display:none; } +.has-tv-multiview .player-top-controls { align-items:center;padding-bottom:1rem;background:var(--surface-base); } +.has-tv-multiview .player-bottom-controls { padding-top:1rem;background:var(--surface-base); } +#tv-player-stage.tv-multiview-stage { display:grid;gap:.5rem;padding:.5rem;top:var(--tv-multiview-top-height,4.75rem);bottom:var(--tv-multiview-controls-height,145px); } +.tv-layout-grid { grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:repeat(2,minmax(0,1fr)); } +.tv-layout-focus { grid-template-columns:minmax(0,3fr) minmax(0,1fr);grid-template-rows:repeat(3,minmax(0,1fr));grid-auto-flow:column; } +.tv-layout-focus > .is-main { grid-column:1;grid-row:1 / 4; } +.tv-layout-grid > .is-main { order:-1; } +.tv-multiview-stage .tv-stream-tile { border:2px solid var(--surface-raised);border-radius:.5rem; } +.tv-multiview-stage .tv-stream-tile.is-main { border-color:var(--accent); } +.tv-multiview-stage .tv-stream-heading { position:absolute;top:0;left:0;right:0;display:flex;align-items:start;justify-content:space-between;gap:.25rem;padding:.4rem;background:linear-gradient(#000b,#0000);z-index:5; } +.is-controls-hidden .tv-stream-heading:not(:focus-within) { opacity:0;pointer-events:none; } +.tv-stream-heading button { display:flex;align-items:center;gap:.5rem;min-width:0;padding:.4rem;border-radius:.4rem;color:#fff;background:#0a0e18b3;min-height:36px; } +.tv-stream-heading [data-tv-multi-remove] { justify-content:center; } +.tv-stream-heading button:first-child { flex:1;text-align:left; } +.tv-stream-heading button > span { min-width:0;display:grid;gap:.2rem; } +.tv-stream-heading strong,.tv-stream-heading small { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; } +.tv-stream-heading small { font-weight:400;color:#d6deea;font-size:.75rem; } +.tv-stream-heading img { width:44px;height:32px;object-fit:contain;flex:none; } +.tv-stream-heading svg,.tv-multiview-toolbar svg { width:20px;height:20px;flex:none; } +.tv-stream-heading button:focus-visible,.tv-multiview-toolbar button:focus-visible { outline-offset:-3px; } +.tv-stream-problem:not([hidden]) { position:absolute;inset:3.5rem .5rem .5rem;z-index:4;background:#0a0e18e8;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:.75rem;padding:1rem; } +.tv-multiview-stage .player-error-indicator { inset:3.5rem .5rem .5rem; } +.tv-stream-add { display:flex;align-items:center;justify-content:center;gap:.75rem;min-width:0;min-height:0;background:var(--surface-base);color:var(--text-muted);border:1px dashed var(--surface-raised);border-radius:.5rem; } +.tv-stream-add svg { width:1.5rem;height:1.5rem; } +.tv-multiview-toolbar { display:flex;align-items:center;gap:1rem;min-width:0; } +.tv-multiview-toolbar strong { white-space:nowrap; } +.tv-multiview-toolbar strong span { color:var(--text-muted);font-size:.8rem;font-weight:400; } +.tv-multiview-toolbar [role="group"] { display:flex;gap:.3rem; } +.tv-multiview-toolbar button { display:inline-flex;align-items:center;justify-content:center;padding:.5rem;min-height:36px;background:var(--surface-panel);color:inherit;border:1px solid var(--surface-raised); } +.tv-multiview-toolbar button[aria-pressed="true"] { color:var(--accent);border-color:var(--accent); } +:is(.tv-multiview-toolbar,.tv-stream-heading,.tv-stream-add,.tv-multiview-picker header) .rail-icon { display:grid;place-items:center;flex:0 0 auto;width:20px;min-width:0;height:20px;line-height:0; } +:is(.tv-multiview-toolbar,.tv-stream-heading,.tv-stream-add,.tv-multiview-picker header) .rail-icon svg { display:block;width:100%;height:100%; } +.tv-stream-add .rail-icon { width:1.5rem;height:1.5rem; } +.tv-multiview-picker header button { display:inline-grid;place-items:center;width:44px;height:44px;padding:0; } +.tv-multiview-picker { display:flex;flex-direction:column;gap:.75rem;height:min(46rem,calc(100dvh - 2rem)); } +.tv-multiview-picker:not([open]) { display:none; } +.tv-multiview-picker p { margin:0;color:var(--text-muted); } +.tv-multiview-picker label { display:grid;gap:.35rem;min-width:0; } +.tv-multiview-picker input[type="search"],.tv-multiview-picker select { width:100%;min-width:0;padding:.65rem;background:var(--surface-base);color:inherit;border:1px solid var(--surface-raised);border-radius:.5rem; } +.tv-multi-picker-filters { display:grid;grid-template-columns:1fr 1fr;gap:.5rem; } +.tv-multiview-picker .tv-dialog-checkbox { display:flex;align-items:center;gap:.5rem; } +.tv-multi-picker-count { font-size:.8rem; } +.tv-multi-picker-results { flex:1;min-height:0;overflow:auto; } +.tv-preview-player #tv-player-stage.tv-multiview-stage { inset:.2rem;padding:0;gap:.2rem; } +.tv-preview-player .tv-stream-heading,.tv-preview-player .tv-stream-add { display:none; } +.tv-preview-player .tv-stream-problem { inset:0;font-size:.7rem;padding:.3rem; } +@media(max-width:850px) { + .has-tv-multiview .player-control-row { grid-template-columns:1fr;gap:.35rem; } + .has-tv-multiview .player-control-cluster { justify-content:center; } + .has-tv-multiview .player-tool-cluster { flex-wrap:wrap; } + #tv-player-stage.tv-multiview-stage { bottom:var(--tv-multiview-controls-height,220px); } + .tv-multiview-toolbar { gap:.35rem;flex-wrap:wrap; } + .tv-multiview-toolbar strong { font-size:.85rem; } + .tv-multiview-toolbar button { font-size:.7rem;padding:.4rem; } + .tv-layout-focus { grid-template-columns:repeat(3,minmax(0,1fr));grid-template-rows:minmax(0,3fr) minmax(0,1fr);grid-auto-flow:row; } + .tv-layout-focus > .is-main { grid-column:1 / 4;grid-row:1; } + .tv-stream-heading { padding:.15rem; } + .tv-stream-heading img { width:28px;height:24px; } + .tv-stream-heading button { gap:.2rem;padding:.2rem;min-height:32px; } + .tv-stream-heading strong { font-size:.7rem; } + .tv-stream-heading small { display:none; } + .tv-stream-heading svg { width:16px;height:16px; } + .tv-stream-heading .rail-icon { width:16px;height:16px; } +} diff --git a/crates/client-web/src/app/tvMultiview.ts b/crates/client-web/src/app/tvMultiview.ts new file mode 100644 index 00000000..5f411b7a --- /dev/null +++ b/crates/client-web/src/app/tvMultiview.ts @@ -0,0 +1,277 @@ +/** Retains independent Live TV decoders while switching the main tile, layout, and single audio source. */ +import * as api from '../liveTvApi'; +import type { TvPlayback } from './types'; +import type { PlaybackProblem, PlaybackQuality } from './playbackOptions'; +import { state } from './state'; +import { escapeHtml as escape } from './format'; +import { renderIcon } from './ui'; +import { tvChannelPlayback } from './tvPlayer'; +import { TvPlaybackWatchdog } from './tvPlaybackRecovery'; +import { bindTvPlaybackHeadroom, tvLivePosition } from './tvPlaybackHeadroom'; +import { channelPrograms } from './liveTvGuideModel'; +import { openTvMultiviewPicker, closeTvMultiviewPicker } from './tvMultiviewPicker'; +import { createIcons, icons } from 'lucide'; +import { setElementHtml } from './domPatcher'; + +/** Four viewing buffers can run concurrently, subject to each source's tuner allowance. */ +export const TV_MULTIVIEW_LIMIT = 4; +/** Per-tile state survives promotion to and from the shared main player. */ +export interface TvMultiviewTile { playback:TvPlayback; quality:PlaybackQuality; paused:boolean; problem?:PlaybackProblem; pending?:boolean } +/** A viewing arrangement belongs to the current account and Live TV screen only. */ +export interface TvMultiview { owner:number; layout:'grid'|'focus'; tiles:TvMultiviewTile[] } +interface Callbacks {render:()=>void;beforeMainChange:()=>void;stop:()=>void} +let callbacks:Callbacks; +let releasing:Promise=Promise.resolve(); +let boundRoot:Element|null=null,events:AbortController|undefined; +const satellites=new Mapvoid}>(); +const pendingTunes=new Map>(); +let attempt=Date.now(); +let sizing:ResizeObserver|undefined,sizedControls:Element|null=null; + +/** Use the existing player coordinator for all main-stream controls and route renders. */ +export function configureTvMultiview(input:Callbacks):void { callbacks=input; } + +function current(view:TvMultiview,tile:TvMultiviewTile):boolean { + return state.tvMultiview===view&&view.owner===state.bootstrap?.current_user?.id&&state.route.page==='live-tv'&&view.tiles.includes(tile); +} +function release(tile:TvMultiviewTile):void { + const buffer=tile.playback.buffer,pending=pendingTunes.get(tile); + if(buffer)releasing=Promise.allSettled([releasing,api.closeTvBuffer(buffer.session_id)]); + if(pending)releasing=Promise.allSettled([releasing,pending]); +} +function videoFor(id:number):HTMLVideoElement|null {return document.querySelector(`#tv-stream-${id} video`);} + +/** Release every extra tuner and pending tune; the standard player releases its main buffer. */ +export function stopTvMultiview():Promise { + const view=state.tvMultiview;state.tvMultiview=undefined; + closeTvMultiviewPicker();events?.abort();boundRoot=null; + sizing?.disconnect();sizedControls=null; + for(const runtime of satellites.values()){runtime.dispose();}satellites.clear(); + for(const tile of view?.tiles??[]){ + if(tile.playback===state.activeTvPlayback)continue; + const video=videoFor(tile.playback.id);video?.pause();video?.removeAttribute('src');video?.load();release(tile); + } + return releasing; +} + +/** Browser exit also releases background tiles using authenticated keepalive requests. */ +export function finishTvMultiviewOnExit():void { + for(const tile of state.tvMultiview?.tiles??[])if(tile.playback!==state.activeTvPlayback&&tile.playback.buffer)api.closeTvBufferOnExit(tile.playback.buffer.session_id); +} + +/** Keep the ordinary single video in a stable tile, ready for additional streams without moving its decoder. */ +export function renderTvStage(media:string):string { + const tv=state.activeTvPlayback; + if(tv?.kind!=='channels')return media; + const tiles=state.tvMultiview?.tiles??[{playback:tv}]; + const markup=tiles.map(tile=>{ + const main=tile.playback===tv,stream=tile.playback; + let video=media; + if(!main)video=stream.url?``:''; + return `
${video}
`; + }).join(''); + let className='';if(state.tvMultiview)className=`tv-multiview-stage tv-layout-${state.tvMultiview.layout}`; + return `
${markup}
`; +} + +/** Add streams from the existing player's toolbar, rather than introducing a second player. */ +export function renderTvMultiviewButton():string { + if(state.activeTvPlayback?.kind!=='channels')return ''; + const label=state.tvMultiview?'Add stream':'Multiview'; + return ``; +} + +function ensureView():TvMultiview|undefined { + const tv=state.activeTvPlayback;if(tv?.kind!=='channels'||tv.owner!==state.bootstrap?.current_user?.id)return; + if(!state.tvMultiview){ + tv.guideOpen=false; + state.tvMultiview={owner:tv.owner,layout:'focus',tiles:[{playback:tv,quality:state.activePlaybackQuality??'original',paused:false,pending:!tv.url&&!state.playbackProblem}]}; + callbacks.render(); + } + return state.tvMultiview; +} + +/** Reserve a tile immediately so repeated clicks and concurrent tunes cannot exceed four streams. */ +export async function addTvMultiviewChannel(channel:api.TvChannel):Promise { + const view=ensureView();if(!view||view.tiles.length>=TV_MULTIVIEW_LIMIT||view.tiles.some(t=>t.playback.id===channel.id))return; + const tile:TvMultiviewTile={playback:{...tvChannelPlayback(channel),url:'',owner:view.owner},quality:'original',paused:false,pending:true}; + view.tiles.push(tile);callbacks.render(); + await trackTune(view,tile,channel); +} + +async function trackTune(view:TvMultiview,tile:TvMultiviewTile,channel:api.TvChannel):Promise { + const operation=tuneTile(view,tile,channel);pendingTunes.set(tile,operation); + try{await operation;}finally{pendingTunes.delete(tile);} +} + +async function tuneTile(view:TvMultiview,tile:TvMultiviewTile,channel:api.TvChannel):Promise { + try{ + await releasing;if(!current(view,tile)){return;} + const buffer=await api.startTvBuffer(channel.id); + if(!current(view,tile)){await api.closeTvBuffer(buffer.session_id);return;} + tile.playback.buffer=buffer;tile.playback.startMs=tvLivePosition(tile.playback); + tile.playback.url=api.tvBufferStreamUrl(buffer.session_id,tile.playback.startMs,tile.quality,++attempt); + if(state.liveTv?.owner===view.owner){const preferences=state.liveTv.preferences??{favorites:[],recent:[]};state.liveTv.preferences={...preferences,recent:[channel.id,...preferences.recent.filter(id=>id!==channel.id)].slice(0,30)};} + }catch(error){if(current(view,tile))tile.problem={message:error instanceof Error?error.message:'Unable to start this stream.',retry:true};} + finally{if(current(view,tile)){tile.pending=false;if(tile.playback===state.activeTvPlayback){state.playbackProblem=tile.problem;}callbacks.render();}} +} + +/** Promotion changes controls, audio, and CSS placement; it never retunes either channel. */ +export function promoteTvMultiview(id:number):void { + const view=state.tvMultiview,tile=view?.tiles.find(t=>t.playback.id===id); + if(!view||!tile||tile.playback===state.activeTvPlayback)return; + const previous=view.tiles.find(t=>t.playback===state.activeTvPlayback),oldVideo=document.querySelector('#media-player'); + const volume=oldVideo?.volume??state.playbackSettings?.volume??1,muted=oldVideo?.muted??state.playbackSettings?.muted??false; + if(previous){previous.paused=oldVideo?.paused??false;previous.quality=state.activePlaybackQuality??'original';previous.problem=state.playbackProblem;} + callbacks.beforeMainChange(); + if(oldVideo){oldVideo.id=`tv-extra-video-${previous?.playback.id}`;oldVideo.muted=true;} + satellites.get(tile)?.dispose();satellites.delete(tile); + const video=videoFor(id);if(video){video.id='media-player';video.volume=volume;video.muted=muted;} + state.activeTvPlayback=tile.playback;state.activePlaybackQuality=tile.quality;state.playbackProblem=tile.problem; + state.playbackSettings={paused:tile.paused,volume,muted};callbacks.render(); +} + +function removeTile(id:number):void { + const view=state.tvMultiview,tile=view?.tiles.find(t=>t.playback.id===id);if(!view||!tile)return; + if(view.tiles.length===1){callbacks.stop();return;} + if(tile.playback===state.activeTvPlayback)promoteTvMultiview(view.tiles.find(t=>t!==tile)!.playback.id); + view.tiles=view.tiles.filter(t=>t!==tile);satellites.get(tile)?.dispose();satellites.delete(tile); + const video=videoFor(id);video?.pause();video?.removeAttribute('src');video?.load();release(tile);callbacks.render(); +} + +async function retryTile(tile:TvMultiviewTile):Promise { + const view=state.tvMultiview;if(!view||tile.pending)return; + const channel=state.liveTv?.channels.find(c=>c.id===tile.playback.id);if(!channel)return; + satellites.get(tile)?.dispose();satellites.delete(tile); + release(tile);tile.playback.buffer=undefined;tile.playback.url='';tile.problem=undefined;tile.pending=true;tile.paused=false; + if(tile.playback===state.activeTvPlayback){state.playbackProblem=undefined;callbacks.beforeMainChange();} + callbacks.render();await trackTune(view,tile,channel); +} + +/** Retune only a failed main tile, leaving the other viewing sessions running. */ +export async function retryTvMultiviewMain():Promise { + const tile=state.tvMultiview?.tiles.find(t=>t.playback===state.activeTvPlayback);if(tile)await retryTile(tile); +} + +function watchMainOnly():void { + const video=document.querySelector('#media-player'); + if(video)state.playbackSettings={paused:video.paused,volume:video.volume,muted:video.muted}; + callbacks.beforeMainChange();void stopTvMultiview();callbacks.render(); + if(!state.playbackSettings?.paused)void document.querySelector('#media-player')?.play().catch(()=>undefined); +} + +function tileTitle(tile:TvMultiviewTile):string { + const data=state.liveTv,channel=data?.channels.find(c=>c.id===tile.playback.id),now=Date.now()/1000; + return channel&&data?channelPrograms(channel,data.programs).find(p=>p.starts_at<=now&&p.ends_at>now)?.title??'': ''; +} +function heading(tile:TvMultiviewTile,main:boolean):string { + const tv=tile.playback,logo=tv.logo?``:''; + return ``; +} + +function syncTile(tile:TvMultiviewTile,stage:HTMLElement):void { + const main=tile.playback===state.activeTvPlayback; + let element=stage.querySelector(`#tv-stream-${tile.playback.id}`); + if(!element){element=document.createElement('section');element.id=`tv-stream-${tile.playback.id}`;element.className='tv-stream-tile';element.setAttribute('aria-label',`${tile.playback.title} stream`);element.innerHTML='
';stage.append(element);} + element.classList.toggle('is-main',main); + const title=element.querySelector('.tv-stream-heading')!,markup=heading(tile,main); + if(title.dataset.content!==markup){setElementHtml(title,markup);title.dataset.content=markup;} + const notice=element.querySelector('.tv-stream-problem')!; + notice.setAttribute('role','status'); + notice.hidden=main||(!tile.pending&&!tile.problem); + const error=tile.problem?`${escape(tile.problem.message)}`:'Starting stream…'; + if(notice.dataset.content!==error){notice.innerHTML=error;notice.dataset.content=error;} + if(main){satellites.get(tile)?.dispose();satellites.delete(tile);return;} + let video=element.querySelector('video'); + if(!video){video=document.createElement('video');video.id=`tv-extra-video-${tile.playback.id}`;video.playsInline=true;video.preload='auto';video.muted=true;element.prepend(video);} + video.muted=true; + if(video.getAttribute('src')!==tile.playback.url&&tile.playback.url){video.src=tile.playback.url;video.dataset.playbackOffsetMs=String(tile.playback.startMs??0);if(!tile.paused)void video.play().catch(()=>undefined);} + if(satellites.get(tile)?.video!==video){satellites.get(tile)?.dispose();satellites.set(tile,{video,dispose:bindSatellite(tile,video)});} +} + +function bindSatellite(tile:TvMultiviewTile,video:HTMLVideoElement):()=>void { + const view=state.tvMultiview!,events=new AbortController(),watchdog=new TvPlaybackWatchdog();let polling=false; + const position=()=>Number(video.dataset.playbackOffsetMs??tile.playback.startMs??0)+video.currentTime*1000; + const active=()=>current(view,tile)&&tile.playback!==state.activeTvPlayback; + const cleanupHeadroom=bindTvPlaybackHeadroom(video,tile.playback,active,()=>tile.paused); + watchdog.attach(position(),Date.now()); + const recover=(interrupted=false)=>{ + const buffer=tile.playback.buffer;if(!active()||!buffer||buffer.error||buffer.finished||tile.paused)return; + const result=watchdog.observe({position:position(),interrupted:interrupted||video.ended||Boolean(video.error),paused:video.paused&&!video.ended&&!video.error,unavailable:buffer.end_ms-buffer.start_ms<4000},Date.now()); + if(result==='recover'){ + const live=tvLivePosition(tile.playback);tile.playback.startMs=watchdog.attempts>1?live:Math.max(buffer.start_ms,Math.min(position(),live)); + tile.playback.url=api.tvBufferStreamUrl(buffer.session_id,tile.playback.startMs,tile.quality,++attempt);callbacks.render(); + }else if(result==='exhausted'&&!tile.problem){tile.problem={message:'This stream stopped. Retry or choose another channel.',retry:true};callbacks.render();} + }; + video.addEventListener('ended',()=>recover(true),{signal:events.signal});video.addEventListener('error',()=>recover(true),{signal:events.signal}); + const timer=globalThis.setInterval(()=>{ + const buffer=tile.playback.buffer;if(!active()||!buffer||polling){return;}polling=true; + void api.getTvBuffer(buffer.session_id).then(info=>{ + if(!active()){return;}tile.playback.buffer=info; + if(info.error){tile.problem={message:info.error,retry:true};callbacks.render();} + else if(position()undefined).finally(()=>{polling=false;}); + },2000); + return ()=>{events.abort();cleanupHeadroom();globalThis.clearInterval(timer);}; +} + +function promoteClickedTile(target:Element):void { + const tile=target.closest('.tv-stream-tile');if(tile&&state.tvMultiview)promoteTvMultiview(Number(tile.id.slice('tv-stream-'.length))); +} + +function handleAction(event:MouseEvent):void { + const target=event.target as Element,button=target.closest('button'); + if(!button){promoteClickedTile(target);return;} + if(button.id==='player-multiview'||button.dataset.tvMultiAdd !== undefined){ + const view=ensureView();if(view&&view.tiles.lengtht.playback.id===Number(button.dataset.tvMultiRetry));if(tile){void retryTile(tile);}return;} + if(button.dataset.tvMultiOnly !== undefined){watchMainOnly();return;} + const view=state.tvMultiview; + if(view&&['grid','focus'].includes(button.dataset.tvMultiLayout??'')){view.layout=button.dataset.tvMultiLayout as 'grid'|'focus';callbacks.render();} +} + +/** Synchronize tiles in place; guide refreshes and layout changes never replace playing videos. */ +export function syncTvMultiview():void { + const root=document.querySelector('#media-player-overlay'); + if(root!==boundRoot){events?.abort();boundRoot=root;events=new AbortController();root?.addEventListener('click',event=>handleAction(event as MouseEvent),{signal:events.signal});} + const view=state.tvMultiview,stage=document.querySelector('#tv-player-stage'); + if(!view||!stage)return; + stage.className=`tv-multiview-stage tv-layout-${view.layout}`;stage.dataset.count=String(view.tiles.length); + const ids=new Set(view.tiles.map(t=>`tv-stream-${t.playback.id}`)); + stage.querySelectorAll('.tv-stream-tile').forEach(tile=>{if(!ids.has(tile.id))tile.remove();}); + for(const tile of view.tiles)syncTile(tile,stage); + for(const [tile,runtime] of satellites)if(!view.tiles.includes(tile)){runtime.dispose();satellites.delete(tile);} + syncAddTile(view,stage); + syncToolbar(root,view); + if(root)createIcons({icons,root}); + observeSize(root); +} + +function syncAddTile(view:TvMultiview,stage:HTMLElement):void { + let empty=stage.querySelector('#tv-multiview-empty'); + if(view.tiles.lengthAdd stream`;stage.append(empty);}} + else empty?.remove(); + if(empty&&view.tiles.length('#tv-multiview-toolbar'); + if(!toolbar){toolbar=document.createElement('div');toolbar.id='tv-multiview-toolbar';toolbar.className='tv-multiview-toolbar';root?.querySelector('.player-top-controls')?.prepend(toolbar);} + const controls=`Multiview ${view.tiles.length} / ${TV_MULTIVIEW_LIMIT}
`; + if(toolbar.dataset.content!==controls){setElementHtml(toolbar,controls);toolbar.dataset.content=controls;} + const add=root?.querySelector('#player-multiview');if(add)add.disabled=view.tiles.length>=TV_MULTIVIEW_LIMIT; +} + +function observeSize(root:Element|null):void { + const bottom=root?.querySelector('.player-bottom-controls'),shell=root?.querySelector('#media-player-shell'); + if(bottom&&bottom!==sizedControls&&shell){ + sizing?.disconnect();sizedControls=bottom; + const measure=()=>{shell.style.setProperty('--tv-multiview-controls-height',`${bottom.getBoundingClientRect().height+8}px`);shell.style.setProperty('--tv-multiview-top-height',`${root?.querySelector('.player-top-controls')?.getBoundingClientRect().height??68}px`);}; + sizing=new ResizeObserver(measure);sizing.observe(bottom);const top=root?.querySelector('.player-top-controls');if(top){sizing.observe(top);}measure(); + } +} diff --git a/crates/client-web/src/app/tvMultiviewPicker.ts b/crates/client-web/src/app/tvMultiviewPicker.ts new file mode 100644 index 00000000..10011300 --- /dev/null +++ b/crates/client-web/src/app/tvMultiviewPicker.ts @@ -0,0 +1,67 @@ +/** Provides a bounded, searchable channel picker for adding independent multiview streams. */ +import type { TvChannel } from '../liveTvApi'; +import type { TvMultiview } from './tvMultiview'; +import { state } from './state'; +import { escapeHtml as escape } from './format'; +import { channelArtwork, channelArtworkFallback, channelPrograms } from './liveTvGuideModel'; +import { renderIcon } from './ui'; +import { createIcons, icons } from 'lucide'; + +let dismiss:(()=>void)|undefined; +/** Dismiss the channel picker when playback or the owning account leaves Live TV. */ +export function closeTvMultiviewPicker():void {dismiss?.();} + +/** Render at most fifty results, even when a source exposes thousands of channels. */ +export function openTvMultiviewPicker(view:TvMultiview,add:(channel:TvChannel)=>Promise):void { + closeTvMultiviewPicker(); + const data=state.liveTv;if(data?.owner!==view.owner)return; + const previous=document.activeElement as HTMLElement|null,dialog=document.createElement('dialog'); + dialog.className='tv-program-dialog tv-multiview-picker';dialog.id='tv-multiview-picker';dialog.setAttribute('aria-labelledby','tv-multi-picker-title'); + const sources=data.guideSources?.map(s=>``).join('')??''; + dialog.innerHTML=`

Multiview

Add a stream

Choose another channel. Select a tile to make it the main stream and hear its audio.

`; + const events=new AbortController(),signal=events.signal; + const close=()=>{events.abort();dialog.close();dialog.remove();if(dismiss===close){dismiss=undefined;}if(state.tvMultiview===view&&previous?.isConnected)previous.focus({preventScroll:true});}; + dismiss=close; + const input=dialog.querySelector('[name="search"]')!,source=dialog.querySelector('[name="source"]')!,provider=dialog.querySelector('[name="provider"]')!,favorites=dialog.querySelector('[name="favorites"]')!; + source.setAttribute('aria-label','Source');provider.setAttribute('aria-label','Provider'); + const now=Date.now()/1000; + const channels=data.channels.filter(c=>c.enabled&&c.available).map(channel=>{ + const title=channelPrograms(channel,data.programs).find(p=>p.starts_at<=now&&p.ends_at>now)?.title??''; + return {channel,title,search:`${channel.number} ${channel.name} ${title}`.toLocaleLowerCase()}; + }); + let page=0; + const render=()=>{ + const playing=new Set(view.tiles.map(t=>t.playback.id)),query=input.value.trim().toLocaleLowerCase(); + const found=channels.filter(({channel,search})=>(!source.value||channel.source_id===Number(source.value))&&(!provider.value||`${channel.source_id}:${channel.provider_id}`===provider.value)&&(!favorites.checked||data.preferences?.favorites.includes(channel.id))&&search.includes(query)) + .sort((a,b)=>Number(playing.has(a.channel.id))-Number(playing.has(b.channel.id))); + page=Math.min(page,Math.max(0,Math.ceil(found.length/50)-1)); + dialog.querySelector('.tv-multi-picker-count')!.textContent=`${found.length} channels · ${view.tiles.length} of 4 streams in use`; + dialog.querySelector('.tv-multi-picker-results')!.innerHTML=found.slice(page*50,page*50+50).map(({channel,title})=>{ + const logo=channelArtwork(channel,data),image=logo?``:''; + return ``; + }).join('')||'

No channels match. Try another search or source.

'; + dialog.querySelector('[data-multi-picker-page="-1"]')!.disabled=page===0; + dialog.querySelector('[data-multi-picker-page="1"]')!.disabled=(page+1)*50>=found.length; + }; + const providers=()=>{ + const options=(data.providers??[]).filter(p=>p.enabled&&(!source.value||p.source_id===Number(source.value))).sort((a,b)=>a.name.localeCompare(b.name)); + provider.innerHTML=''+options.map(p=>``).join(''); + }; + providers();render(); + for(const element of [input,provider,favorites])element.addEventListener('input',()=>{page=0;render();},{signal}); + source.addEventListener('change',()=>{providers();page=0;render();},{signal}); + dialog.addEventListener('click',event=>{ + const button=(event.target as Element).closest('button'); + if(button?.hasAttribute('data-multi-picker-close')){close();return;} + if(button?.dataset.multiPickerPage){page+=Number(button.dataset.multiPickerPage);render();dialog.querySelector('.tv-multi-picker-results')!.scrollTop=0;return;} + const channel=data.channels.find(c=>c.id===Number(button?.dataset.multiPickerChannel)); + if(channel&&state.tvMultiview===view&&state.bootstrap?.current_user?.id===view.owner){close();void add(channel);} + const box=dialog.getBoundingClientRect();if(event.target===dialog&&(event.clientXbox.right||event.clientYbox.bottom))close(); + },{signal}); + dialog.addEventListener('cancel',event=>{event.preventDefault();close();},{signal}); + dialog.addEventListener('error',event=>{ + const image=event.target;if(!(image instanceof HTMLImageElement))return; + if(image.dataset.fallbackSrc&&image.getAttribute('src')!==image.dataset.fallbackSrc)image.src=image.dataset.fallbackSrc;else image.hidden=true; + },{capture:true,signal}); + document.body.append(dialog);createIcons({icons,root:dialog});dialog.showModal();input.focus(); +} diff --git a/crates/client-web/src/app/tvPlaybackHeadroom.ts b/crates/client-web/src/app/tvPlaybackHeadroom.ts new file mode 100644 index 00000000..cede4367 --- /dev/null +++ b/crates/client-web/src/app/tvPlaybackHeadroom.ts @@ -0,0 +1,77 @@ +/** Maintains a playable Live TV reserve before starting or resuming a growing buffer. */ +import type { TvPlayback } from './types'; + +const RESERVE_MS = 2000; + +/** Measure the contiguous downloaded video at the current position, excluding later buffered islands. */ +export function bufferedTvHeadroom(video: HTMLMediaElement): number { + const position = video.currentTime; + for (let index=0;index=position) return Math.max(0,end-Math.max(position,start))*1000; + } + return 0; +} + +/** Skip to the newest safe point while retaining room for the capture and playback pipelines. */ +export function tvLivePosition(tv: TvPlayback): number { + const buffer = tv.buffer; + return buffer ? Math.max(buffer.start_ms,buffer.end_ms-6000-RESERVE_MS) : 0; +} + +/** Startup and underruns resume only after actual playable media accumulates; no retune or seek. */ +export function bindTvPlaybackHeadroom(video: HTMLMediaElement, tv: TvPlayback, active: () => boolean, paused: () => boolean): () => void { + const events = new AbortController(); + let played = !video.paused && video.readyState >= video.HAVE_FUTURE_DATA; + const rebuffer = bindHeadroomHold(video,events.signal,() => Boolean(tv.buffer?.finished || video.ended)); + video.addEventListener('playing',() => { played = true; },{signal:events.signal}); + const holdIfPlaying = () => { + const buffer = tv.buffer; + if (!active() || !played || paused() || video.paused || video.seeking || !buffer || buffer.finished || buffer.error) return; + rebuffer.hold(); + }; + video.addEventListener('waiting',holdIfPlaying,{signal:events.signal}); + monitorBufferEdge(video,holdIfPlaying,events.signal); + if (active() && video.currentTime<0.25 && !paused() && !tv.buffer?.finished && !tv.buffer?.error) rebuffer.hold(); + return () => { events.abort(); rebuffer.cancel(); }; +} + +/** Check playable progress because decoder readiness can include audio beyond the last video frame. */ +function monitorBufferEdge(video:HTMLMediaElement,holdIfPlaying:()=>void,signal:AbortSignal):void { + // Audio samples can keep readyState and buffered high after the last video + // frame. Detect a stopped clock as well as a nearly empty downloaded range. + let lastPosition=video.currentTime,lastProgress=Date.now(); + const edgeCheck = globalThis.setInterval(() => { + const position=video.currentTime,now=Date.now(); + if (position!==lastPosition || video.paused || video.seeking || video.playbackRate===0) lastProgress=now; + lastPosition=position; + const headroom=bufferedTvHeadroom(video); + if (headroom<500 || (headroom=750)) holdIfPlaying(); + },100); + signal.addEventListener('abort',()=>globalThis.clearInterval(edgeCheck),{once:true}); +} + +/** Hold the playback clock while loading, retaining user pause intent and the existing decoder. */ +function bindHeadroomHold(video: HTMLMediaElement, signal: AbortSignal, finished: () => boolean): {hold:()=>void;cancel:()=>void} { + let timer:ReturnType|undefined, rate=1; + const cancel = () => { + if (timer===undefined) return; + globalThis.clearInterval(timer);timer=undefined; + if (video.playbackRate===0) video.playbackRate=rate; + }; + const ready = () => { + if (finished() || (video.readyState>=video.HAVE_FUTURE_DATA && bufferedTvHeadroom(video)>=RESERVE_MS)) cancel(); + }; + video.addEventListener('pause',cancel,{signal}); + video.addEventListener('seeking',cancel,{signal}); + video.addEventListener('progress',ready,{signal}); + video.addEventListener('canplay',ready,{signal}); + const hold = () => { + if (timer!==undefined || video.playbackRate===0 || bufferedTvHeadroom(video)>=RESERVE_MS) return; + rate=video.playbackRate;video.playbackRate=0; + timer=globalThis.setInterval(ready,100); + }; + return {hold,cancel}; +} diff --git a/crates/client-web/src/app/tvPlaybackRecovery.ts b/crates/client-web/src/app/tvPlaybackRecovery.ts new file mode 100644 index 00000000..e4dce8c6 --- /dev/null +++ b/crates/client-web/src/app/tvPlaybackRecovery.ts @@ -0,0 +1,49 @@ +/** Detects stalled TV playback and limits retries across reconnects to the same buffer. */ + +/** A playback observation, independent of browser readiness events. */ +export interface TvPlaybackObservation { + /** Absolute position within the recording buffer, in milliseconds. */ + position: number; + paused: boolean; + interrupted: boolean; + unavailable: boolean; +} + +/** Keeps retry history when a decoder is replaced, and forgives failures after sustained playback. */ +export class TvPlaybackWatchdog { + attempts = 0; + private lastAttempt = Number.NEGATIVE_INFINITY; + private lastPosition = 0; + private lastProgress = 0; + private healthySince: number | undefined; + + /** Give a newly connected decoder time to start; position and clock values use milliseconds. */ + attach(position: number, now: number): void { + this.lastPosition = position; + this.lastProgress = now; + this.healthySince = undefined; + } + + /** Reconnect after twelve seconds without progress, including freezes with a healthy readyState. */ + observe(observation: TvPlaybackObservation, now: number): 'recover' | 'exhausted' | undefined { + const advanced = observation.position > this.lastPosition + 250; + this.lastPosition = observation.position; + if (observation.paused || observation.unavailable) { + this.lastProgress = now; + this.healthySince = undefined; + return; + } + if (advanced && !observation.interrupted) { + this.lastProgress = now; + this.healthySince ??= now; + if (now - this.healthySince >= 30000) this.attempts = 0; + return; + } + this.healthySince = undefined; + if ((!observation.interrupted && now - this.lastProgress < 12000) || now - this.lastAttempt < 15000) return; + if (this.attempts >= 3) return 'exhausted'; + this.attempts++; + this.lastAttempt = now; + return 'recover'; + } +} diff --git a/crates/client-web/src/app/tvPlayer.ts b/crates/client-web/src/app/tvPlayer.ts new file mode 100644 index 00000000..a87165ed --- /dev/null +++ b/crates/client-web/src/app/tvPlayer.ts @@ -0,0 +1,187 @@ +/** Adds program details and a compact channel guide to the shared full-screen video player. */ +import type { TvChannel, TvMetadata } from '../liveTvApi'; +import { tvStreamUrl } from '../liveTvApi'; +import { state } from './state'; +import { escapeHtml as escape } from './format'; +import { channelArtwork, channelArtworkFallback, channelPrograms, guideSources, programBadges, tvImage, tvTime } from './liveTvGuideModel'; +import { renderIcon } from './ui'; +import { setElementHtml } from './domPatcher'; + +/** Build the same channel artwork and playback details for the main guide and the player guide. */ +export function tvChannelPlayback(channel: TvChannel, guideOpen = false): Omit, 'owner'> { + const data = state.liveTv!, now = Date.now()/1000; + const program = channelPrograms(channel,data.programs).find(p => p.starts_at <= now && p.ends_at > now); + return {kind:'channels',id:channel.id,title:channel.name,url:tvStreamUrl('channels',channel.id),guideOpen, + image:tvImage(program?.metadata.image ?? channel.logo),logo:channelArtwork(channel,data),logoFallback:channelArtworkFallback(channel,data)}; +} + +function programDetails(program: {title:string;metadata:TvMetadata;starts_at?:number;ends_at?:number}): string { + const metadata = program.metadata; + const tags = [...programBadges(metadata),metadata.episode,metadata.rating,...(metadata.categories ?? []).slice(0,3)].filter(Boolean); + const time = program.starts_at && program.ends_at ? `${tvTime(program.starts_at)} – ${tvTime(program.ends_at)}` : ''; + const subtitle = metadata.subtitle ? `

${escape(metadata.subtitle)}

` : ''; + const description = metadata.description ? `

${escape(metadata.description)}

` : ''; + return `

${escape(program.title)}

${subtitle}
${escape([time,...tags].filter(Boolean).join(' · '))}
${description}`; +} + +/** Describe the playing channel, including time-shifted broadcasts when their listings remain available. */ +export function renderTvPlayerProgram(positionMs?: number): string { + const tv = state.activeTvPlayback, data = state.liveTv; + if (!tv || !data || data.owner !== tv.owner) return ''; + if (tv.kind === 'recordings') { + const recording = data.recordings.find(r => r.id === tv.id); + return recording ? programDetails(recording) : ''; + } + const channel = data.channels.find(c => c.id === tv.id); + const lag = tv.buffer && positionMs !== undefined ? Math.max(0,tv.buffer.end_ms-positionMs) : 0; + const time = (Date.now()-lag)/1000; + const program = channel && channelPrograms(channel,data.programs).find(p => p.starts_at <= time && p.ends_at > time); + return program ? programDetails(program) : '

No program information available

'; +} + +/** Update changing program details without replacing the video or its controls. */ +export function refreshTvPlayerProgram(positionMs?: number): void { + const details = document.querySelector('#player-tv-program'); + if (!details) return; + const player = document.querySelector('#media-player'); + if (positionMs === undefined && player) positionMs = Number(player.dataset.playbackOffsetMs ?? state.activeTvPlayback?.startMs ?? 0)+player.currentTime*1000; + const markup = renderTvPlayerProgram(positionMs); + if (details.dataset.content !== markup) { details.innerHTML = markup; details.dataset.content = markup; } +} + +function channelImage(channel: TvChannel): string { + const data = state.liveTv!; + const logo = channelArtwork(channel,data), fallback = channelArtworkFallback(channel,data); + const image = logo ? `` : ''; + return ``; +} + +function guideRow(channel: TvChannel): string { + const programs = channelPrograms(channel,state.liveTv!.programs), now = Date.now()/1000; + const current = programs.find(p => p.starts_at <= now && p.ends_at > now); + const next = programs.find(p => p.starts_at > now); + const title = current?.title ?? 'No listings available'; + const nextTitle = next ? `Next · ${escape(tvTime(next.starts_at))} · ${escape(next.title)}` : ''; + const tags = current ? programBadges(current.metadata).join(' · ') : ''; + const flags = tags ? `${escape(tags)}` : ''; + const selected = state.activeTvPlayback?.id === channel.id; + const search = `${channel.number} ${channel.name} ${title}`.toLocaleLowerCase(); + return ``; +} + +function guideRows(): string { + return state.liveTv?.channels.filter(c => c.enabled && c.available).map(guideRow).join('') ?? ''; +} + +/** Render a smaller Now/Next guide alongside the playing video. */ +export function renderTvPlayerGuide(): string { + const tv = state.activeTvPlayback, data = state.liveTv; + if (!tv || !data || tv.kind !== 'channels') return ''; + const sources = guideSources(data).map(source => ``).join(''); + return ``; +} + +function recoverImage(image: HTMLImageElement): void { + const fallback = image.dataset.fallbackSrc; + if (fallback && fallback !== image.getAttribute('src')) image.src = fallback; + else image.hidden = true; +} + +function filterChannels(): void { + const query = document.querySelector('#player-tv-guide-search')?.value.toLocaleLowerCase() ?? ''; + const source = document.querySelector('#player-tv-guide-source')?.value ?? ''; + let count = 0; + document.querySelectorAll('[data-player-tv-channel]').forEach(channel => { + channel.hidden = Boolean((source && source !== channel.dataset.tvSourceId) || !channel.dataset.tvSearch?.includes(query)); + if (!channel.hidden) count++; + }); + const empty = document.querySelector('#player-tv-guide-empty'); + if (empty) empty.hidden = count > 0; +} + +/** Refresh listings only when their content changes, retaining focused channel buttons. */ +export function refreshTvPlayerGuide(): void { + const list = document.querySelector('#player-tv-guide-channels'); + if (!list) return; + const markup = guideRows(); + if (list.dataset.content === markup) return; + setElementHtml(list,markup); list.dataset.content = markup; filterChannels(); +} + +function navigateGuide(event: KeyboardEvent, panel: HTMLElement, close: () => void): void { + event.stopPropagation(); + if (event.key === 'Escape') { event.preventDefault(); close(); return; } + if ((event.target as Element).matches('input,select')) return; + if (!['ArrowUp','ArrowDown'].includes(event.key)) return; + const channels = Array.from(panel.querySelectorAll('[data-player-tv-channel]')).filter(channel => !channel.hidden); + const index = channels.indexOf(document.activeElement as HTMLElement), step = event.key === 'ArrowDown' ? 1 : -1; + const target = channels[Math.max(0,Math.min(channels.length-1,index+step))]; + target?.focus({preventScroll:true}); target?.scrollIntoView({block:'nearest'}); event.preventDefault(); +} + +function observePlayerControls(shell: HTMLElement | null): () => void { + const controls = shell?.querySelector('.player-bottom-controls'); + if (!controls || !shell) return () => undefined; + const measure = () => { + const height = controls.getBoundingClientRect().height-Number.parseFloat(getComputedStyle(controls).paddingTop)+16; + shell.style.setProperty('--tv-player-controls-height',`${height}px`); + }; + const observer = new ResizeObserver(measure); + observer.observe(controls); measure(); + return () => observer.disconnect(); +} + +/** Reveal the current channel inside the guide's own scroller, leaving the video and page in place. */ +function revealPlayingChannel(panel: HTMLElement, focus: boolean): void { + const list = panel.querySelector('#player-tv-guide-channels'); + const channel = list?.querySelector('[data-player-tv-channel][aria-pressed="true"]'); + if (!list || !channel) return; + if (channel.hidden) { + const query = panel.querySelector('#player-tv-guide-search'); + const source = panel.querySelector('#player-tv-guide-source'); + if (query) query.value = ''; + if (source) source.value = ''; + filterChannels(); + } + if (focus) channel.focus({preventScroll:true}); + const bounds = list.getBoundingClientRect(), row = channel.getBoundingClientRect(); + list.scrollTop += row.top-bounds.top-Math.max(0,(list.clientHeight-row.height)/2); +} + +/** Bind independently of page refreshes so browsing channels never interrupts the decoder. */ +export function bindTvPlayerGuide(onTune: (channel: TvChannel) => void): () => void { + const events = new AbortController(), signal = events.signal; + const root = document.querySelector('#media-player-overlay'); + const shell = document.querySelector('#media-player-shell'); + const panel = document.querySelector('#player-tv-guide'); + const toggle = document.querySelector('#player-tv-guide-toggle'); + const cleanupLayout = panel ? observePlayerControls(shell) : () => undefined; + const setOpen = (open: boolean) => { + if (!panel || !state.activeTvPlayback) return; + state.activeTvPlayback.guideOpen = open; panel.hidden = !open; + shell?.classList.toggle('has-tv-guide',open); toggle?.setAttribute('aria-expanded',String(open)); + if (open) revealPlayingChannel(panel,true); + else toggle?.focus({preventScroll:true}); + }; + toggle?.addEventListener('click',()=>setOpen(Boolean(panel?.hidden)),{signal}); + document.querySelector('#player-tv-guide-close')?.addEventListener('click',()=>setOpen(false),{signal}); + panel?.addEventListener('keydown',event=>navigateGuide(event,panel,()=>setOpen(false)),{signal}); + panel?.addEventListener('click',event=>{ + const button = (event.target as Element).closest('[data-player-tv-channel]'); + const channel = state.liveTv?.channels.find(c => c.id === Number(button?.dataset.playerTvChannel)); + if (channel && channel.id !== state.activeTvPlayback?.id) onTune(channel); + },{signal}); + document.querySelector('#player-tv-guide-search')?.addEventListener('input',filterChannels,{signal}); + document.querySelector('#player-tv-guide-source')?.addEventListener('change',filterChannels,{signal}); + root?.addEventListener('error',event=>{ + if (event.target instanceof HTMLImageElement && event.target.dataset.tvImage !== undefined) recoverImage(event.target); + },{capture:true,signal}); + root?.querySelectorAll('[data-tv-image]').forEach(image=>{if (image.complete && !image.naturalWidth) recoverImage(image);}); + if (panel && !panel.hidden) revealPlayingChannel(panel,false); + return () => { events.abort(); cleanupLayout(); }; +} diff --git a/crates/client-web/src/app/types.ts b/crates/client-web/src/app/types.ts index d86075ba..7b571545 100644 --- a/crates/client-web/src/app/types.ts +++ b/crates/client-web/src/app/types.ts @@ -32,13 +32,14 @@ export type AppRoute = | { page: 'item'; itemId: number } | { page: 'person'; personId: number } | { page: 'podcasts' } + | { page: 'live-tv' } | { page: 'settings'; section?: SettingsSection }; /** Top-level tabs shown on the home browsing surface. */ export type HomeBrowseTab = 'recommended' | 'library' | 'collections' | 'playlists' | 'categories'; /** Settings subsections addressable from navigation and direct URLs. */ -export type SettingsSection = 'general' | 'libraries' | 'providers' | 'scheduled' | 'users' | 'dashboard' | 'logs'; +export type SettingsSection = 'general' | 'libraries' | 'live-tv' | 'providers' | 'scheduled' | 'users' | 'dashboard' | 'logs'; /** Describes a resolved browse filter used by category, collection, and playlist detail pages. */ export interface BrowseFilter { @@ -105,6 +106,8 @@ declare global { /** Mutable state for the browser client between render passes. */ export interface AppState { + /** Live channels and personal DVR data are scoped to the current account. */ + liveTv?: import('./liveTvActions').LiveTvState; apiBase: string; apiMode: ApiMode; route: AppRoute; @@ -168,6 +171,9 @@ export interface AppState { isLoading: boolean; isPlayerOpen: boolean; activePlaybackItem?: MediaItemDetail; + activeTvPlayback?: TvPlayback; + /** Independently buffered Live TV tiles; the active tile owns the shared player controls and audio. */ + tvMultiview?: import('./tvMultiview').TvMultiview; activePlaybackSession?: PlaybackSession; activePlaybackStartMs: number; activePlaybackQuality?: import('./playbackOptions').PlaybackQuality; @@ -196,6 +202,13 @@ export interface AppState { }; } +/** One account-owned TV stream, retained when its tile becomes the main view. */ +export interface TvPlayback { + owner: number; kind: 'channels' | 'recordings'; id: number; title: string; url: string; + image?: string; logo?: string; logoFallback?: string; guideOpen?: boolean; + buffer?: import('../liveTvApi').TvBufferInfo; startMs?: number; +} + /** Lucide icon names intentionally allowed by the web UI renderer. */ export type AppIconName = | 'highlighter' @@ -219,6 +232,7 @@ export type AppIconName = | 'chevron-right' | 'chevron-up' | 'check' + | 'circle' | 'circle-check' | 'clapperboard' | 'database-zap' @@ -231,6 +245,7 @@ export type AppIconName = | 'languages' | 'layers' | 'layout-grid' + | 'layout-panel-left' | 'link-2' | 'log-in' | 'log-out' @@ -256,6 +271,7 @@ export type AppIconName = | 'settings' | 'skip-back' | 'skip-forward' + | 'step-forward' | 'sliders-horizontal' | 'star' | 'trash-2' diff --git a/crates/client-web/src/liveTvApi.ts b/crates/client-web/src/liveTvApi.ts new file mode 100644 index 00000000..0a788b13 --- /dev/null +++ b/crates/client-web/src/liveTvApi.ts @@ -0,0 +1,167 @@ +/** Defines Live TV guide, setup, playback, and personal DVR API contracts. */ +import { requestJson, resolveApiUrl, getStoredAuthToken } from './api'; + +/** A playable channel; upstream stream credentials are never returned. */ +export interface TvChannel { + id: number; source_id: number; source_key: string; name: string; number: string; + group_name: string; logo?: string | null; guide_key: string; guide_override?: string | null; + enabled: boolean; available: boolean; + provider_id?:string|null; +} +/** Provider metadata retained with both guide programs and recordings. */ +export interface TvMetadata { + subtitle?: string | null; description?: string | null; categories?: string[]; image?: string | null; + rating?: string | null; episode?: string | null; episode_id?: string | null; credits?: string[]; is_new?: boolean | null; poster?: string | null; + is_live?: boolean | null; is_premiere?: boolean | null; is_finale?: boolean | null; + year?: number | null; series_id?: string | null; artwork_sources?: string[]; +} +/** A scheduled broadcast using UTC seconds. */ +export interface TvProgram { + id: number; source_id: number; guide_key: string; starts_at: number; ends_at: number; + title: string; metadata: TvMetadata; +} +/** A guide station that can be assigned explicitly to a tuner or IPTV channel. */ +export interface TvStation { source_id: number; guide_key: string; name: string; number: string; aliases: string[]; logo?: string | null } +/** An administrator's configured tuner or playlist and refresh health. */ +export interface TvSource { id: number; name: string; kind: string; enabled: boolean; capacity: number; next_refresh: number; last_error?: string | null;channel_count?:number;playlist_bytes?:number|null;guide_bytes?:number|null;image_url?:string|null;iptv_mode?:string|null;providers?:TvProvider[] } +/** A provider within an IPTV connection; identity remains stable when its display name or image changes. */ +export interface TvProvider {source_id:number;id:string;name:string;enabled:boolean;channel_count:number;image_url?:string|null;icon_channel_id?:number|null;has_custom_icon:boolean} +/** Named guide lineup combining independent source connections and stream capacities. */ +export interface TvSourceGroup { id:string; name:string; source_ids:number[] } +/** Editable source settings; a stored PIN stays masked and can be preserved on save. */ +export interface TvSourceConnection { source:TvSourceInput; has_pin:boolean } +/** Public source labels for the guide, without setup details or credentials. */ +export interface TvGuideSource { id: number; name: string; kind: string;image_url?:string|null;iptv_mode?:string|null } +/** Account-specific channel lists synchronized by the server. */ +export interface TvPreferences { favorites: number[]; recent: number[] } +/** A private, recording-backed live viewing session and its currently seekable range. */ +export interface TvBufferInfo { + session_id: string; channel_id: number; title: string; start_ms: number; end_ms: number; + recording_id?: number | null; finished: boolean; error?: string | null; can_keep: boolean; +} +/** Admit a cancellable tune immediately; playback waits for video while the seekable buffer grows. */ +export function startTvBuffer(channel_id: number): Promise { return requestJson('POST','/api/v1/live-tv/sessions',{channel_id,wait:false}); } +/** Renew the viewing session and read the rolling buffer range. */ +export function getTvBuffer(id: string): Promise { return requestJson('GET',`/api/v1/live-tv/sessions/${encodeURIComponent(id)}`); } +/** Stop temporary capture, preserving an explicitly kept DVR recording. */ +export function closeTvBuffer(id: string): Promise { return requestJson('DELETE',`/api/v1/live-tv/sessions/${encodeURIComponent(id)}`); } +/** Preserve buffered content and continue recording the current program. */ +export function keepTvBuffer(id: string, privateRecording: boolean): Promise { return requestJson('POST',`/api/v1/live-tv/sessions/${encodeURIComponent(id)}/keep`,{private:privateRecording}); } +/** Resolve a buffer playback URL without exposing upstream credentials. */ +export function tvBufferStreamUrl(id: string, start: number, quality: string, attempt: number): string { + return resolveApiUrl(`/api/v1/live-tv/sessions/${encodeURIComponent(id)}/stream?start_ms=${Math.max(0,Math.floor(start))}&quality=${encodeURIComponent(quality)}&attempt=${attempt}`); +} +/** Release a buffer when the browser exits, retaining authorization on the keepalive request. */ +export function closeTvBufferOnExit(id: string): void { + const token = getStoredAuthToken(); + if (token) void fetch(resolveApiUrl(`/api/v1/live-tv/sessions/${encodeURIComponent(id)}`), {method:'DELETE',credentials:'include',keepalive:true,headers:{Authorization:`Bearer ${token}`}}).catch(()=>undefined); +} +/** Source connection, guide, and optional native tuner settings supplied during setup. */ +export interface TvSourceConfig { + url: string; pin: string; xmltv_url: string; guide_kind: string; + iptv_mode?:string;provider_grouping?:boolean;bridge_capacity?:number; + artwork_provider?: string; + preserve_pin?:boolean; + adapter?: number; frontend?: number; zap_path?: string; multiplex_urls?: string[]; + zap2it?: TvGuideSelection; refresh_hours?: number; + native_device?: string; delivery_system?: string; region?: string; tuning_options?: string; +} +/** Discover driver-backed tuners attached to the Koko server. */ +export function discoverTvTuners(): Promise<{ id: string; name: string; info: string }[]> { return requestJson('GET', '/api/v1/live-tv/tuners'); } +/** Frequency regions provided by Koko's bundled tuner support. */ +export function getTvTunerRegions(): Promise { return requestJson('GET', '/api/v1/live-tv/tuners/regions'); } +/** A tuner or server advertised on the Koko server's local network. */ +export interface TvDiscoveredSource { name: string; url: string; capacity: number } +/** Search local advertisements for the selected source type. */ +export function discoverTvSources(kind: string): Promise { return requestJson('GET', `/api/v1/live-tv/discover/${encodeURIComponent(kind)}`, undefined, 20000); } +/** Scan a local tuner's terrestrial channels again. */ +export function rescanTvTuner(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/sources/${id}/scan`); } +/** Selected Zap2It lineup and country/postal lookup context. */ +export interface TvGuideSelection { country: string; postal_code: string; lineup_id: string; headend_id: string; device: string; timezone: string; days: number } +/** A postal-code guide provider, including OTA, cable, or satellite. */ +export interface TvGuideProvider { type: string; device: string; lineupId: string; name: string; location: string; timezone: string; headendId: string } +/** Administrator-controlled storage and per-account DVR permissions. */ +export interface TvDvrSettings { settings: { recording_dir: string }; users: { user_id: number; username: string; can_record: boolean }[] } +/** Find guide providers available for an American or Canadian postal code. */ +export function findTvGuideProviders(country: string, postalCode: string): Promise { return requestJson('GET', `/api/v1/live-tv/guide/providers?country=${encodeURIComponent(country)}&postal_code=${encodeURIComponent(postalCode)}`); } +/** Read DVR settings visible only to administrators. */ +export function getTvDvrSettings(): Promise { return requestJson('GET', '/api/v1/live-tv/dvr/settings'); } +/** Change the save location for future recordings. */ +export function setTvDvrSettings(recordingDir: string): Promise { return requestJson('PUT', '/api/v1/live-tv/dvr/settings', { recording_dir: recordingDir }); } +/** Allow or deny scheduling for one account. */ +export function setTvRecordingPermission(userId: number, canRecord: boolean): Promise { return requestJson('PUT', `/api/v1/live-tv/dvr/permissions/${userId}`, { can_record: canRecord }); } +/** A supported source and its simultaneous stream allowance. */ +export interface TvSourceInput { name: string; kind: string; capacity: number; config: TvSourceConfig } +/** A recording timer or completed capture belonging to one account. */ +export interface TvRecording { + id: number; user_id: number; channel_id: number; rule_id?: number | null; title: string; + starts_at: number; ends_at: number; padding_before: number; padding_after: number; + state: string; has_video: boolean; last_error?: string | null; metadata: TvMetadata; + shared: boolean; +} +/** A series reservation on a selected channel. */ +export interface TvSeriesRule { id: number; user_id: number; channel_id: number; title: string; new_only: boolean; enabled: boolean; shared: boolean } +/** A one-off timer or request for a recurring series. */ +export interface TvSchedule { + channel_id: number; title: string; starts_at: number; ends_at: number; series: boolean; + new_only: boolean; padding_before: number; padding_after: number; + private?: boolean; +} +/** Read enabled channels or the administrator's complete channel list. */ +export function getTvChannels(setup = false): Promise { return requestJson('GET', `/api/v1/live-tv/${setup ? 'setup/' : ''}channels`); } +/** Fetch at most seven days of schedules and available guide station mappings. */ +export function getTvGuide(start: number, end: number): Promise<{ programs: TvProgram[]; stations: TvStation[]; sources?: TvGuideSource[]; preferences?: TvPreferences;source_groups?:TvSourceGroup[];providers?:TvProvider[] }> { return requestJson('GET', `/api/v1/live-tv/guide?start=${start}&end=${end}`); } +/** Rename/show a provider and optionally keep its icon linked to one of its own channels. */ +export function setTvProvider(source:number,id:string,body:{name:string;enabled:boolean;icon_channel_id:number|null;remove_icon:boolean}):Promise {return requestJson('PUT',`/api/v1/live-tv/sources/${source}/providers/${encodeURIComponent(id)}`,body);} +/** Upload or restore source/provider artwork, using the authenticated image endpoint. */ +export async function setTvSourceImage(source:number,provider:string|undefined,file?:File):Promise { + if (file && file.size>5*1024*1024) throw new Error('Choose an image smaller than 5 MiB.'); + const token=getStoredAuthToken(),params=provider?`?provider=${encodeURIComponent(provider)}`:''; + const response=await fetch(resolveApiUrl(`/api/v1/live-tv/sources/${source}/image${params}`),{method:file?'PUT':'DELETE',credentials:'include',headers:token?{Authorization:`Bearer ${token}`}:{},body:file}); + if(!response.ok) {const body=await response.json().catch(()=>({})) as {message?:string};throw new Error(body.message ?? 'Unable to update the icon.');} +} +/** Load saved source settings on demand for the administrator's connection editor. */ +export function getTvSourceConnection(id:number):Promise { return requestJson('GET',`/api/v1/live-tv/sources/${id}/connection`); } +/** Persist shared lineup groups while leaving their member connections independent. */ +export function setTvSourceGroups(groups:TvSourceGroup[]):Promise { return requestJson('PUT','/api/v1/live-tv/source-groups',groups); } +/** Update a channel favorite for the signed-in account. */ +export function setTvFavorite(id: number, favorite: boolean): Promise { return requestJson('PUT', `/api/v1/live-tv/channels/${id}/favorite`, {favorite}); } +/** Read administrator source health. */ +export function getTvSources(): Promise { return requestJson('GET', '/api/v1/live-tv/sources'); } +/** Read recordings and scheduling conflicts visible to the signed-in account. */ +export function getTvRecordings(): Promise<{ recordings: TvRecording[]; conflicts: number[]; can_record: boolean }> { return requestJson('GET', '/api/v1/live-tv/recordings'); } +/** Read the signed-in account's recurring rules. */ +export function getTvRules(): Promise { return requestJson('GET', '/api/v1/live-tv/rules'); } +/** Save a source and report guide or channel refresh errors without losing its configuration. */ +export function addTvSource(source: TvSourceInput): Promise<{ id: number; refresh_error?: string | null }> { return requestJson('POST', '/api/v1/live-tv/sources', source, 180000); } +/** Replace all source connection settings while preserving channels and DVR schedules. */ +export function replaceTvSource(id: number, source: TvSourceInput): Promise { return requestJson('PUT', `/api/v1/live-tv/sources/${id}/connection`, source, 180000); } +/** Refresh a configured source immediately. */ +export function refreshTvSource(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/sources/${id}/refresh`, undefined, 180000); } +/** Remove a source and its future schedules while preserving saved recordings. */ +export function removeTvSource(id:number):Promise {return requestJson('DELETE',`/api/v1/live-tv/sources/${id}`);} +/** Update source availability and stream capacity. */ +export function setTvSource(source: Pick): Promise { return requestJson('PUT', `/api/v1/live-tv/sources/${source.id}`, source); } +/** Set channel visibility and an optional explicit guide station. */ +export function setTvChannel(id: number, enabled: boolean, guideOverride: string | null): Promise { return requestJson('PUT', `/api/v1/live-tv/channels/${id}`, { enabled, guide_override: guideOverride }); } +/** Reserve one broadcast or a recurring series. */ +export function scheduleTvRecording(timer: TvSchedule): Promise { return requestJson('POST', '/api/v1/live-tv/recordings', timer); } +/** Stop a capture or cancel a timer while retaining any saved video. */ +export function cancelTvRecording(id: number): Promise { return requestJson('POST', `/api/v1/live-tv/recordings/${id}/cancel`); } +/** Delete the current account's stopped recording and saved video. */ +export function deleteTvRecording(id: number): Promise { return requestJson('DELETE', `/api/v1/live-tv/recordings/${id}`); } +/** Remove a recurring series rule and cancel its future timers. */ +export function deleteTvRule(id: number): Promise { return requestJson('DELETE', `/api/v1/live-tv/rules/${id}`); } +/** Resolve authenticated same-server video URLs without including credentials in the URL. */ +export function tvStreamUrl(kind: 'channels' | 'recordings', id: number): string { return resolveApiUrl(`/api/v1/live-tv/${kind}/${id}/stream`); } +/** Upload XMLTV directly when the guide is stored on the user's computer. */ +export async function uploadTvGuide(id: number, file: File): Promise { + const token = getStoredAuthToken(); + const response = await fetch(resolveApiUrl(`/api/v1/live-tv/sources/${id}/xmltv`), { + method: 'POST', credentials: 'include', body: file, headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({})) as { message?: string }; + throw new Error(body.message ?? 'The XMLTV guide could not be imported.'); + } +} diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css index 64fb8a8e..60ce38d1 100644 --- a/crates/client-web/src/style.css +++ b/crates/client-web/src/style.css @@ -1318,6 +1318,26 @@ legend { justify-content: flex-end; } +.home-feature-media { + position: absolute; + inset: 0 0 0 auto; + width: min(72%, 1040px); + height: 100%; + pointer-events: none; +} + +.home-feature.has-media-preview::before { background-image: none; } +.home-feature-media .player-overlay { pointer-events: auto; } +.home-pinned-header:has(.media-player-overlay:not(.tv-preview-player)), +.home-feature:has(.media-player-overlay:not(.tv-preview-player)) { z-index: 40; overflow: visible; } +.home-feature-copy.has-poster { display: grid; grid-template-columns: 110px minmax(0, 1fr); align-items: start; } +.home-feature-poster { width: 100%; aspect-ratio: 2 / 3; object-fit: contain; border-radius: var(--radius-control); } +.home-feature-details { display: flex; flex-direction: column; gap: .75rem; min-width: 0; } +.home-feature-copy.has-poster:has(.home-feature-poster[hidden]) { grid-template-columns: minmax(0, 1fr); } +@media (max-width: 700px) { + .home-feature-copy.has-poster { grid-template-columns: 75px minmax(0, 1fr); } +} + .search-results-section, .search-results-list { display: flex; diff --git a/crates/client-web/tests/dom-patching.test.mjs b/crates/client-web/tests/dom-patching.test.mjs new file mode 100644 index 00000000..0dc04eb4 --- /dev/null +++ b/crates/client-web/tests/dom-patching.test.mjs @@ -0,0 +1,159 @@ +/** Verifies player continuity across guide updates and intentional stream replacements. */ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { JSDOM } from 'jsdom'; + +const dom = new JSDOM('
', {pretendToBeVisual:true}); +for (const key of ['document','Node','Element','HTMLElement','HTMLMediaElement','HTMLInputElement','HTMLTextAreaElement','HTMLSelectElement','HTMLDetailsElement']) globalThis[key] = dom.window[key]; +globalThis.CSS = {escape:String}; +globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); +globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); +const server = await createServer({root:fileURLToPath(new URL('..',import.meta.url)),server:{middlewareMode:true,hmr:false,ws:false}}); +after(async()=>{await server.close();dom.window.close();}); +const {setElementHtml} = await server.ssrLoadModule('/src/app/domPatcher.ts'); +const {bindTvGuideMappings} = await server.ssrLoadModule('/src/app/liveTvMapping.ts'); +const {rememberTvGuideFocus,tvGuideNavigationTarget,tvGuideNavigationTime,restoreTvGuideFocus} = await server.ssrLoadModule('/src/app/tvGuideNavigation.ts'); +const root = document.querySelector('#app'); +const player = source => `
`; + +test('background refreshes preserve discovered connection drafts and changing type resets them',()=>{ + const form = (kind,owner=1) => `
`; + root.innerHTML=form('hdhomerun'); + const capacity=root.querySelector('input');capacity.value='4'; + root.querySelector('[data-tv-discovery-results]').textContent='Living room tuner selected'; + setElementHtml(root,form('hdhomerun')); + assert.equal(root.querySelector('input'),capacity); + assert.equal(capacity.value,'4'); + assert.equal(root.querySelector('[data-tv-discovery-results]').textContent,'Living room tuner selected'); + setElementHtml(root,form('nextpvr')); + assert.equal(root.querySelector('input').value,'1'); + assert.equal(root.querySelector('[data-tv-discovery-results]').textContent,''); + root.querySelector('input').value='8'; + setElementHtml(root,form('nextpvr',2)); + assert.equal(root.querySelector('input').value,'1'); +}); + +test('an open station selector survives background patches and compacts when focus leaves',()=>{ + const markup=''; + root.innerHTML=markup; + const control=root.querySelector('select'); + const controller=new dom.window.AbortController(); + bindTvGuideMappings(root,controller.signal,[ + {source_id:1,guide_key:'chosen',name:'Station',number:'1',aliases:[]}, + {source_id:1,guide_key:'other',name:'Other station',number:'2',aliases:[]}, + ]); + control.focus();control.value='chosen'; + setElementHtml(root,markup); + assert.equal(document.activeElement,control); + assert.equal(control.options.length,3); + assert.equal(control.value,'chosen'); + control.blur(); + assert.equal(control.options.length,2); + controller.abort(); +}); + +test('adding a navigation hint preserves the mounted video and player controls',()=>{ + root.innerHTML = `
${player('/buffer/one')}
`; + const video = document.querySelector('video'); + setElementHtml(root,`
${player('/buffer/one')}
`); + assert.equal(document.querySelector('video'),video); + assert.equal(document.querySelector('aside').textContent,'Navigation hint'); + setElementHtml(root,`
${player('/buffer/one')}
`); + assert.equal(document.querySelector('video'),video); +}); + +test('controlled source editors honor their state while ordinary settings panels retain expansion',()=>{ + root.innerHTML='
Edit source
Settings
'; + setElementHtml(root,'
Edit source
Settings
'); + assert.equal(document.querySelector('#source').open,false); + assert.equal(document.querySelector('#ordinary').open,true); +}); + +test('changing the media source replaces the decoder element even with the same player id',()=>{ + root.innerHTML = player('/buffer/one'); + const previous = document.querySelector('video'); + setElementHtml(root,player('/buffer/two')); + assert.notEqual(document.querySelector('video'),previous); + assert.equal(document.querySelector('video').getAttribute('src'),'/buffer/two'); +}); + +test('background updates leave owned player controls, focus, and in-progress clicks intact',()=>{ + const markup = key => `
`; + root.innerHTML=markup('one'); + const video=document.querySelector('video'); video.volume=0.27; + const section=document.querySelector('section'); section.className='is-controls-hidden'; + const select=document.querySelector('select'); select.value='720p'; select.focus(); + const icon=document.querySelector('svg'); + setElementHtml(root,markup('one')); + assert.equal(document.querySelector('select'),select); + assert.equal(document.activeElement,select); + assert.equal(select.value,'720p'); + assert.equal(document.querySelector('svg'),icon); + assert.equal(section.className,'is-controls-hidden'); + assert.equal(video.volume,0.27); + setElementHtml(root,markup('expanded')); + assert.equal(section.className,'is-controls-visible'); +}); + +test('a pending patch restore does not undo subsequent keyboard or pointer scrolling',async()=>{ + root.innerHTML='
'; + const guide=document.querySelector('#guide'); guide.scrollTop=100; + setElementHtml(root,'
'); + guide.scrollTop=300; + await new Promise(resolve=>requestAnimationFrame(resolve)); + assert.equal(guide.scrollTop,300); +}); + +function guideFixture(start = 0) { + const ranges=[[[0,1800],[1800,3600],[3600,7200]],[[0,7200]],[[0,900],[900,1800],[1800,2700],[2700,3600],[3600,7200]],[],[[0,1800],[2400,7200]]]; + const rows=ranges.map((row,index)=>{ + const programs=row.map(([from,to],id)=>to>start?``:'').join(''); + return `
${programs}
`; + }).join(''); + return `
${rows}
`; +} + +test('guide navigation retains a time through long broadcasts, short entries, gaps, and empty rows',()=>{ + rememberTvGuideFocus(null); root.innerHTML=guideFixture(); + let control=document.querySelector('#p-0-1'); control.focus(); rememberTvGuideFocus(control); + const move=direction=>{control=tvGuideNavigationTarget(control,direction).target;control.focus();rememberTvGuideFocus(control);return control.id;}; + assert.equal(move('down'),'p-1-0'); + assert.equal(tvGuideNavigationTime(control),1800); + assert.equal(move('down'),'p-2-2'); + assert.equal(move('down'),''); + assert.equal(move('down'),'p-4-0'); + assert.equal(tvGuideNavigationTime(control),1800); + assert.equal(move('up'),''); + assert.equal(move('up'),'p-2-2'); + assert.equal(move('right'),'p-2-3'); + assert.equal(tvGuideNavigationTime(control),2700); + assert.equal(move('up'),'p-1-0'); + assert.equal(move('up'),'p-0-1'); + assert.equal(tvGuideNavigationTime(control),2700); +}); + +test('an expired focused broadcast moves to the same channel at the new guide start',()=>{ + rememberTvGuideFocus(null); root.innerHTML=guideFixture(); + const old=document.querySelector('#p-0-0');old.focus();rememberTvGuideFocus(old); + setElementHtml(root,guideFixture(1800)); + assert.equal(document.activeElement,document.body); + const target=restoreTvGuideFocus(); + assert.equal(target.id,'p-0-1'); + assert.equal(tvGuideNavigationTime(target),1800); + target.focus();rememberTvGuideFocus(target); + assert.equal(restoreTvGuideFocus(),undefined); + target.blur(); + assert.equal(target.isConnected,true); + assert.equal(restoreTvGuideFocus(),target); +}); + +test('a queued render restore cannot undo a newer navigation focus',async()=>{ + rememberTvGuideFocus(null);root.innerHTML=''; + document.querySelector('#before').focus(); + setElementHtml(root,''); + document.querySelector('#after').focus(); + await new Promise(resolve=>requestAnimationFrame(resolve)); + assert.equal(document.activeElement.id,'after'); +}); diff --git a/crates/client-web/tests/e2e/iptv-providers.spec.mjs b/crates/client-web/tests/e2e/iptv-providers.spec.mjs new file mode 100644 index 00000000..3e53a1d6 --- /dev/null +++ b/crates/client-web/tests/e2e/iptv-providers.spec.mjs @@ -0,0 +1,151 @@ +/** Exercises one-address FastChannels setup, reusable provider grouping, and persistent icon choices. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; + +async function openSettingsSource(card) { + if(!(await card.evaluate(element=>element.open))) await card.locator(':scope > summary').click(); + await expect(card).toHaveJSProperty('open',true); +} + +test('IPTV groups FastChannels and E+TV providers and keeps channel-linked icons current',async({page},testInfo)=>{ + test.setTimeout(180000); + let revision=1,sourceId,headers; + const requested=new Set(); + const icon=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'); + const stamp=seconds=>new Date(seconds*1000).toISOString().slice(0,19).replaceAll(/[-T:]/g,'')+' +0000'; + const fixture=createServer((request,response)=>{ + const base=`http://127.0.0.1:${fixture.address().port}`;requested.add(request.url); + response.setHeader('Content-Type','application/x-mpegurl'); + if(request.url==='/feeds/default/native/m3u') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="pluto.news" tvg-id="pluto.news" tvg-chno="100" tvg-logo="${base}/pluto.png" group-title="News",Pluto News\n${base}/native.ts\n`); + else if(request.url==='/feeds/default/m3u/prismcast') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="espn.espn1" tvg-id="espn.espn1" tvg-chno="200" tvg-logo="${base}/espn-${revision}.png" group-title="Sports",ESPN\n${base}/bridge.m3u8\n`); + else if(request.url==='/feeds/default/m3u/prismcast/gracenote') response.end(`#EXTM3U\n#EXTINF:-1 channel-id="sling.sports" tvc-guide-stationid="123" tvg-chno="300" group-title="Sports",Sling Sports\n${base}/other.m3u8\n#EXTINF:-1 channel-id="fox_one.fox_sports" tvg-id="fox_one.fox_sports" tvg-chno="400" group-title="Sports",FOX Sports\n${base}/fox.m3u8\n`); + else if(request.url==='/feeds/default/native/epg.xml') { + const now=Math.floor(Date.now()/1000);response.setHeader('Content-Type','application/xml'); + response.end(''+['pluto.news','espn.espn1','sling.sports'].map(id=>`${id}${id} program`).join('')+''); + }else if(request.url==='/eplus.m3u') response.end(`#EXTM3U\n#EXTINF:0 channel-id="foxone.FOX" tvg-id="410.eplustv" tvg-chno="410",FOX\n${base}/fox.ts\n`); + else if(request.url.endsWith('.png')) {response.setHeader('Content-Type','image/png');response.end(icon);} + else {response.statusCode=404;response.end();} + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try { + const base=`http://127.0.0.1:${fixture.address().port}`; + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + await page.goto('/settings/live-tv'); + const addPanel=page.locator('#tv-add-source-panel');if(!(await addPanel.evaluate(element=>element.open))) await addPanel.locator('summary').click(); + await page.locator('#tv-source-kind').selectOption('m3u'); + const form=page.locator('#tv-source-form');await form.locator('[name="name"]').fill('FastChannels test');await form.locator('[name="iptv_mode"]').selectOption('fastchannels'); + await expect(form.locator('[name="provider_grouping"]')).toBeChecked();await expect(form.getByText('FastChannels server address',{exact:true})).toBeVisible(); + await form.locator('[name="url"]').fill(base);await form.locator('[name="capacity"]').fill('5');await form.getByRole('button',{name:'Add source',exact:true}).click(); + await expect.poll(async()=>{ + const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json();const source=sources.find(s=>s.name==='FastChannels test');sourceId=source?.id;return source?.channel_count; + },{timeout:45000}).toBe(4); + for(const path of ['/feeds/default/native/m3u','/feeds/default/m3u/prismcast','/feeds/default/m3u/prismcast/gracenote','/feeds/default/native/epg.xml'])expect(requested.has(path)).toBeTruthy(); + await page.reload(); + const card=page.locator(`[data-tv-source-card="${sourceId}"]`); + await expect(card).toHaveJSProperty('open',false); + await expect(card).toContainText('4 channels'); + await expect(card.locator('.tv-source-heading img')).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); + await page.goto('/live-tv'); + const presetIcon=page.locator(`[data-tv-source="${sourceId}"] img`); + await expect(presetIcon).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); + await expect.poll(()=>presetIcon.evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + const fastFox=page.locator(`[data-tv-provider="${sourceId}:fox_one"]`); + await expect(fastFox).toHaveAttribute('aria-label',/^FOX One,/); + await expect(fastFox.locator('img')).toHaveAttribute('src',/\/assets\/providers\/tv-foxone\.ico$/); + await fastFox.scrollIntoViewIfNeeded(); + await expect.poll(()=>fastFox.locator('img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + await page.goto('/settings/live-tv'); + await openSettingsSource(card); + const details=card.locator(`[data-tv-provider-editor="${sourceId}:espn"]`);await details.locator('summary').click(); + await expect(details.locator('summary img')).toHaveAttribute('src',/\/assets\/providers\/tv-espn\.png$/); + const providerForm=details.locator('form'); + const channels=await(await page.request.get('/api/v1/live-tv/setup/channels',{headers})).json();const espn=channels.find(c=>c.source_id===sourceId&&c.name==='ESPN'); + await providerForm.locator('[name="name"]').fill('My ESPN');await providerForm.locator('[name="icon_channel"]').selectOption(`channel:${espn.id}`);await providerForm.getByRole('button',{name:'Save provider',exact:true}).click(); + await expect(details.locator('summary')).toContainText('My ESPN');await expect(details.locator('summary img')).toHaveAttribute('src',`${base}/espn-1.png`); + const sourceIcon=card.getByLabel('Upload icon for FastChannels test',{exact:true});await sourceIcon.setInputFiles({name:'source.png',mimeType:'image/png',buffer:icon}); + await expect(card.locator('.tv-source-heading img')).toHaveAttribute('src',new RegExp(`/api/v1/live-tv/sources/${sourceId}/image`)); + await expect.poll(()=>card.locator('.tv-source-heading img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + await page.screenshot({path:testInfo.outputPath('iptv-provider-settings.png'),fullPage:true}); + await page.goto('/live-tv');await expect(page.locator('[data-tv-provider=""] [aria-label="All providers icon"]')).toBeVisible();await page.locator(`[data-tv-provider="${sourceId}:espn"]`).click(); + await expect(page.locator('.tv-guide-channel')).toHaveCount(1);await expect(page.locator('.tv-guide-channel')).toContainText('ESPN'); + await page.screenshot({path:testInfo.outputPath('iptv-provider-guide.png')}); + revision=2;await page.request.post(`/api/v1/live-tv/sources/${sourceId}/refresh`,{headers}); + await expect.poll(async()=>{ + const guide=await(await page.request.get('/api/v1/live-tv/guide',{headers})).json();return guide.providers.find(p=>p.source_id===sourceId&&p.id==='espn')?.image_url; + },{timeout:45000}).toBe(`${base}/espn-2.png`); + await page.reload();await expect(page.locator(`[data-tv-provider="${sourceId}:espn"] img`)).toHaveAttribute('src',`${base}/espn-2.png`); + await page.goto('/settings/live-tv');await openSettingsSource(card);await details.locator('summary').click(); + await details.locator('[name="enabled"]').uncheck();await details.getByRole('button',{name:'Save provider',exact:true}).click(); + await page.goto('/live-tv');await expect(page.locator(`[data-tv-provider="${sourceId}:espn"]`)).toHaveCount(0); + const visible=await(await page.request.get('/api/v1/live-tv/channels',{headers})).json();expect(visible.some(c=>c.id===espn.id)).toBeFalsy(); + const old=await(await page.request.get('/api/v1/live-tv/setup/channels',{headers})).json();expect(old.find(c=>c.id===espn.id).enabled).toBeTruthy(); + await page.goto('/settings/live-tv');await openSettingsSource(card);await details.locator('summary').click();await details.locator('[name="enabled"]').check();await details.getByRole('button',{name:'Save provider',exact:true}).click(); + await details.getByLabel('Upload icon for My ESPN',{exact:true}).setInputFiles({name:'provider.png',mimeType:'image/png',buffer:icon}); + await expect(details.locator('summary img')).toHaveAttribute('src',new RegExp(`/api/v1/live-tv/sources/${sourceId}/image.*provider=espn`)); + await expect(details.locator('[name="icon_channel"]')).toHaveValue('upload'); + await details.locator('[name="icon_channel"]').selectOption('default');await details.getByRole('button',{name:'Save provider',exact:true}).click();await expect(details.locator('summary img')).toHaveAttribute('src',/\/assets\/providers\/tv-espn\.png$/); + await card.getByRole('button',{name:'Use default icon',exact:true}).click();await expect(card.locator('.tv-source-heading img')).toHaveAttribute('src',/\/assets\/providers\/fastchannels\.svg$/); + await details.getByRole('button',{name:'Manage channels',exact:true}).click();await expect(card.locator('[data-tv-channel-update]')).toHaveCount(1);await expect(card.locator('[data-tv-channel-update]')).toContainText('ESPN'); + await card.locator('[data-tv-connection-source] summary').click();const connection=card.locator('[data-tv-replace-source]');await expect(connection.locator('[name="url"]')).toHaveValue(base);await expect(connection.locator('[name="iptv_mode"]')).toHaveValue('fastchannels');await expect(connection.locator('[name="provider_grouping"]')).toBeChecked(); + const eplus=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'E+TV test',kind:'m3u',capacity:1,config:{url:`${base}/eplus.m3u`,guide_kind:'automatic',provider_grouping:true}}});expect(eplus.ok()).toBeTruthy();const otherId=(await eplus.json()).id; + await expect.poll(async()=>{const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json();return sources.find(s=>s.id===otherId)?.providers[0]?.name;},{timeout:45000}).toBe('FOX One'); + await page.goto('/live-tv');await page.locator(`[data-tv-source="${otherId}"]`).click(); + await expect(page.locator(`[data-tv-provider="${otherId}:foxone"] img`)).toHaveAttribute('src',/\/assets\/providers\/tv-foxone\.ico$/); + await page.request.put(`/api/v1/live-tv/sources/${otherId}`,{headers,data:{enabled:false,capacity:1}}); + }finally{ + if(sourceId&&headers)await page.request.put(`/api/v1/live-tv/sources/${sourceId}`,{headers,data:{enabled:false,capacity:1}}); + await new Promise(done=>fixture.close(done)); + } +}); + +test('Live TV source cards show application icons and support cancelling or confirming removal',async({page},testInfo)=>{ + test.setTimeout(120000); + const ids=[]; + const fixture=createServer((request,response)=>{ + if(request.url==='/lineup.json') {response.setHeader('Content-Type','application/json');response.end('[]');} + else if(request.url.startsWith('/service')) {response.setHeader('Content-Type','application/json');response.end('{"stat":"fail"}');} + else response.end('#EXTM3U\n'); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + let headers; + try { + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + for(const kind of ['hdhomerun','nextpvr','tvheadend']) { + const response=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:`${kind} icon test`,kind,capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}`,pin:'0000',guide_kind:'upload'}}}); + expect(response.ok()).toBeTruthy();ids.push({kind,id:(await response.json()).id}); + } + await page.goto('/settings/live-tv'); + for(const {kind,id} of ids) { + await expect(page.locator(`[data-tv-source-card="${id}"]`)).toHaveJSProperty('open',false); + const icon=page.locator(`[data-tv-source-card="${id}"] .tv-source-heading img`); + await expect(icon).toHaveAttribute('src',new RegExp(`/assets/providers/${kind}\\.`)); + await icon.scrollIntoViewIfNeeded(); + await expect.poll(()=>icon.evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy(); + await expect.poll(()=>icon.locator('..').evaluate(element=>getComputedStyle(element).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); + await page.locator(`[data-tv-source-card="${id}"]`).screenshot({path:testInfo.outputPath(`${kind}-source-card.png`)}); + } + await page.screenshot({path:testInfo.outputPath('live-tv-application-icons.png'),fullPage:true}); + const first=page.locator(`[data-tv-source-card="${ids[0].id}"]`); + await openSettingsSource(first); + await first.getByRole('button',{name:'Remove source',exact:true}).click(); + const modal=page.getByRole('dialog');await expect(modal).toContainText('Saved recordings and their files will be kept'); + await expect(modal.getByRole('button',{name:'Cancel',exact:true})).toBeFocused(); + await page.screenshot({path:testInfo.outputPath('live-tv-remove-confirmation.png')}); + await page.keyboard.press('Escape');await expect(modal).toHaveCount(0);await expect(first).toBeVisible(); + await first.getByRole('button',{name:'Remove source',exact:true}).click();await modal.getByRole('button',{name:'Cancel',exact:true}).click();await expect(first).toBeVisible(); + for(const {id} of ids) { + const card=page.locator(`[data-tv-source-card="${id}"]`); + await openSettingsSource(card); + await card.getByRole('button',{name:'Remove source',exact:true}).click(); + await modal.getByRole('button',{name:'Remove source',exact:true}).click(); + await expect(card).toHaveCount(0); + } + const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json(); + expect(sources.some(source=>ids.some(({id})=>source.id===id))).toBeFalsy(); + }finally{ + if(headers) for(const {id} of ids) await page.request.delete(`/api/v1/live-tv/sources/${id}`,{headers}); + await new Promise(done=>fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs b/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs new file mode 100644 index 00000000..3ffe0ba0 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-guide-actions.spec.mjs @@ -0,0 +1,66 @@ +/** Verifies compact source navigation, direct guide playback, hold menus, and readable DVR dialogs. */ +import {test,expect} from '@playwright/test'; + +async function guide(page){ + const now=Math.floor(Date.now()/1000),start=Math.floor(now/1800)*1800; + const channels=Array.from({length:160},(_,i)=>{let provider='tcl';if(i<150){provider='pluto';}if(i<110){provider='espn';}return {id:i+1,source_id:i<60?1:2,source_key:String(i+1),guide_key:String(i+1),name:`Channel ${i+1}`,number:String(i+1),group_name:'',provider_id:i<60?undefined:provider,logo:`http://127.0.0.1:${process.env.KOKO_E2E_PORT}/assets/providers/tv-espn.png`,enabled:true,available:true};}); + const programs=channels.flatMap(channel=>[0,1].map(i=>({id:channel.id*10+i,source_id:channel.source_id,guide_key:channel.guide_key,title:`Show ${channel.id}${i?' next':''}`,starts_at:i?start+3600:start-600,ends_at:i?start+7200:start+3600,metadata:{description:'Program information',categories:['Sports'],image:`http://127.0.0.1:${process.env.KOKO_E2E_PORT}/assets/providers/tv-foxone.ico`,is_live:!i}}))); + const preferences={favorites:[],recent:[]},tunes=[],recordings=[],errors=[];page.on('pageerror',error=>errors.push(error.message));let failRecording=false; + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[],preferences,sources:[{id:1,name:'NextPVR',kind:'nextpvr'},{id:2,name:'FastChannels',kind:'m3u',iptv_mode:'fastchannels'}],providers:[{source_id:2,id:'espn',name:'ESPN',enabled:true,channel_count:50},{source_id:2,id:'pluto',name:'Pluto TV',enabled:true,channel_count:40},{source_id:2,id:'tcl',name:'TCL TV+',enabled:true,channel_count:10}]}})); + await page.route('**/api/v1/live-tv/recordings',route=>{ + if(route.request().method()==='POST'){recordings.push(route.request().postDataJSON());return route.fulfill(failRecording?{status:400,json:{status:400,message:'Recording fixture failure'}}:{json:{}});} + return route.fulfill({json:{recordings:[],conflicts:[],can_record:true}}); + }); + await page.route('**/api/v1/live-tv/rules',route=>route.fulfill({json:[]})); + await page.route('**/api/v1/live-tv/sessions',route=>{tunes.push(route.request().postDataJSON());return route.fulfill({status:400,json:{status:400,message:'Playback activation fixture'}});}); + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/live-tv');await expect(page.locator('#tv-guide-scroll')).toBeVisible(); + return {tunes,recordings,errors,failRecording:value=>{failRecording=value;}}; +} + +test('source and provider navigation uses large icons, clear nesting, and unclipped focus',async({page},testInfo)=>{ + await guide(page); + const source=page.getByRole('button',{name:'NextPVR, 60 channels',exact:true});await source.focus(); + const geometry=await source.evaluate(button=>{const ring=getComputedStyle(button),icon=button.querySelector('.tv-source-icon').getBoundingClientRect(),rail=button.closest('.tv-source-rail').getBoundingClientRect(),box=button.getBoundingClientRect();return {offset:parseFloat(ring.outlineOffset),icon:icon.width,left:box.left-rail.left,top:box.top-rail.top,text:button.textContent.trim()};}); + expect(geometry.offset).toBeLessThan(0);expect(geometry.icon).toBeGreaterThanOrEqual(60);expect(geometry.left).toBeGreaterThanOrEqual(0);expect(geometry.top).toBeGreaterThanOrEqual(0);expect(geometry.text).toBe('60'); + await page.screenshot({path:testInfo.outputPath('tv-source-focus.png')}); + await page.getByRole('button',{name:'FastChannels, 100 channels',exact:true}).click(); + const provider=page.getByRole('button',{name:'ESPN, 50 channels',exact:true});await page.keyboard.press('Tab');await provider.focus(); + const nested=await provider.evaluate(button=>{const source=document.querySelector('[data-tv-source="2"]').getBoundingClientRect(),box=button.getBoundingClientRect();return {left:box.left-source.left,width:button.querySelector('.tv-source-icon').getBoundingClientRect().width,offset:parseFloat(getComputedStyle(button).outlineOffset)};}); + expect(nested.left).toBeGreaterThan(8);expect(nested.width).toBeGreaterThanOrEqual(48);expect(nested.offset).toBeLessThan(0); + expect(await page.locator('.tv-channel-logo-slot').first().evaluate(icon=>icon.getBoundingClientRect().width)).toBeGreaterThanOrEqual(70); + const tcl=page.getByRole('button',{name:'TCL TV+, 10 channels',exact:true});await expect(tcl.locator('img')).toHaveAttribute('src',/tv-tcl\.png$/);await expect.poll(()=>tcl.locator('img').evaluate(image=>image.complete&&image.naturalWidth>0)).toBeTruthy();await provider.focus(); + await page.screenshot({path:testInfo.outputPath('tv-compact-navigation.png')}); + await tcl.focus();await page.screenshot({path:testInfo.outputPath('tv-tcl-provider.png')}); +}); + +test('guide click and Enter tune the selected channel while holds open recording options without tuning',async({page},testInfo)=>{ + const fixture=await guide(page); + const watch=async(selector,key)=>{ + const previous=fixture.tunes.length,button=page.locator(selector);await button.focus(); + if(key)await page.keyboard.press('Enter');else await button.click(); + await expect.poll(()=>fixture.tunes.length).toBe(previous+1); + expect(fixture.tunes.at(-1).channel_id).toBe(2);await expect(page.getByText('Playback activation fixture',{exact:true})).toBeVisible(); + await page.locator('#close-player').click();await page.getByRole('button',{name:'Stop playback',exact:true}).click(); + }; + await watch('#tv-channel-select-2',false);await watch('#tv-program-2-20',true); + await watch('#tv-program-2-20',false);await watch('#tv-channel-select-2',true); + const count=fixture.tunes.length,program=page.locator('#tv-program-2-20');await program.focus(); + await page.keyboard.down('Enter'); + const options=page.getByRole('dialog',{name:'Channel options',exact:true});await expect(options).toBeVisible();await page.keyboard.up('Enter');expect(fixture.tunes.length).toBe(count); + await options.getByRole('button',{name:'Record program or series',exact:true}).click(); + const dialog=page.getByRole('dialog',{name:'Record program or series',exact:true});await expect(dialog).toBeVisible();await expect(dialog).toContainText('Show 2'); + await dialog.getByLabel('Before (minutes)',{exact:true}).fill('4');await dialog.getByLabel('After (minutes)',{exact:true}).fill('7');await dialog.getByLabel('Private (only my account)',{exact:true}).check();await dialog.getByLabel('New episodes only (series)',{exact:true}).check(); + await page.locator('[data-tv-refresh]').evaluate(button=>button.click());await expect(dialog.getByLabel('Before (minutes)',{exact:true})).toHaveValue('4'); + await page.screenshot({path:testInfo.outputPath('tv-recording-modal.png')}); + fixture.failRecording(true);await dialog.getByRole('button',{name:'Record series',exact:true}).click();await expect(dialog.getByRole('alert')).toHaveText('Recording fixture failure');await expect(dialog.getByLabel('Before (minutes)',{exact:true})).toHaveValue('4'); + fixture.failRecording(false);await page.screenshot({path:testInfo.outputPath('tv-recording-error.png')});await dialog.getByRole('button',{name:'Record series',exact:true}).click();await expect(dialog).toHaveCount(0); + expect(fixture.recordings.at(-1)).toMatchObject({channel_id:2,title:'Show 2',series:true,new_only:true,private:true,padding_before:240,padding_after:420}); + await program.click({button:'right'});await expect(options).toBeVisible();await page.keyboard.press('Escape');await expect(options).toHaveCount(0);await expect(program).toBeFocused(); + const box=await program.boundingBox();await page.mouse.move(box.x+20,box.y+20);await page.mouse.down();await page.waitForTimeout(700);await expect(options).toBeVisible();await page.mouse.up();expect(fixture.tunes.length).toBe(count);await page.keyboard.press('Escape'); + await page.locator('#tv-guide-scroll').evaluate(node=>{node.scrollTop=45*88;});await expect(page.locator('#tv-program-46-460')).toBeAttached();await page.locator('#tv-program-46-460').click({button:'right'});await expect(options).toContainText('Show 46');await page.keyboard.press('Escape'); + await page.setViewportSize({width:390,height:844});await page.locator('#tv-program-46-460').click({button:'right'});await options.getByRole('button',{name:'Record program or series',exact:true}).click();await expect(dialog).toBeVisible(); + const mobile=await dialog.boundingBox();expect(mobile.x).toBeGreaterThanOrEqual(0);expect(mobile.x+mobile.width).toBeLessThanOrEqual(390);await page.screenshot({path:testInfo.outputPath('tv-recording-modal-mobile.png')});await page.keyboard.press('Escape'); + expect(fixture.errors).toEqual([]); +}); diff --git a/crates/client-web/tests/e2e/live-tv-headroom.spec.mjs b/crates/client-web/tests/e2e/live-tv-headroom.spec.mjs new file mode 100644 index 00000000..61cd4ae9 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-headroom.spec.mjs @@ -0,0 +1,78 @@ +/** Starves a real browser decoder briefly and verifies automatic headroom without replacing it. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; + +test('repeated live-edge starvation adds headroom while preserving the connection and player settings',async({page},testInfo)=>{ + test.setTimeout(100000); + const children=new Set();let connections=0,tunes=0,streams=0,capturedAt,holding=false,release=()=>undefined; + const fixture=createServer((request,response)=>{ + connections++;response.writeHead(200,{'Content-Type':'video/mp4','Access-Control-Allow-Origin':'*'}); + const generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG,['-hide_banner','-loglevel','error','-re','-f','lavfi','-i','testsrc2=size=320x180:rate=25','-f','lavfi','-i','sine=sample_rate=48000','-c:v','mpeg4','-g','25','-c:a','aac','-f','mpegts','pipe:1'],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + const encoder=spawn(process.env.KOKO_E2E_FFMPEG,['-hide_banner','-loglevel','error','-i','pipe:0','-c:v','libx264','-preset','ultrafast','-tune','zerolatency','-g','25','-pix_fmt','yuv420p','-c:a','aac','-movflags','frag_keyframe+empty_moov+default_base_moof','-frag_duration','1000000','-f','mp4','pipe:1'],{windowsHide:true,stdio:['pipe','pipe','ignore']}); + children.add(generator);children.add(encoder);encoder.stdin.on('error',()=>undefined);generator.stdout.pipe(encoder.stdin); + const queued=[]; + const flush=()=>{for(const held of queued.splice(0)){response.write(held);}}; + release=()=>{holding=false;flush();}; + encoder.stdout.on('data',chunk=>{ + capturedAt??=Date.now(); + if(holding){queued.push(chunk);return;} + flush();response.write(chunk); + }); + response.once('close',()=>{generator.kill();encoder.kill();}); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.addInitScript(()=>{ + globalThis.tvStartupReserves=[]; + document.addEventListener('ratechange',event=>{ + const video=event.target; + if(video.id==='media-player'&&video.playbackRate>0&&video.currentTime<1&&video.buffered.length){ + globalThis.tvStartupReserves.push(video.buffered.end(0)-video.currentTime); + } + },true); + }); + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const channel={id:1,source_id:1,source_key:'jitter',guide_key:'jitter',name:'Uneven delivery',number:'1',group_name:'Sports',enabled:true,available:true}; + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:[channel]})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs:[],stations:[],preferences:{favorites:[],recent:[]},sources:[{id:1,name:'Jitter fixture',kind:'m3u'}]}})); + await page.route('**/api/v1/live-tv/recordings',route=>route.fulfill({json:{recordings:[],conflicts:[],can_record:true}})); + await page.route('**/api/v1/live-tv/rules',route=>route.fulfill({json:[]})); + await page.context().route(/\/api\/v1\/live-tv\/sessions(?:\/|$)/,route=>{ + const request=route.request(),path=new URL(request.url()).pathname; + if(request.method()==='DELETE')return route.fulfill({status:204}); + if(path.endsWith('/sessions')){tunes++;} + if(path.endsWith('/stream')){streams++;return route.fulfill({status:302,headers:{Location:`http://127.0.0.1:${fixture.address().port}/live.mp4`}});} + return route.fulfill({json:{session_id:'jitter',channel_id:1,title:channel.name,start_ms:0,end_ms:capturedAt?Date.now()-capturedAt:0,finished:false,can_keep:true}}); + }); + await page.goto('/live-tv');await page.locator('#tv-channel-select-1').click(); + const player=page.locator('#media-player'); + await expect.poll(()=>player.evaluate(video=>video.readyState>=2&&video.currentTime>0.1&&video.playbackRate>0),{timeout:25000}).toBeTruthy(); + expect(await page.evaluate(()=>globalThis.tvStartupReserves.some(seconds=>seconds>=1.9))).toBeTruthy(); + const initialStreams=streams,initialConnections=connections; + await player.evaluate(video=>{ + globalThis.originalTvVideo=video;globalThis.liveWaits=0;globalThis.liveHolds=0;globalThis.liveSeeks=0; + video.volume=0.3;video.addEventListener('waiting',()=>globalThis.liveWaits++); + video.addEventListener('ratechange',()=>{if(video.playbackRate===0)globalThis.liveHolds++;}); + video.addEventListener('seeking',()=>globalThis.liveSeeks++); + }); + holding=true; + await expect.poll(()=>page.evaluate(()=>globalThis.liveHolds),{timeout:15000}).toBeGreaterThan(0); + const firstWait=await player.evaluate(video=>video.currentTime);release(); + await expect.poll(()=>player.evaluate(video=>video.currentTime)).toBeGreaterThan(firstWait+2); + holding=true; + await expect.poll(()=>page.evaluate(()=>globalThis.liveHolds),{timeout:15000}).toBeGreaterThanOrEqual(2); + if(await player.evaluate(video=>video.playbackRate===0))await expect(page.locator('.media-player-shell')).toHaveClass(/is-media-loading/); + const result=await player.evaluate(video=>({same:video===globalThis.originalTvVideo,volume:video.volume,holds:globalThis.liveHolds,seeks:globalThis.liveSeeks,position:video.currentTime})); + expect(result.same).toBe(true);expect(result.volume).toBeCloseTo(0.3);expect(result.holds).toBeGreaterThanOrEqual(2); + expect(result.seeks).toBe(0); + release(); + await expect.poll(()=>player.evaluate(video=>video.playbackRate)).toBe(1); + await expect.poll(()=>player.evaluate(video=>video.currentTime),{timeout:15000}).toBeGreaterThan(result.position+3); + expect(tunes).toBe(1);expect(streams).toBe(initialStreams);expect(connections).toBe(initialConnections); + await expect(page.locator('.player-problem')).not.toBeVisible(); + }finally{ + await testInfo.attach('rebuffering',{body:JSON.stringify(await page.evaluate(()=>{const video=document.querySelector('video');return {waits:globalThis.liveWaits,holds:globalThis.liveHolds,seeks:globalThis.liveSeeks,position:video?.currentTime,paused:video?.paused,ended:video?.ended,readyState:video?.readyState,rate:video?.playbackRate,buffered:video?Array.from({length:video.buffered.length},(_,index)=>[video.buffered.start(index),video.buffered.end(index)]):[]};})),contentType:'application/json'}); + try{await page.goto('about:blank',{waitUntil:'commit',timeout:5000});}finally{for(const child of children){child.kill();}fixture.closeAllConnections();await new Promise(done=>fixture.close(done));} + } +}); diff --git a/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs b/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs new file mode 100644 index 00000000..c462e5e1 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-inline-keys.spec.mjs @@ -0,0 +1,66 @@ +/** Verifies encrypted IPTV key handling and high-bitrate streams whose audio starts after video. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; +import {createCipheriv} from 'node:crypto'; + +for(const keySource of ['inline HLS keys','external keys mislabeled as playlists']){ +test(`IPTV with ${keySource} and delayed audio decodes through the temporary TV buffer`,async({page})=>{ + test.setTimeout(120000); + const transport=await new Promise((done,reject)=>{ + const generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG??process.env.KOKO_E2E_FFMPEG??'ffmpeg',[ + '-hide_banner','-loglevel','error','-f','lavfi','-i','testsrc2=size=640x360:rate=25', + '-itsoffset','5','-f','lavfi','-i','sine=frequency=440:sample_rate=48000','-t','12', + // The default 5 MB probe ends before the first audio packet in this stream. + '-c:v','mpeg2video','-b:v','12000000','-minrate','12000000','-maxrate','12000000', + '-bufsize','12000000','-g','25','-c:a','aac','-f','mpegts','pipe:1', + ],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + const chunks=[];generator.stdout.on('data',chunk=>chunks.push(chunk)); + generator.once('error',reject);generator.once('exit',code=>code===0?done(Buffer.concat(chunks)):reject(new Error('Unable to generate encrypted IPTV fixture'))); + }); + const key=Buffer.from('00112233445566778899aabbccddeeff','hex'),iv=Buffer.alloc(16); + const cipher=createCipheriv('aes-128-cbc',key,iv); + const encrypted=Buffer.concat([cipher.update(transport),cipher.final()]); + const inline=`data:text/plain;base64,${key.toString('base64')}`; + const attributes=`METHOD=AES-128,URI="${keySource==='inline HLS keys'?inline:'key.m3u8'}",IV=0x${iv.toString('hex')}`; + let sourceId,sessionId,headers,segments=0,unwanted=0; + const fixture=createServer((request,response)=>{ + if(request.url==='/channels.m3u')response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="inline" tvg-chno="410",Inline Key TV\nhttp://127.0.0.1:${fixture.address().port}/master.m3u8\n`); + else if(request.url==='/master.m3u8'){ + response.setHeader('Content-Type','application/vnd.apple.mpegurl'); + response.end(`#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-SESSION-KEY:${attributes}\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="Unavailable",URI="unavailable.m3u8"\n#EXT-X-STREAM-INF:BANDWIDTH=100000,RESOLUTION=160x90\nunavailable.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=12000000,RESOLUTION=640x360,SUBTITLES="subs"\nmedia.m3u8\n`); + }else if(request.url==='/media.m3u8'){ + response.setHeader('Content-Type','application/vnd.apple.mpegurl'); + response.end(`#EXTM3U\n#EXT-X-VERSION:5\n#EXT-X-TARGETDURATION:12\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-KEY:${attributes}\n#EXTINF:12,\nsegment.ts\n#EXT-X-ENDLIST\n`); + }else if(request.url==='/key.m3u8'){ + // Some providers return a playlist MIME type even though this is a binary AES key. + response.setHeader('Content-Type','application/vnd.apple.mpegurl');response.end(key); + }else if(request.url==='/segment.ts'){ + segments++;response.setHeader('Content-Type','video/mp2t');response.end(encrypted); + }else {unwanted++;response.writeHead(503);response.end();} + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + const added=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Inline Key IPTV',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/channels.m3u`,guide_kind:'automatic'}}});expect(added.ok()).toBeTruthy();sourceId=(await added.json()).id; + let channel; + await expect.poll(async()=>{channel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + await page.goto('/live-tv'); + const tuning=page.waitForResponse(response=>response.url().endsWith('/api/v1/live-tv/sessions')&&response.request().method()==='POST'); + await page.locator(`[data-tv-play="${channel.id}"]`).first().click(); + const tuned=await tuning,session=await tuned.json();expect(tuned.ok(),JSON.stringify(session)).toBeTruthy();sessionId=session.session_id; + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&video.currentTime>0.2&&video.videoWidth===640),{timeout:45000}).toBeTruthy(); + expect(segments).toBeGreaterThan(0); + expect(unwanted).toBe(0); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>{ + const stream=video.captureStream(),count=stream.getAudioTracks().length; + stream.getTracks().forEach(track=>track.stop());return count; + }),{timeout:30000}).toBe(1); + }finally{ + if(sessionId&&headers)await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers}); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + fixture.closeAllConnections();await new Promise(done=>fixture.close(done)); + } +}); +} diff --git a/crates/client-web/tests/e2e/live-tv-multiview-streams.spec.mjs b/crates/client-web/tests/e2e/live-tv-multiview-streams.spec.mjs new file mode 100644 index 00000000..c24b3c7d --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-multiview-streams.spec.mjs @@ -0,0 +1,52 @@ +/** Verifies four independent Koko capture buffers and real browser decoders against a live IPTV fixture. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; + +test('four live buffers play concurrently and release every source lease on exit',async({page},testInfo)=>{ + test.setTimeout(240000); + const clients=new Set(),children=new Set();let sourceId,headers,encoder,generator; + const start=()=>{ + if(encoder)return; + generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG,['-hide_banner','-loglevel','error','-re','-f','lavfi','-i','testsrc2=size=320x180:rate=25','-f','lavfi','-i','sine=frequency=440:sample_rate=48000','-c:v','mpeg4','-q:v','5','-g','25','-c:a','aac','-f','mpegts','pipe:1'],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + encoder=spawn(process.env.KOKO_E2E_FFMPEG,['-hide_banner','-loglevel','error','-i','pipe:0','-c:v','libx264','-preset','ultrafast','-tune','zerolatency','-g','25','-pix_fmt','yuv420p','-c:a','copy','-f','mpegts','pipe:1'],{windowsHide:true,stdio:['pipe','pipe','ignore']}); + children.add(generator);children.add(encoder);encoder.stdin.on('error',()=>undefined);generator.stdout.pipe(encoder.stdin); + encoder.stdout.on('data',chunk=>{for(const response of clients)response.write(chunk);}); + }; + const fixture=createServer((request,response)=>{ + if(request.url==='/tv.m3u'){ + const base=`http://127.0.0.1:${fixture.address().port}`; + response.end('#EXTM3U\n'+Array.from({length:4},(_,i)=>`#EXTINF:-1 tvg-id="multi-${i}" tvg-chno="${i+1}",Live Sport ${i+1}\n${base}/stream-${i}.ts\n`).join(''));return; + } + if(request.url?.startsWith('/stream-')){response.setHeader('Content-Type','video/mp2t');clients.add(response);response.on('close',()=>clients.delete(response));start();return;} + response.statusCode=404;response.end(); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + headers={Authorization:`Bearer ${await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'))}`}; + expect((await page.request.put('/api/v1/live-tv/dvr/settings',{headers,data:{recording_dir:testInfo.outputPath('buffers')}})).ok()).toBeTruthy(); + const created=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Four tuners',kind:'m3u',capacity:4,config:{url:`http://127.0.0.1:${fixture.address().port}/tv.m3u`,guide_kind:'automatic'}}}); + expect(created.ok()).toBeTruthy();const added=await created.json();sourceId=added.id;expect(added.refresh_error??null).toBeNull(); + let channels=[];await expect.poll(async()=>{channels=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).filter(c=>c.source_id===sourceId);return channels.length;},{timeout:45000}).toBe(4); + await page.goto('/live-tv');await page.locator(`#tv-channel-select-${channels[0].id}`).click(); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&video.currentTime>0),{timeout:60000}).toBeTruthy(); + for(const channel of channels.slice(1)){ + await page.mouse.move(400,400);await page.locator('#player-multiview').click();await page.locator(`[data-multi-picker-channel="${channel.id}"]`).click(); + } + await expect(page.locator('#tv-player-stage video')).toHaveCount(4); + await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll(videos=>videos.every(video=>video.readyState>=2&&!video.paused&&video.currentTime>0)),{timeout:60000}).toBeTruthy(); + expect(clients.size).toBe(4); + await page.mouse.move(400,400);await page.getByRole('button',{name:'Main and sidebar',exact:true}).click(); + await page.locator(`[data-tv-multi-main="${channels[3].id}"]`).click(); + await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll(videos=>videos.filter(video=>!video.muted).length)).toBe(1); + const frames=await page.locator('#tv-player-stage video').evaluateAll(videos=>videos.map(video=>({video,frames:video.getVideoPlaybackQuality().totalVideoFrames})).map(({frames})=>frames)); + await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll((videos,before)=>videos.every((video,i)=>video.getVideoPlaybackQuality().totalVideoFrames>before[i]),frames)).toBeTruthy(); + await page.screenshot({path:testInfo.outputPath('four-live-streams.png')}); + await page.goto('/'); + await expect.poll(()=>clients.size,{timeout:20000}).toBe(0); + }finally{ + for(const response of clients){response.destroy();}for(const child of children){child.kill();}fixture.close(); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}).catch(()=>undefined); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv-multiview.spec.mjs b/crates/client-web/tests/e2e/live-tv-multiview.spec.mjs new file mode 100644 index 00000000..3496a757 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-multiview.spec.mjs @@ -0,0 +1,102 @@ +/** Exercises multiple real browser decoders, persistent tile swaps, bounded selection, and session cleanup. */ +import {test,expect} from '@playwright/test'; + +async function fixture(page,holdFirst=false){ + const now=Math.floor(Date.now()/1000),channels=Array.from({length:120},(_,i)=>({id:i+1,source_id:i<4?1:2,source_key:String(i+1),guide_key:String(i+1),name:`Sports ${i+1}`,number:String(i+1),group_name:'Sports',provider_id:'espn',enabled:true,available:true})); + const programs=channels.map(c=>({id:c.id,source_id:c.source_id,guide_key:c.guide_key,title:`Game ${c.id}`,starts_at:now-3600,ends_at:now+3600,metadata:{categories:['Sports']}})); + const starts=[],closes=[],streams=[],errors=[],sessions=new Map();let serial=0,fail,hold,release;if(holdFirst)hold=1; + page.on('pageerror',error=>errors.push(error.message)); + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token')),headers={Authorization:`Bearer ${token}`}; + const items=await(await page.request.get('/api/v1/items',{headers})).json(),sample=items.find(i=>i.display_title==='Playback Sample'); + const video=await(await page.request.get(`/api/v1/items/${sample.id}/stream`,{headers})).body(); + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[],preferences:{favorites:[2],recent:[1]},sources:[{id:1,name:'Sports source',kind:'m3u'},{id:2,name:'Other source',kind:'m3u'}],providers:[{source_id:1,id:'espn',name:'ESPN',enabled:true,channel_count:4},{source_id:2,id:'espn',name:'ESPN',enabled:true,channel_count:116}]}})); + await page.route('**/api/v1/live-tv/recordings',route=>route.fulfill({json:{recordings:[],conflicts:[],can_record:true}})); + await page.route('**/api/v1/live-tv/rules',route=>route.fulfill({json:[]})); + await page.context().route(/\/api\/v1\/live-tv\/sessions(?:\/|$)/,async route=>{ + const request=route.request(),url=new URL(request.url()); + if(url.pathname.endsWith('/sessions')){ + const channel=request.postDataJSON().channel_id;starts.push(channel); + if(hold===channel)await new Promise(done=>{release=done;}); + if(fail===channel)return route.fulfill({status:400,json:{status:400,message:'No tuner is available for this source'}}); + const buffer={session_id:`multi-${++serial}`,channel_id:channel,title:`Sports ${channel}`,start_ms:0,end_ms:60000,finished:false,can_keep:true};sessions.set(buffer.session_id,buffer);return route.fulfill({json:buffer}); + } + const id=/\/sessions\/([^/]+)/.exec(url.pathname)?.[1]; + if(request.method()==='DELETE'){closes.push(id);return route.fulfill({status:204});} + if(url.pathname.endsWith('/stream')){streams.push(id);return route.fulfill({body:video,contentType:'video/mp4'});} + return route.fulfill({json:sessions.get(id)}); + }); + await page.goto('/live-tv');await page.locator('#tv-channel-select-1').click(); + if(!holdFirst)await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy(); + return {starts,closes,streams,errors,sessions,fail:id=>{fail=id;},hold:id=>{hold=id;},release:()=>{release?.();},add:async id=>{ + await page.locator('#player-multiview').click(); + const picker=page.getByRole('dialog',{name:'Add a stream'});await expect(picker).toBeVisible();await picker.getByLabel('Find a channel',{exact:true}).fill(`Sports ${id}`);await picker.locator(`[data-multi-picker-channel="${id}"]`).click(); + }}; +} + +test('four streams retain their decoders across layouts and main swaps, with only main audio',async({page},testInfo)=>{ + test.setTimeout(120000);const f=await fixture(page); + await f.add(2);await f.add(3);await f.add(4); + await expect(page.locator('#tv-player-stage video')).toHaveCount(4); + await expect(page.getByRole('button',{name:'Main and sidebar',exact:true})).toHaveAttribute('aria-pressed','true'); + await expect(page.getByRole('button',{name:'Main and sidebar',exact:true}).locator('svg')).toHaveClass(/lucide-layout-panel-left/); + await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll(videos=>videos.every(v=>v.readyState>=2&&!v.paused))).toBeTruthy(); + await page.evaluate(()=>{globalThis.tvVideos=Array.from(document.querySelectorAll('#tv-player-stage video')).map(video=>({video,id:video.closest('section').id}));}); + expect(f.starts).toEqual([1,2,3,4]); + await expect(page.locator('#player-multiview')).toBeDisabled(); + await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll(videos=>videos.filter(v=>!v.muted).length)).toBe(1); + await page.getByRole('button',{name:'Main and sidebar',exact:true}).click(); + const main=await page.locator('#tv-stream-1').boundingBox(),side=await page.locator('#tv-stream-2').boundingBox();expect(main.width).toBeGreaterThan(side.width*2); + await page.screenshot({path:testInfo.outputPath('multiview-focus.png')}); + await page.locator('#tv-stream-3 video').click();await expect(page.locator('#tv-stream-3 #media-player')).toHaveCount(1); + expect(await page.evaluate(()=>globalThis.tvVideos.every(({video})=>video.isConnected))).toBeTruthy(); + expect(f.starts).toEqual([1,2,3,4]);await expect.poll(()=>page.locator('#tv-player-stage video').evaluateAll(videos=>videos.filter(v=>!v.muted).map(v=>v.closest('section').id))).toEqual(['tv-stream-3']); + await page.getByRole('button',{name:'Equal grid',exact:true}).click();await page.screenshot({path:testInfo.outputPath('multiview-grid.png')}); + await page.locator('#player-volume').fill('0.3');await page.locator('#player-volume').dispatchEvent('input'); + await page.locator('#player-quality').selectOption('480p');await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.volume)).toBeCloseTo(0.3,1); + expect(await page.evaluate(()=>globalThis.tvVideos.filter(({id})=>id!=='tv-stream-3').every(({video})=>video.isConnected))).toBeTruthy(); + await page.getByRole('button',{name:'Remove Sports 2',exact:true}).click();await expect(page.locator('#tv-stream-2')).toHaveCount(0);await expect.poll(()=>f.closes.length).toBe(1); + await page.locator('#close-player').click();await expect(page.locator('.tv-preview-player #tv-player-stage video')).toHaveCount(3); + expect(f.closes.length).toBe(1);await page.locator('#expand-tv-player').click();await expect(page.locator('.tv-preview-player')).toHaveCount(0); + await page.getByRole('button',{name:'Watch main only',exact:true}).click();await expect(page.locator('#tv-player-stage video')).toHaveCount(1);await expect(page.locator('#tv-stream-3 #media-player')).toHaveCount(1); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>!video.paused&&video.readyState>=2)).toBeTruthy(); + await expect.poll(()=>f.closes.length).toBe(3);expect(f.starts).toEqual([1,2,3,4]); + await page.locator('#close-player').click();await page.getByRole('button',{name:'Stop playback',exact:true}).click();await expect.poll(()=>f.closes.length).toBe(4);expect(new Set(f.closes).size).toBe(4);expect(f.errors).toEqual([]); +}); + +test('failed and canceled tunes release only their tile, and the picker stays bounded for large lineups',async({page},testInfo)=>{ + const f=await fixture(page);f.fail(2);await f.add(2);await expect(page.locator('#tv-stream-2 .tv-stream-problem')).toContainText('No tuner is available'); + await page.evaluate(()=>{globalThis.tvMain=document.querySelector('#media-player');}); + f.fail(undefined);await page.getByRole('button',{name:'Retry stream',exact:true}).click();await expect.poll(()=>page.locator('#tv-stream-2 video').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy(); + expect(await page.evaluate(()=>globalThis.tvMain===document.querySelector('#media-player'))).toBeTruthy(); + await page.locator('#player-multiview').click();const picker=page.getByRole('dialog',{name:'Add a stream'}); + await expect(picker.locator('[data-multi-picker-channel]')).toHaveCount(50);await expect(picker.locator('[data-multi-picker-channel]').first()).toHaveAttribute('data-multi-picker-channel','3');await picker.getByLabel('Source',{exact:true}).selectOption('1');await expect(picker.locator('[data-multi-picker-channel]')).toHaveCount(4); + await picker.getByLabel('Favorites',{exact:true}).check();await expect(picker.locator('[data-multi-picker-channel]')).toHaveCount(1);await expect(picker.locator('[data-multi-picker-channel="2"]')).toBeDisabled();await page.keyboard.press('Escape'); + f.hold(3);await f.add(3);await expect(page.locator('#tv-stream-3')).toContainText('Starting stream');await expect.poll(()=>f.starts.includes(3)).toBeTruthy();await page.getByRole('button',{name:'Remove Sports 3',exact:true}).click();f.release();await expect.poll(()=>f.closes.length).toBe(1); + await page.setViewportSize({width:390,height:844});await page.locator('#player-multiview').click();await page.screenshot({path:testInfo.outputPath('multiview-picker-mobile.png')});await page.keyboard.press('Escape'); + await page.locator('#close-player').click();await page.locator('#mobile-library-picker').selectOption('/');await expect.poll(()=>f.closes.length).toBe(3);expect(f.errors).toEqual([]); +}); + +test('a late first tune survives promotion, removing main preserves its replacement, and leaving Live TV closes every buffer',async({page})=>{ + const f=await fixture(page,true);await expect.poll(()=>f.starts.includes(1)).toBeTruthy(); + await f.add(2);await expect.poll(()=>page.locator('#tv-stream-2 video').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy(); + await page.locator('[data-tv-multi-main="2"]').click();f.release(); + await expect.poll(()=>page.locator('#tv-stream-1 video').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy();expect(f.closes).toEqual([]); + await page.evaluate(()=>{globalThis.tvReplacement=document.querySelector('#tv-stream-1 video');}); + await page.getByRole('button',{name:'Remove Sports 2',exact:true}).click();await expect(page.locator('#tv-stream-1 #media-player')).toHaveCount(1); + expect(await page.evaluate(()=>globalThis.tvReplacement===document.querySelector('#media-player'))).toBeTruthy();await expect.poll(()=>f.closes.length).toBe(1); + await f.add(3);await expect.poll(()=>page.locator('#tv-stream-3 video').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy(); + await page.locator('#close-player').click();await page.getByRole('button',{name:'Home',exact:true}).click();await expect.poll(()=>f.closes.length).toBe(3);expect(new Set(f.closes).size).toBe(3);expect(f.errors).toEqual([]); +}); + +test('retrying a stopped single-channel capture creates a new buffer instead of seeking the failed one',async({page})=>{ + const f=await fixture(page),buffer=f.sessions.values().next().value; + buffer.error='The upstream capture stopped';buffer.finished=true; + await expect(page.locator('.player-error-indicator')).toContainText('The upstream capture stopped'); + await page.getByRole('button',{name:'Retry playback',exact:true}).click(); + await expect.poll(()=>f.starts.length).toBe(2);await expect.poll(()=>f.closes.length).toBe(1); + expect(f.starts).toEqual([1,1]);expect(f.closes).toEqual([buffer.session_id]); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&!video.paused)).toBeTruthy(); + await expect(page.locator('.player-error-indicator')).not.toBeVisible();expect(f.errors).toEqual([]); +}); diff --git a/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs b/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs new file mode 100644 index 00000000..327e072c --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-source-errors.spec.mjs @@ -0,0 +1,55 @@ +/** Upstream HTTP failures must reach the player as source errors, with safe actionable messages. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; + +test('a missing IPTV stream reports its HTTP status instead of blaming DVR storage',async({page})=>{ + let sourceId,headers; + const fixture=createServer((request,response)=>{ + if(request.url==='/channels.m3u') { + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="missing" tvg-chno="410",Unavailable FOX\nhttp://127.0.0.1:${fixture.address().port}/missing.m3u8\n`); + }else {response.writeHead(404,{'Content-Type':'text/plain'});response.end('404 not found');} + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try { + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + const response=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Unavailable IPTV',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/channels.m3u`,guide_kind:'automatic'}}});expect(response.ok()).toBeTruthy();sourceId=(await response.json()).id; + let channel; + await expect.poll(async()=>{const channels=await(await page.request.get('/api/v1/live-tv/channels',{headers})).json();channel=channels.find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + const playback=await page.request.post('/api/v1/live-tv/sessions',{headers,data:{channel_id:channel.id},timeout:45000}); + expect(playback.status()).toBe(400); + const error=await playback.json();expect(error.message).toContain('HTTP 404');expect(error.message).toContain('source app');expect(error.message).not.toMatch(/disk|127\.0\.0\.1|token/i); + }finally{ + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + await new Promise(done=>fixture.close(done)); + } +}); + +test('the player explains an upstream stream limit instead of a generic HTTP 5xx failure',async({page})=>{ + let sourceId,sessionId,headers,attempts=0; + const fixture=createServer((request,response)=>{ + if(request.url==='/channels.m3u'){ + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="busy" tvg-chno="1",Busy Bridge TV\nhttp://127.0.0.1:${fixture.address().port}/busy.m3u8\n`);return; + } + attempts++;response.writeHead(503,{'Content-Type':'text/plain','Retry-After':'120'}); + response.end('Maximum concurrent streams (4) reached. Private diagnostic: http://private.example/?token=secret'); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + headers={Authorization:`Bearer ${await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'))}`}; + const added=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Busy Bridge',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/channels.m3u`,guide_kind:'upload'}}});expect(added.ok()).toBeTruthy();sourceId=(await added.json()).id; + let channel;await expect.poll(async()=>{channel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + await page.goto('/live-tv');const tuning=page.waitForResponse(r=>r.url().endsWith('/api/v1/live-tv/sessions')&&r.request().method()==='POST'); + await page.locator(`#tv-channel-select-${channel.id}`).click();sessionId=(await(await tuning).json()).session_id; + const notice=page.locator('.player-error-indicator'); + await expect(notice).toContainText('The TV source has reached its concurrent stream limit (4).'); + await expect(notice).toContainText('Stop another stream'); + await expect(notice).not.toContainText('5xx');await expect(notice).not.toContainText('private.example');await expect(notice).not.toContainText('secret'); + expect(attempts).toBe(1); + }finally{ + if(sessionId&&headers)await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers}); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + fixture.closeAllConnections();await new Promise(done=>fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv-startup.spec.mjs b/crates/client-web/tests/e2e/live-tv-startup.spec.mjs new file mode 100644 index 00000000..eb917cdd --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv-startup.spec.mjs @@ -0,0 +1,99 @@ +/** Reproduces transient source errors and a cold IPTV connection that exceeds the former buffer deadline. */ +import {test,expect} from '@playwright/test'; +import {createServer} from 'node:http'; +import {spawn} from 'node:child_process'; + +async function selectChannel(page,channel,first){ + if(first){await page.locator(`#tv-channel-select-${channel.id}`).click();return;} + if(!await page.locator('#player-tv-guide').isVisible()){await page.locator('#player-tv-guide-toggle').click();} + await page.locator(`[data-player-tv-channel="${channel.id}"]`).click(); +} + +test('a cold channel survives a transient HTTP error and more than thirty seconds before its first video bytes',async({page})=>{ + test.setTimeout(150000); + const transport=await new Promise((done,reject)=>{ + const generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG??process.env.KOKO_E2E_FFMPEG??'ffmpeg',['-hide_banner','-loglevel','error','-f','lavfi','-i','testsrc2=size=640x360:rate=25','-f','lavfi','-i','sine=frequency=440:sample_rate=48000','-t','14','-c:v','mpeg2video','-q:v','5','-g','25','-c:a','aac','-f','mpegts','pipe:1'],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + const chunks=[];generator.stdout.on('data',chunk=>chunks.push(chunk));generator.once('error',reject);generator.once('exit',code=>code===0?done(Buffer.concat(chunks)):reject(new Error('Unable to generate startup fixture'))); + }); + let attempts=0,sourceId,sessionId,headers; + const fixture=createServer((request,response)=>{ + if(request.url==='/tv.m3u'){response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="cold" tvg-chno="1",Cold Start TV\nhttp://127.0.0.1:${fixture.address().port}/stream.ts\n`);return;} + if(request.url!=='/stream.ts'){response.writeHead(404);response.end();return;} + attempts++;if(attempts===1){response.writeHead(503);response.end();return;} + const timer=setTimeout(()=>{response.writeHead(200,{'Content-Type':'video/mp2t'});response.write(transport);response.end();},32000); + response.once('close',()=>clearTimeout(timer)); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + headers={Authorization:`Bearer ${await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'))}`}; + const added=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Cold Start',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/tv.m3u`,guide_kind:'upload'}}});expect(added.ok()).toBeTruthy();sourceId=(await added.json()).id; + let channel;await expect.poll(async()=>{channel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===sourceId);return !!channel;},{timeout:45000}).toBeTruthy(); + await page.goto('/live-tv');const started=Date.now(); + const tuning=page.waitForResponse(response=>response.url().endsWith('/api/v1/live-tv/sessions')&&response.request().method()==='POST',{timeout:90000}); + await page.locator(`#tv-channel-select-${channel.id}`).click(); + const tuned=await tuning,session=await tuned.json();expect(tuned.ok(),JSON.stringify(session)).toBeTruthy();sessionId=session.session_id; + expect(Date.now()-started).toBeLessThan(5000);expect(session.end_ms).toBe(0); + await page.waitForTimeout(3500); + await expect(page.locator('.media-player-shell')).not.toHaveClass(/is-controls-hidden/); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&video.currentTime>0.2&&video.videoWidth===640),{timeout:90000}).toBeTruthy(); + expect(Date.now()-started).toBeGreaterThan(30000);expect(attempts).toBe(2); + }finally{ + if(sessionId&&headers)await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers}); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + fixture.closeAllConnections();await new Promise(done=>fixture.close(done)); + } +}); + +test('channel surfing cancels cold captures and plays while the temporary buffer is still growing',async({page})=>{ + test.setTimeout(120000); + let sourceId,sessionId,headers,slowConnections=0,closedSlow=0; + const generators=new Set(); + const fixture=createServer((request,response)=>{ + if(request.url==='/tv.m3u'){ + response.end('#EXTM3U\n'+[1,2,3,4].map(id=>{const path=id===4?'fast':'slow-'+id;return `#EXTINF:-1 tvg-id="surf-${id}" tvg-chno="${id}",Surf ${id}\nhttp://127.0.0.1:${fixture.address().port}/${path}.ts`;}).join('\n')+'\n');return; + } + response.writeHead(200,{'Content-Type':'video/mp2t'});response.flushHeaders(); + if(request.url.startsWith('/slow-')){ + slowConnections++;response.once('close',()=>{slowConnections--;closedSlow++;});return; + } + const generator=spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG??process.env.KOKO_E2E_FFMPEG??'ffmpeg',[ + '-hide_banner','-loglevel','error','-re','-f','lavfi','-i','testsrc2=size=640x360:rate=25', + '-f','lavfi','-i','sine=frequency=440:sample_rate=48000','-t','40', + '-c:v','mpeg2video','-q:v','5','-g','25','-c:a','aac','-f','mpegts','pipe:1', + ],{windowsHide:true,stdio:['ignore','pipe','ignore']}); + generators.add(generator);generator.stdout.pipe(response); + response.once('close',()=>generator.kill());generator.once('exit',()=>generators.delete(generator)); + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + try{ + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + headers={Authorization:`Bearer ${await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'))}`}; + const added=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name:'Channel Surfing',kind:'m3u',capacity:1,config:{url:`http://127.0.0.1:${fixture.address().port}/tv.m3u`,guide_kind:'upload'}}});expect(added.ok()).toBeTruthy();sourceId=(await added.json()).id; + let channels;await expect.poll(async()=>{channels=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).filter(c=>c.source_id===sourceId).sort((a,b)=>Number(a.number)-Number(b.number));return channels.length;},{timeout:45000}).toBe(4); + await page.goto('/live-tv'); + for(let index=0;indexr.url().endsWith('/api/v1/live-tv/sessions')&&r.request().method()==='POST'); + const opening=index===3?page.waitForRequest(r=>new URL(r.url()).pathname.endsWith('/stream')&&r.url().includes('/live-tv/sessions/')):undefined; + await selectChannel(page,channels[index],index===0); + const tuned=await tuning,session=await tuned.json();expect(tuned.ok(),JSON.stringify(session)).toBeTruthy();sessionId=session.session_id; + expect(Date.now()-started).toBeLessThan(5000); + if(index<3)await expect.poll(()=>slowConnections).toBe(1); + else{ + expect(session.end_ms).toBe(0);await opening; + const initial=await(await page.request.get(`/api/v1/live-tv/sessions/${sessionId}`,{headers})).json();expect(initial.end_ms).toBe(0); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2&&video.currentTime>0.2),{timeout:20000}).toBeTruthy(); + const status=await(await page.request.get(`/api/v1/live-tv/sessions/${sessionId}`,{headers})).json(); + expect(status.error).toBeNull();expect(status.finished).toBe(false);expect(status.end_ms).toBeGreaterThan(0); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.currentTime),{timeout:20000}).toBeGreaterThan(10); + } + } + expect(closedSlow).toBe(3);expect(slowConnections).toBe(0); + await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers});sessionId=undefined; + await expect.poll(()=>generators.size).toBe(0); + }finally{ + if(sessionId&&headers)await page.request.delete(`/api/v1/live-tv/sessions/${sessionId}`,{headers}); + if(sourceId&&headers)await page.request.delete(`/api/v1/live-tv/sources/${sourceId}`,{headers}); + for(const generator of generators){generator.kill();}fixture.closeAllConnections();await new Promise(done=>fixture.close(done)); + } +}); diff --git a/crates/client-web/tests/e2e/live-tv.spec.mjs b/crates/client-web/tests/e2e/live-tv.spec.mjs new file mode 100644 index 00000000..ec4aa787 --- /dev/null +++ b/crates/client-web/tests/e2e/live-tv.spec.mjs @@ -0,0 +1,821 @@ +/** Exercises real IPTV import, browser decoding, tuner release, and timed DVR capture. */ +import { test, expect } from '@playwright/test'; +import { createServer } from 'node:http'; +import { spawn } from 'node:child_process'; +import { stat } from 'node:fs/promises'; + +test('IPTV imports its guide, plays live video, releases the tuner, and saves a timed recording', async ({ page }, testInfo) => { + test.setTimeout(300000); + page.setDefaultTimeout(15000); + const children = new Set(); + let activeStreams = 0; + let sourceId; + let headers; + const stamp = seconds => new Date(seconds * 1000).toISOString().slice(0, 19).replaceAll(/[-T:]/g, '') + ' +0000'; + const fixture = createServer((request, response) => { + const base = `http://127.0.0.1:${fixture.address().port}`; + if (request.url === '/tv.m3u') { + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="test-tv" tvg-chno="7.1" tvg-logo="${base}/missing-logo.svg" group-title="News",Live Test TV\n${base}/stream.ts\n#EXTINF:-1 tvg-id="hls-tv" tvg-chno="7.2",HLS Test TV\n${base}/master.m3u8\n#EXTINF:-1 tvg-id="alternate-tv" tvg-chno="7.3",Alternate TV\n${base}/stream.ts\n`); + } else if (request.url === '/logo.svg') { + response.setHeader('Content-Type','image/svg+xml'); + response.end('TV'); + } else if (request.url === '/master.m3u8') { + response.setHeader('Content-Type', 'application/octet-stream'); + response.write('#EXT'); + setImmediate(() => response.end('M3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=640x360\nhls.m3u8\n')); + } else if (request.url === '/hls.m3u8') { + response.setHeader('Content-Type', 'application/vnd.apple.mpegurl'); + response.end('#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:2,\nsegment0.ts\n#EXT-X-DISCONTINUITY\n#EXTINF:2,\nsegment1.ts\n#EXT-X-ENDLIST\n'); + } else if (request.url === '/guide.xml') { + const now = Math.floor(Date.now() / 1000); + response.setHeader('Content-Type', 'application/xml'); + response.end(`Live Test TVLive Test NewsLocal IPTV guide fixture.News`); + } else if (request.url === '/stream.ts' || /^\/segment\d+\.ts$/.test(request.url)) { + activeStreams += 1; + const ffmpeg = spawn(process.env.KOKO_E2E_GENERATOR_FFMPEG ?? process.env.KOKO_E2E_FFMPEG ?? 'ffmpeg', ['-hide_banner', '-loglevel', 'error', '-re', + '-f', 'lavfi', '-i', 'testsrc2=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000', + '-c:v','mpeg4','-q:v','5', '-g', '25', '-c:a', 'aac', '-f', 'mpegts', ...(request.url === '/stream.ts' ? [] : ['-t', '2']), 'pipe:1'], + { windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); + children.add(ffmpeg); + let encoder; + if (request.url === '/stream.ts') { + // The fixture generator has lavfi; Koko's bundled FFmpeg supplies libx264. + encoder=spawn(process.env.KOKO_E2E_FFMPEG,['-hide_banner','-loglevel','error','-i','pipe:0','-c:v','libx264','-preset','ultrafast','-tune','zerolatency','-g','25','-pix_fmt','yuv420p','-c:a','copy','-f','mpegts','pipe:1'],{windowsHide:true,stdio:['pipe','pipe','ignore']}); + children.add(encoder); encoder.stdin.on('error',()=>undefined); + ffmpeg.stdout.pipe(encoder.stdin); encoder.once('error',()=>response.destroy()); + } + response.setHeader('Content-Type', 'video/mp2t'); + (encoder ?? ffmpeg).stdout.pipe(response); + response.once('close', () => { activeStreams -= 1; ffmpeg.kill(); children.delete(ffmpeg); encoder?.kill(); children.delete(encoder); }); + ffmpeg.once('error', () => response.destroy()); + } else { response.statusCode = 404; response.end(); } + }); + await new Promise(done => fixture.listen(0, '127.0.0.1', done)); + try { + await page.goto('/'); + await page.getByLabel('Username', { exact: true }).fill('playback-admin'); + await page.getByLabel('Password', { exact: true }).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + const token = await page.evaluate(() => localStorage.getItem('koko-client-web-auth-token')); + headers = { Authorization: `Bearer ${token}` }; + const initialStorage = await page.request.put('/api/v1/live-tv/dvr/settings',{headers,data:{recording_dir:testInfo.outputPath('initial-capture')}}); + expect(initialStorage.ok()).toBeTruthy(); + const base = `http://127.0.0.1:${fixture.address().port}`; + const created = await page.request.post('/api/v1/live-tv/sources', { headers, data: { + name: 'Local IPTV test', kind: 'm3u', capacity: 1, + config: { url: `${base}/tv.m3u`, guide_kind: 'xmltv', xmltv_url: `${base}/guide.xml` }, + } }); + expect(created.ok()).toBeTruthy(); sourceId = (await created.json()).id; + let channel; + await expect.poll(async () => { + const response = await page.request.get('/api/v1/live-tv/channels', { headers }); + channel = (await response.json()).find(c => c.source_id === sourceId && c.name === 'Live Test TV'); + return channel?.name; + }, { timeout: 45000 }).toBe('Live Test TV'); + expect(channel).not.toHaveProperty('stream_url'); + await page.goto('/live-tv'); + await expect(page.getByRole('heading', { name:'Live Test News', exact:true })).toBeVisible(); + await expect(page.locator('#tv-guide-scroll')).toHaveCount(1); + await expect(page.locator('.tv-guide-tick')).toHaveCount(12); + await expect(page.locator('.live-tv-programs')).toHaveCount(0); + await page.getByRole('button',{name:'Add Live Test TV to favorites',exact:true}).click(); + await expect(page.getByRole('button',{name:'Remove Live Test TV from favorites',exact:true})).toHaveAttribute('aria-pressed','true'); + await page.getByRole('button',{name:'Filters',exact:true}).click(); + await page.locator('[data-tv-channel-view="favorites"]').click(); + await expect(page.locator('.tv-guide-row')).toHaveCount(1); + await page.locator('[data-tv-channel-view="all"]').click(); + await page.screenshot({ path: testInfo.outputPath('live-tv-guide.png') }); + const channels = await (await page.request.get('/api/v1/live-tv/channels', { headers })).json(); + const hls = channels.find(c => c.source_id === sourceId && c.name === 'HLS Test TV'); + await page.locator(`#tv-channel-select-${hls.id}`).click(); + await expect.poll(() => page.locator('#media-player').evaluate(video => video.readyState >= 2 && video.currentTime > 0.1),{timeout:45000}).toBeTruthy(); + await page.getByRole('button', { name:'Close player',exact:true }).click(); + await expect(page.locator('.tv-preview-player #media-player')).toBeAttached(); + await page.getByRole('button', {name:'Stop playback',exact:true}).click(); + await expect.poll(() => activeStreams).toBe(0); + // A delayed tune must be closed before a replacement asks the same single tuner. + let releaseTune, tuneReady; + const pendingTune = new Promise(resolve=>{releaseTune=resolve;}); + const captured = new Promise(resolve=>{tuneReady=resolve;}); + await page.route('**/api/v1/live-tv/sessions',async route=>{ + const response = await route.fetch(); + tuneReady(); await pendingTune; await route.fulfill({response}); + },{times:1}); + await page.locator(`[data-tv-play="${channel.id}"]`).first().click(); + await captured; + await page.getByRole('button',{name:'Close player',exact:true}).click(); + const alternate = channels.find(c=>c.source_id===sourceId && c.name==='Alternate TV'); + await page.locator(`[data-tv-play="${alternate.id}"]`).first().click(); + releaseTune(); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:45000}).toBeTruthy(); + expect(activeStreams).toBe(1); + await page.getByRole('button',{name:'Close player',exact:true}).click(); + await page.getByRole('button',{name:'Stop playback',exact:true}).click(); + await expect.poll(()=>activeStreams).toBe(0); + await page.locator(`.tv-guide-program[data-tv-select-channel="${channel.id}"]`).first().click(); + await expect.poll(() => page.locator('#media-player').evaluate(video => video.readyState >= 2 && video.currentTime > 0.2),{timeout:45000}).toBeTruthy(); + const viewport = await page.locator('.media-player-overlay').boundingBox(); + expect(viewport.x).toBe(0); expect(viewport.y).toBe(0); expect(viewport.height).toBe(900); + await page.getByRole('button',{name:'Pause',exact:true}).click(); + await page.getByRole('button',{name:'Play',exact:true}).click(); + await expect(page.locator('.player-channel-heading h2')).toHaveText('Live Test TV'); + await expect(page.locator('#player-tv-program')).toContainText('Live Test News'); + await expect(page.locator('#player-tv-program')).toContainText('Local IPTV guide fixture.'); + await expect.poll(()=>page.locator('.player-channel-logo').evaluate(image=>image.naturalWidth)).toBeGreaterThan(0); + await expect(page.locator('.player-channel-logo')).toHaveAttribute('src',/\/logo.svg$/); + await page.locator('#player-volume').evaluate(input=>{input.value='0.27';input.dispatchEvent(new Event('input',{bubbles:true}));}); + // Refresh the surrounding app much more frequently than the real guide timer. + // These updates must not rebind controls, change volume, or replace SVG click targets. + await page.evaluate(()=>{ + window.tvCloseIcon=document.querySelector('#close-player svg'); + window.tvQuality=document.querySelector('#player-quality'); + window.tvRefreshTimer=setInterval(()=>document.querySelector('[data-tv-refresh]')?.click(),750); + }); + await page.getByLabel('Playback quality',{exact:true}).focus(); + await page.waitForTimeout(2200); + expect(await page.evaluate(()=>window.tvQuality===document.activeElement && window.tvQuality===document.querySelector('#player-quality'))).toBe(true); + await page.locator('.media-player-shell').focus(); + await page.mouse.move(5,5); + await expect(page.locator('.media-player-shell')).toHaveClass(/is-controls-hidden/); + const steady = await page.locator('#media-player').evaluate(async video=>{ + let waits=0; const onWait=()=>{waits++;}; video.addEventListener('waiting',onWait); + const start=video.currentTime; + await new Promise(resolve=>setTimeout(resolve,12000)); + video.removeEventListener('waiting',onWait); + return {elapsed:video.currentTime-start,waits}; + }); + expect(steady.elapsed).toBeGreaterThan(10); expect(steady.waits).toBeLessThanOrEqual(1); + await expect(page.locator('.media-player-shell')).toHaveClass(/is-controls-hidden/); + expect(await page.locator('#media-player').evaluate(video=>video.volume)).toBe(0.27); + expect(await page.evaluate(()=>window.tvCloseIcon===document.querySelector('#close-player svg'))).toBe(true); + await page.evaluate(()=>clearInterval(window.tvRefreshTimer)); + await page.locator('.media-player-shell').hover(); + expect(activeStreams).toBe(1); + const video = await page.locator('#media-player').elementHandle(); + const originalSource = await video.getAttribute('src'); + const bufferId = new URL(originalSource,'http://localhost').pathname.split('/').at(-2); + // Freeze only the reported clock: readyState stays healthy, as in a decoder + // that stops advancing without emitting a useful stalled/error event. + await video.evaluate(node=>{ + const frozen=node.currentTime; + Object.defineProperty(node,'currentTime',{configurable:true,get:()=>frozen}); + }); + await expect.poll(()=>page.locator('#media-player').getAttribute('src'),{timeout:25000}).not.toBe(originalSource); + await expect(page.locator('#media-player')).toHaveAttribute('src',new RegExp(`sessions/${bufferId}/stream.*quality=original`)); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.currentTime>0.2),{timeout:20000}).toBeTruthy(); + expect(await page.locator('#media-player').evaluate(node=>node.volume)).toBe(0.27); + expect(activeStreams).toBe(1); + const recoveredVideo = await page.locator('#media-player').elementHandle(); + const initialBuffer = await (await page.request.get(`/api/v1/live-tv/sessions/${bufferId}`,{headers})).json(); + await page.getByRole('button',{name:'Pause',exact:true}).click(); + await expect.poll(async()=> (await (await page.request.get(`/api/v1/live-tv/sessions/${bufferId}`,{headers})).json()).end_ms,{timeout:15000}).toBeGreaterThan(initialBuffer.end_ms); + await page.getByRole('button',{name:'Play',exact:true}).click(); + await page.getByRole('button', { name:'Close player',exact:true }).click(); + expect(await recoveredVideo.evaluate(node=>node === document.querySelector('#media-player'))).toBe(true); + await expect(page.locator('.tv-preview-player')).toBeVisible(); + await expect(page.locator('#tv-guide-hero #tv-hero-media .tv-preview-player #media-player')).toHaveCount(1); + await expect(page.locator('.tv-preview-player .player-controls')).toHaveCount(0); + await expect(page.locator('.tv-preview-player')).toHaveCSS('position','absolute'); + const watchButton = page.locator('#tv-guide-hero [data-tv-play]'); + await expect(watchButton).toContainText('Watch channel live'); + await expect(watchButton).not.toHaveCSS('color','rgba(0, 0, 0, 0)'); + await page.screenshot({path:testInfo.outputPath('live-tv-mini-player.png')}); + expect(activeStreams).toBe(1); + await page.locator('[data-tv-channel-view="recent"]').click(); + await expect(page.locator('.tv-guide-row')).toHaveCount(3); + expect(await recoveredVideo.evaluate(node=>node === document.querySelector('#media-player'))).toBe(true); + await page.getByRole('button',{name:'Expand player',exact:true}).click(); + await page.getByLabel('Playback quality',{exact:true}).selectOption('720p'); + await expect(page.locator('#media-player')).toHaveAttribute('src',new RegExp(`sessions/${bufferId}/stream.*quality=720p`)); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2 && node.currentTime>0.1),{timeout:20000}).toBeTruthy(); + expect(activeStreams).toBe(1); + const beforeSeek = await page.locator('#media-player').evaluate(video=>Number(new URL(video.src).searchParams.get('start_ms'))+video.currentTime*1000); + await page.getByRole('button',{name:'Back 10 seconds',exact:true}).click(); + const seekPosition = await page.locator('#media-player').evaluate(video=>Number(new URL(video.src).searchParams.get('start_ms'))); + expect(Math.abs(seekPosition-Math.max(0,beforeSeek-10000))).toBeLessThan(1500); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:20000}).toBeTruthy(); + await expect(page.getByRole('button',{name:'Go live',exact:true})).toHaveCount(0); + const previousSeekSource=await page.locator('#media-player').getAttribute('src'); + let releaseStatus,observeStatus; + const statusGate=new Promise(resolve=>{releaseStatus=resolve;}),statusSeen=new Promise(resolve=>{observeStatus=resolve;}); + const statusPath=new RegExp(`/api/v1/live-tv/sessions/${bufferId}$`); + const delayStatus=async route=>{if(route.request().method()==='GET'){observeStatus();await statusGate;}await route.continue();}; + await page.route(statusPath,delayStatus); + try{ + await page.getByRole('button',{name:'Skip to end',exact:true}).click();await statusSeen; + await page.getByRole('button',{name:'Close player',exact:true}).click(); + await expect(page.locator('.tv-preview-player')).toBeVisible(); + await page.getByRole('button',{name:'Expand player',exact:true}).click(); + releaseStatus(); + await expect(page.locator('#media-player')).not.toHaveAttribute('src',previousSeekSource); + }finally{releaseStatus();await page.unroute(statusPath,delayStatus);} + const latestBuffer=await (await page.request.get(`/api/v1/live-tv/sessions/${bufferId}`,{headers})).json(); + const newestPosition=await page.locator('#media-player').evaluate(node=>Number(new URL(node.src).searchParams.get('start_ms'))); + expect(latestBuffer.end_ms-newestPosition).toBeGreaterThanOrEqual(5000); + expect(latestBuffer.end_ms-newestPosition).toBeLessThan(11000); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:20000}).toBeTruthy(); + expect(await page.locator('#media-player').evaluate(node=>node.volume)).toBe(0.27); + await page.getByRole('button', {name:'Close player',exact:true}).click(); + await page.getByRole('button', {name:'Stop playback',exact:true}).click(); + await expect.poll(() => activeStreams).toBe(0); + const closedBuffer = await page.request.get(`/api/v1/live-tv/sessions/${bufferId}`,{headers}); + expect(closedBuffer.ok()).toBe(false); + + const recordingDirectory = testInfo.outputPath('dvr-storage'); + const storage = await page.request.put('/api/v1/live-tv/dvr/settings', { headers, data: { recording_dir: recordingDirectory } }); + expect(storage.ok()).toBeTruthy(); + // Keeping a buffer reuses its tuner, then survives switching and leaving Live TV. + const capacity = await page.request.put(`/api/v1/live-tv/sources/${sourceId}`, { headers, data: { enabled:true, capacity:2 } }); + expect(capacity.ok()).toBeTruthy(); + await page.locator('[data-tv-channel-view="all"]').click(); + await page.locator(`[data-tv-play="${channel.id}"]`).first().click(); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:45000}).toBeTruthy(); + const keepResponse = page.waitForResponse(response=>response.url().endsWith('/keep') && response.request().method()==='POST'); + await page.getByRole('button',{name:'Keep recording',exact:true}).click(); + await expect(page.getByRole('menu',{name:'Keep recording',exact:true})).toBeVisible(); + const recordingButton=await page.locator('#player-keep-tv').boundingBox(); + const recordingMenu=await page.locator('#player-recording-menu').boundingBox(); + expect(recordingMenu.y+recordingMenu.height).toBeLessThan(recordingButton.y); + await page.evaluate(()=>document.querySelector('[data-tv-refresh]').click()); + await page.waitForTimeout(1000); + await expect(page.getByRole('menu',{name:'Keep recording',exact:true})).toBeVisible(); + await page.getByRole('menuitem',{name:/Shared recording/}).click(); + const keptResult = await keepResponse; + expect(keptResult.ok()).toBeTruthy(); + expect(keptResult.request().postDataJSON()).toEqual({private:false}); + await expect(page.getByRole('button',{name:'Recording kept',exact:true})).toBeDisabled(); + const keptId = await keptResult.json(); + await expect.poll(async()=> (await stat(testInfo.outputPath('dvr-storage',`recording-${keptId}.ts`)).catch(()=>({size:0}))).size).toBeGreaterThan(0); + expect(activeStreams).toBe(1); + await page.getByRole('button',{name:'Close player',exact:true}).click(); + await page.locator(`[data-tv-play="${alternate.id}"]`).first().click(); + await expect.poll(()=>activeStreams).toBe(2); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:45000}).toBeTruthy(); + const browsingVideo=await page.locator('#media-player').elementHandle(); + const browsingSource=await browsingVideo.getAttribute('src'); + await page.getByRole('button',{name:'Channel guide',exact:true}).click(); + const playerGuide=page.getByRole('complementary',{name:'Channel guide',exact:true}); + await expect(playerGuide).toBeVisible(); + const overlappingControls=await page.locator('.player-bottom-controls button').evaluateAll(buttons=>{ + const bounds=buttons.map(button=>button.getBoundingClientRect()).filter(rect=>rect.width && rect.height); + return bounds.some((a,i)=>bounds.slice(i+1).some(b=>Math.min(a.right,b.right)-Math.max(a.left,b.left)>1 && Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top)>1)); + }); + expect(overlappingControls).toBe(false); + const beforeBrowsing=await browsingVideo.evaluate(node=>node.currentTime); + await playerGuide.getByRole('searchbox',{name:'Find a channel',exact:true}).fill('Live Test'); + await expect(playerGuide.locator('[data-player-tv-channel]:visible')).toHaveCount(1); + await page.waitForTimeout(1500); + expect(await browsingVideo.evaluate(node=>node===document.querySelector('#media-player'))).toBe(true); + expect(await browsingVideo.evaluate(node=>node.currentTime)).toBeGreaterThan(beforeBrowsing+.7); + await expect(page.locator('.tv-preview-player')).toHaveCount(0); + await page.screenshot({path:testInfo.outputPath('live-tv-player-guide.png')}); + await playerGuide.getByRole('button',{name:'Watch Live Test TV live',exact:true}).click(); + await expect.poll(()=>page.locator('#media-player').getAttribute('src'),{timeout:45000}).not.toBe(browsingSource); + await expect.poll(()=>page.locator('#media-player').evaluate(node=>node.readyState>=2),{timeout:45000}).toBeTruthy(); + await expect(playerGuide).toBeVisible(); + await expect(page.locator('#player-tv-program')).toContainText('Live Test News'); + await expect.poll(()=>page.locator('.player-channel-logo').evaluate(image=>image.naturalWidth)).toBeGreaterThan(0); + await page.screenshot({path:testInfo.outputPath('live-tv-player-details.png')}); + await page.setViewportSize({width:390,height:844}); + await expect(playerGuide).toBeVisible(); + await expect.poll(()=>page.locator('#player-tv-guide-channels').evaluate(list=>list.clientHeight)).toBeGreaterThan(50); + await expect.poll(async()=>{ + const guideBounds=await playerGuide.boundingBox(),progressBounds=await page.locator('#player-progress').boundingBox(); + return guideBounds.y+guideBounds.height<=progressBounds.y; + }).toBeTruthy(); + await page.screenshot({path:testInfo.outputPath('live-tv-player-guide-mobile.png')}); + await page.setViewportSize({width:1440,height:900}); + await page.getByRole('button',{name:'Close channel guide',exact:true}).click(); + await expect(playerGuide).toBeHidden(); + await page.getByRole('button',{name:'Close player',exact:true}).click(); + await page.locator('[data-nav-settings]').click(); + await expect(page.locator('#media-player')).toHaveCount(0); + await expect.poll(()=>activeStreams).toBe(1); + const keptList = await (await page.request.get('/api/v1/live-tv/recordings',{headers})).json(); + expect(keptList.recordings.find(recording=>recording.id===keptId)).toMatchObject({state:'recording',shared:true}); + const cancelled = await page.request.post(`/api/v1/live-tv/recordings/${keptId}/cancel`,{headers}); + expect(cancelled.ok()).toBeTruthy(); + await expect.poll(()=>activeStreams,{timeout:15000}).toBe(0); + const removedKept = await page.request.delete(`/api/v1/live-tv/recordings/${keptId}`,{headers}); + expect(removedKept.ok()).toBeTruthy(); + await page.locator('[data-nav-live-tv]').click(); + const now = Math.floor(Date.now() / 1000); + const scheduled = await page.request.post('/api/v1/live-tv/recordings', { headers, data: { + channel_id: channel.id, title: 'Timed IPTV recording', starts_at: now - 1, ends_at: now + 12, + series: false, new_only: false, padding_before: 0, padding_after: 0, + } }); + expect(scheduled.ok()).toBeTruthy(); + let recording; + await expect.poll(async () => { + const response = await page.request.get('/api/v1/live-tv/recordings', { headers }); + recording = (await response.json()).recordings.find(r => r.title === 'Timed IPTV recording'); + return recording?.state; + }, { timeout: 35000 }).toBe('completed'); + expect(recording.has_video).toBeTruthy(); + expect(recording.shared).toBeTruthy(); + const savedFile = testInfo.outputPath('dvr-storage', `recording-${recording.id}.ts`); + expect((await stat(savedFile)).size).toBeGreaterThan(0); + // Changing the folder for future captures must not move or break the completed video. + const changedStorage = await page.request.put('/api/v1/live-tv/dvr/settings', { headers, data: { recording_dir: '' } }); + expect(changedStorage.ok()).toBeTruthy(); + await page.getByRole('button', { name: 'Recordings', exact: true }).click(); + await expect(page.getByText('Timed IPTV recording', { exact: true })).toBeVisible(); + await page.locator(`[data-tv-recording-play="${recording.id}"]`).click(); + await expect.poll(() => page.locator('#media-player').evaluate(video => video.readyState >= 2 && video.currentTime > 0.1)).toBeTruthy(); + await page.getByRole('button', {name:'Close player',exact:true}).click(); + await page.getByRole('button', {name:'Stop playback',exact:true}).click(); + const unauthenticated = await page.request.get('/api/v1/live-tv/recordings', { headers: { Authorization: 'Bearer invalid' } }); + expect(unauthenticated.status()).toBe(401); + const deleted = await page.request.delete(`/api/v1/live-tv/recordings/${recording.id}`, { headers }); + expect(deleted.ok(), await deleted.text()).toBeTruthy(); + await expect(stat(savedFile)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + if (sourceId && headers) await page.request.put(`/api/v1/live-tv/sources/${sourceId}`, { headers, data: { enabled: false, capacity: 1 } }); + for (const child of children) child.kill(); + fixture.closeAllConnections(); + await new Promise(done => fixture.close(done)); + } +}); + +test('a 4402-channel guide stays bounded while scrolling, navigating, searching, and jumping to the end',async({page},testInfo)=>{ + test.setTimeout(120000); + const start=Math.floor(Date.now()/1800000)*1800; + const channels=Array.from({length:4402},(_,i)=>({id:i+1,source_id:1,source_key:String(i),guide_key:String(i),name:`Channel ${i+1}`,number:String(i+1),group_name:'',enabled:true,available:true})); + const programs=channels.flatMap(c=>Array.from({length:4},(_,i)=>({id:c.id*10+i,source_id:1,guide_key:c.guide_key,title:`Channel ${c.id} program ${i}`,starts_at:start+i*5400,ends_at:start+(i+1)*5400,metadata:{description:`Program details for channel ${c.id}`,categories:['News']}}))); + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[],sources:[{id:1,name:'Large lineup',kind:'m3u'}]}})); + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/live-tv'); + const scroll=page.locator('#tv-guide-scroll'); + await expect(scroll).toHaveAttribute('data-tv-channel-count','4402'); + expect(await page.locator('.tv-guide-row').count()).toBeLessThan(30); + await page.locator('#tv-program-1-10').focus(); + const mounted=await page.locator('#tv-channel-row-1').elementHandle(); + await page.keyboard.press('ArrowDown'); + await expect(page.locator('#tv-program-2-20')).toBeFocused(); + expect(await mounted.evaluate(node=>node.isConnected)).toBe(true); + await expect(page.locator('#tv-guide-hero h2')).toHaveText('Channel 2 program 0'); + await page.evaluate(()=>{ + const guide=document.querySelector('#tv-guide-scroll');window.tvGuideIdentity=guide; + for(let i=0;i<50;i++) document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowDown',bubbles:true})); + }); + await expect(page.locator('#tv-program-52-520')).toBeFocused(); + expect(await page.evaluate(()=>window.tvGuideIdentity===document.querySelector('#tv-guide-scroll'))).toBe(true); + expect(await page.locator('.tv-guide-row').count()).toBeLessThan(30); + await scroll.evaluate(node=>{node.scrollTop=2200*88;node.scrollLeft=240;}); + await expect(page.locator('#tv-program-2201-22010')).toBeAttached(); + await page.locator('#tv-program-2201-22010').focus(); + await page.keyboard.press('ArrowDown');await expect(page.locator('#tv-program-2202-22020')).toBeFocused(); + await page.keyboard.press('End');await expect(page.locator('#tv-program-4402-44020')).toBeFocused(); + expect(await page.locator('.tv-guide-row').count()).toBeLessThan(30); + await page.keyboard.press('Home');await expect(page.locator('#tv-program-1-10')).toBeFocused(); + await page.getByRole('button',{name:'Filters',exact:true}).click(); + await page.getByRole('searchbox',{name:'Search channels or programs',exact:true}).fill('Channel 4402'); + await page.locator('#tv-filter-form').getByRole('button',{name:'Search',exact:true}).click(); + await expect(page.locator('.tv-guide-row')).toHaveCount(1); + await expect(page.locator('#tv-guide-hero h2')).toHaveText('Channel 4402 program 0'); + await page.getByRole('searchbox',{name:'Search channels or programs',exact:true}).fill(''); + await page.locator('#tv-filter-form').getByRole('button',{name:'Search',exact:true}).click(); + expect(await page.locator('.tv-guide-row').count()).toBeLessThan(30); + await scroll.screenshot({path:testInfo.outputPath('large-guide-virtual-rows.png')}); +}); + +test('source settings prefill connections, report imports, isolate channel editors, and persist guide groups',async({page})=>{ + test.setTimeout(150000); + const stamp=seconds=>new Date(seconds*1000).toISOString().slice(0,19).replaceAll(/[-T:]/g,'')+' +0000'; + const fixture=createServer((request,response)=>{ + const now=Math.floor(Date.now()/1000),bridge=request.url.startsWith('/bridge'); + if (request.url.endsWith('.m3u')) { + response.end(`#EXTM3U\n#EXTINF:-1 tvg-id="station" tvg-chno="${bridge?'5167':'100'}",${bridge?'ESPN bridge':'Regular channel'}\nhttp://example.com/stream.ts\n`); + } else { + response.end(`${bridge?'ESPN bridge':'Regular channel'}${bridge?'ESPN program':'Regular program'}`); + } + }); + await new Promise(done=>fixture.listen(0,'127.0.0.1',done)); + let headers;const ids=[]; + try { + await page.goto('/');await page.getByLabel('Username',{exact:true}).fill('playback-admin');await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD);await page.getByRole('button',{name:'Sign in',exact:true}).click();await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token'));headers={Authorization:`Bearer ${token}`}; + const base=`http://127.0.0.1:${fixture.address().port}`; + for(const [name,prefix,capacity] of [['Regular feed','regular',10],['PrismCast bridge','bridge',1]]) { + const response=await page.request.post('/api/v1/live-tv/sources',{headers,data:{name,kind:'m3u',capacity,config:{url:`${base}/${prefix}.m3u`,guide_kind:'xmltv',xmltv_url:`${base}/${prefix}.xml`,artwork_provider:'guide',refresh_hours:12}}}); + expect(response.ok()).toBeTruthy();ids.push((await response.json()).id); + } + await expect.poll(async()=>{ + const sources=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json(); + return sources.filter(s=>ids.includes(s.id)&&s.channel_count===1&&s.playlist_bytes>0&&s.guide_bytes>0).length; + },{timeout:65000}).toBe(2); + await page.goto('/settings/live-tv'); + const regular=page.locator(`[data-tv-source-card="${ids[0]}"]`),bridge=page.locator(`[data-tv-source-card="${ids[1]}"]`); + await expect(regular).toContainText('1 channel');await expect(regular).toContainText('Last imported: M3U');await expect(regular).toContainText('XMLTV'); + await regular.locator(':scope > summary').click();await bridge.locator(':scope > summary').click(); + await regular.getByText('Edit connection settings',{exact:true}).click(); + const editor=regular.locator('[data-tv-replace-source]'); + await expect(editor.getByLabel('IPTV playlist URL',{exact:true})).toHaveValue(`${base}/regular.m3u`); + await expect(editor.getByLabel('XMLTV URL',{exact:true})).toHaveValue(`${base}/regular.xml`); + await expect(editor.getByRole('combobox',{name:'Program artwork',exact:true})).toHaveValue('guide'); + await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('12'); + await editor.getByLabel('Refresh interval (hours)',{exact:true}).fill('18'); + await regular.locator(':scope > summary').click();await expect(regular).toHaveJSProperty('open',false); + await regular.locator(':scope > summary').click();await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('18'); + await regular.getByText('Channel visibility and guide mapping',{exact:true}).click(); + await expect(editor.getByLabel('XMLTV URL',{exact:true})).toHaveValue(`${base}/regular.xml`); + await expect(editor.getByLabel('Refresh interval (hours)',{exact:true})).toHaveValue('18'); + await editor.getByRole('button',{name:'Save connection settings',exact:true}).click(); + await expect(editor).toHaveCount(0); + if (!(await regular.locator('[data-tv-mapping-source]').evaluate(node=>node.open))) await regular.getByText('Channel visibility and guide mapping',{exact:true}).click(); + await expect(regular.locator('[data-tv-channel-update]')).toHaveCount(1);await expect(bridge.locator('[data-tv-channel-update]')).toHaveCount(0); + await bridge.getByText('Channel visibility and guide mapping',{exact:true}).click(); + await expect(bridge.locator('[data-tv-channel-update]')).toHaveCount(1);await expect(regular.locator('[data-tv-channel-update]')).toHaveCount(0); + await page.getByText('Add a source group',{exact:true}).click(); + const group=page.locator('[data-tv-group-form=""]');await group.getByLabel('Group name',{exact:true}).fill('Combined FastChannels'); + for(const id of ids) await group.locator(`input[name="source_id"][value="${id}"]`).check(); + await group.getByRole('button',{name:'Create group',exact:true}).click(); + await expect(page.getByText('Combined FastChannels',{exact:true})).toBeVisible(); + await page.goto('/live-tv'); + const tab=page.locator('[data-tv-source-group][aria-label^="Combined FastChannels,"]'); + if(await tab.count()) await tab.click(); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-channel-count','2'); + const bridgeChannel=(await(await page.request.get('/api/v1/live-tv/channels',{headers})).json()).find(c=>c.source_id===ids[1]); + await page.locator(`[data-tv-select-channel="${bridgeChannel.id}"]`).first().focus(); + await expect(page.locator('#tv-guide-hero h2')).toHaveText('ESPN program'); + await page.reload();await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-channel-count','2'); + const saved=await(await page.request.get(`/api/v1/live-tv/sources/${ids[0]}/connection`,{headers})).json(); + expect(saved.source.config.refresh_hours).toBe(18);expect(saved.source.config.url).toBe(`${base}/regular.m3u`); + const configured=await(await page.request.get('/api/v1/live-tv/sources',{headers})).json(); + expect(configured.find(s=>s.id===ids[0]).capacity).toBe(10);expect(configured.find(s=>s.id===ids[1]).capacity).toBe(1); + await page.goto('/settings/live-tv');await page.getByText('Combined FastChannels',{exact:true}).click(); + const savedGroup=page.locator('[data-tv-group-form]').filter({has:page.locator('[data-tv-group-delete]')}).filter({has:page.locator('input[value="Combined FastChannels"]')}); + await savedGroup.getByLabel('Group name',{exact:true}).fill('FastChannels and bridge'); + await savedGroup.getByRole('button',{name:'Save group',exact:true}).click(); + await expect(page.getByText('FastChannels and bridge',{exact:true})).toBeVisible(); + const renamed=page.locator('[data-tv-group-form]').filter({has:page.locator('input[value="FastChannels and bridge"]')}); + await renamed.getByRole('button',{name:'Delete group',exact:true}).click(); + await expect(page.getByText('FastChannels and bridge',{exact:true})).toHaveCount(0); + expect((await(await page.request.get('/api/v1/live-tv/sources',{headers})).json()).filter(s=>ids.includes(s.id))).toHaveLength(2); + } finally { + for(const id of ids) if(headers) await page.request.put(`/api/v1/live-tv/sources/${id}`,{headers,data:{enabled:false,capacity:1}}).catch(()=>undefined); + await new Promise(done=>fixture.close(done)); + } +}); + +test('large Live TV Settings loads mapping choices on demand and saves the selected station', async ({page},testInfo) => { + const stations=Array.from({length:5200},(_,i)=>({source_id:1,guide_key:`station-${i}`,name:`Station ${i}`,number:String(i),aliases:[]})); + const channels=[1,2].map(id=>({id,source_id:1,source_key:String(id),name:`Channel ${id}`,number:String(id),group_name:'',guide_key:`station-${id}`,guide_override:id===1?'station-5':null,enabled:true,available:true})); + const updates=[]; + await page.route('**/api/v1/live-tv/setup/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs:[],stations}})); + await page.route('**/api/v1/live-tv/sources',route=>route.fulfill({json:[{id:1,name:'Large lineup',kind:'m3u',enabled:true,capacity:1,next_refresh:0}]})); + await page.route('**/api/v1/live-tv/channels/1',route=>{ + const update=route.request().postDataJSON(); updates.push(update); channels[0].guide_override=update.guide_override; + return route.fulfill({status:204}); + }); + await page.goto('/'); + await page.getByLabel('Username',{exact:true}).fill('playback-admin'); + await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button',{name:'Sign in',exact:true}).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/settings/live-tv'); + const mapping=page.locator('[data-tv-channel-update="1"]'); + await page.locator('[data-tv-source-card="1"] > summary').click(); + await page.getByText('Channel visibility and guide mapping',{exact:true}).click(); + const select=mapping.getByRole('combobox',{name:'Guide station',exact:true}); + await expect(page.locator('[data-tv-guide-source] option')).toHaveCount(3); + await select.click(); + await expect(select.locator('option')).toHaveCount(5201); + await select.press('Escape'); + await select.selectOption('station-5199'); + await mapping.getByRole('button',{name:'Save',exact:true}).click(); + await expect.poll(()=>updates).toEqual([{enabled:true,guide_override:'station-5199'}]); + await expect(select).toHaveValue('station-5199'); + await expect(page.locator('[data-tv-guide-source] option')).toHaveCount(3); + await expect(mapping).toBeVisible(); + await mapping.screenshot({path:testInfo.outputPath('large-lineup-channel-mapping.png')}); +}); + +test('Live TV Settings separates setup flows and finds local tuner metadata without hardware', async ({ page }, testInfo) => { + test.setTimeout(90000); + await page.goto('/'); + await page.getByLabel('Username', { exact: true }).fill('playback-admin'); + await page.getByLabel('Password', { exact: true }).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button', { name:'Sign in', exact:true }).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/settings/live-tv'); + await expect(page.locator('#tv-source-kind')).toBeAttached(); + const panel = page.locator('details').filter({ has: page.locator('#tv-source-form') }).last(); + if (!(await panel.evaluate(node=>node.open))) await panel.locator('summary').first().click(); + const form = page.locator('#tv-source-form'); + await expect(form.getByRole('combobox', {name:'Program artwork',exact:true})).toHaveValue('automatic'); + await form.getByRole('combobox', {name:'Program artwork',exact:true}).selectOption('guide'); + await expect(form.getByRole('combobox', {name:'Guide source',exact:true})).toHaveValue('automatic'); + await form.getByRole('combobox', {name:'Program artwork',exact:true}).selectOption('tvdb'); + await expect(form.getByRole('combobox', {name:'Guide source',exact:true})).toHaveValue('automatic'); + await page.route('**/api/v1/live-tv/discover/hdhomerun', route => route.fulfill({ json:[ + {name:'Living room HDHomeRun',url:'http://192.0.2.10/',capacity:4}, + {name:'Office HDHomeRun',url:'http://192.0.2.11/',capacity:2}, + ] })); + await form.getByRole('button',{name:'Find HDHomeRun',exact:true}).click(); + const livingRoom = form.getByRole('radio', { name:/Living room HDHomeRun/ }); + const office = form.getByRole('radio', { name:/Office HDHomeRun/ }); + await expect(form.getByRole('radio')).toHaveCount(2); + await livingRoom.check(); + await expect(form.locator('[name="url"]')).toHaveValue('http://192.0.2.10/'); + await expect(form.locator('[name="capacity"]')).toHaveValue('4'); + await expect(form.locator('[name="name"]')).toHaveValue('Living room HDHomeRun'); + await form.locator('.live-tv-source-discovery').scrollIntoViewIfNeeded(); + await form.locator('.live-tv-source-discovery').screenshot({ path:testInfo.outputPath('live-tv-discovery-cards.png') }); + await page.setViewportSize({ width:390, height:844 }); + await form.locator('.live-tv-discovery-results').scrollIntoViewIfNeeded(); + const cards = await form.locator('.live-tv-discovery-card').evaluateAll(nodes => nodes.map(node => { + const rect = node.getBoundingClientRect(); return {left:rect.left, right:rect.right, top:rect.top, bottom:rect.bottom}; + })); + expect(cards[0].left).toBeGreaterThanOrEqual(0); + expect(cards[0].right).toBeLessThanOrEqual(390); + expect(cards[1].top).toBeGreaterThanOrEqual(cards[0].bottom); + await page.screenshot({ path:testInfo.outputPath('live-tv-discovery-cards-mobile.png'), fullPage:true }); + await page.setViewportSize({ width:1440, height:900 }); + await livingRoom.focus(); await livingRoom.press('ArrowRight'); + await expect(office).toBeChecked(); + await expect(form.locator('[name="url"]')).toHaveValue('http://192.0.2.11/'); + await expect(form.locator('[name="capacity"]')).toHaveValue('2'); + await expect(form.locator('[name="name"]')).toHaveValue('Office HDHomeRun'); + await form.locator('[name="name"]').fill('Antenna'); await livingRoom.check(); + await expect(form.locator('[name="name"]')).toHaveValue('Antenna'); + await form.locator('[name="url"]').fill('http://192.0.2.99/'); + await expect(form.locator('input[type="radio"]:checked')).toHaveCount(0); + if (process.env.KOKO_E2E_NEXT_PVR_URL) { + const expected = new URL(process.env.KOKO_E2E_NEXT_PVR_URL).href; + await page.locator('#tv-source-kind').selectOption('nextpvr'); + await form.getByRole('button', { name:'Find NextPVR', exact:true }).click(); + const card = form.locator('.live-tv-discovery-card').filter({ hasText:expected }); + await expect(card).toHaveCount(1); + await card.getByRole('radio').check(); + await expect(form.getByLabel('NextPVR address')).toHaveValue(expected); + await form.locator('.live-tv-source-discovery').screenshot({ path:testInfo.outputPath('live-tv-nextpvr-cards.png') }); + } + await page.locator('#tv-source-kind').selectOption('m3u'); + await expect(form.getByLabel('IPTV playlist URL')).toBeVisible(); + await expect(form.locator('[name="pin"], [name="native_device"]')).toHaveCount(0); + await form.locator('[name="guide_kind"]').selectOption('zap2it'); + await form.getByLabel('ZIP / postal code').fill('14850'); + const token = await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token')); + if (process.env.KOKO_E2E_LIVE_ZAP2IT !== '1') await page.route('**/api/v1/live-tv/guide/providers?**',route=>route.fulfill({json:[{type:'OTA',name:'Local Over the Air',lineupId:'USA-lineupId-DEFAULT',headendId:'lineupId',device:'',timezone:'',location:''}]})); + await form.getByRole('button',{name:'Find providers',exact:true}).click(); + await expect.poll(()=>form.locator('[name="guide_provider"] option').count(),{timeout:30000}).toBeGreaterThan(0); + await expect(form.locator('[data-tv-provider-status]')).toContainText('Select your provider'); + await page.locator('#tv-source-kind').selectOption('native'); + await expect.poll(()=>form.locator('[name="region"] option').count()).toBeGreaterThan(1); + await expect(form.locator('input[name="region"]')).toHaveCount(0); + await form.getByRole('button',{name:'Find local tuners',exact:true}).click(); + await expect(form.locator('[data-tv-tuner-status]')).toContainText(/No tuners found|tuners? found\./); + await expect(form.locator('[data-tv-tuner-status]')).not.toContainText('missing'); + await page.route('**/api/v1/live-tv/tuners', route => route.fulfill({ json:[ + {id:'/dev/dvb/adapter0',name:'Hauppauge test tuner',info:'DVB-T2'}, + {id:'/dev/dvb/adapter1',name:'Second test tuner',info:'DVB-T2'}, + ] })); + await form.getByRole('button',{name:'Find local tuners',exact:true}).click(); + await form.getByRole('radio', {name:/Hauppauge test tuner/}).check(); + await expect(form.getByLabel('Tuner location', {exact:true})).toHaveValue('/dev/dvb/adapter0'); + await expect(form.locator('select[name="native_device"]')).toHaveCount(0); + await page.screenshot({path:testInfo.outputPath('live-tv-settings.png'),fullPage:true}); + await form.getByLabel('Tuner location', {exact:true}).fill('/dev/dvb/manual'); + await expect(form.locator('input[type="radio"]:checked')).toHaveCount(0); + const denied = await page.request.get('/api/v1/live-tv/discover/hdhomerun',{headers:{Authorization:'Bearer invalid'}}); + expect(denied.status()).toBe(401); + expect(token).toBeTruthy(); +}); + +test('the guide aligns all channels to one timeline with a program hero, source rail, and categories', async ({page},testInfo) => { + const start = Math.floor(Date.now()/1800000)*1800; + const channel = (id,source_id,name,number) => ({id,source_id,name,number,source_key:String(id),guide_key:String(id),group_name:'',enabled:true,available:true}); + const channels = [channel(101,10,'Local News','9.1'),channel(102,10,'Sports Network','13.6'),channel(201,20,'Cable Movies','1'),...Array.from({length:20},(_,i)=>channel(300+i,10,`News ${i+1}`,String(20+i)))]; + channels[0].logo='/api/v1/live-tv/channels/101/artwork'; + channels[1].logo='/api/v1/live-tv/channels/102/artwork'; + const cover='https://zpmc.tmsimg.com/assets/p13159645_b_v13_aa.jpg?w=165'; + const poster='https://zpmc.tmsimg.com/assets/poster_b_v13_aa.jpg?w=165'; + const logo='https://zpmc.tmsimg.com/h3/NowShowing/45738/station.png?w=55'; + const programs = [ + {id:1,source_id:10,guide_key:'101',title:'Afternoon News',starts_at:start-600,ends_at:start+1800,metadata:{description:'The stories that matter in your community, with headlines, weather, and local reporting.',categories:['News'],image:cover,poster,is_new:true}}, + {id:2,source_id:10,guide_key:'101',title:'The Classic Film',starts_at:start+1800,ends_at:start+7200,metadata:{description:'A feature-length movie with interviews from the cast.',categories:['Movie / drama'],is_premiere:true}}, + {id:3,source_id:10,guide_key:'102',title:'World of Sports',starts_at:start-600,ends_at:start+7200,metadata:{description:'Live coverage and analysis.',categories:['Sports event'],is_live:true}}, + {id:4,source_id:20,guide_key:'201',title:'Cable Movie Premiere',starts_at:start-600,ends_at:start+7200,metadata:{description:'A film from your cable lineup.',categories:['Movies'],is_premiere:true}}, + ...Array.from({length:20},(_,i)=>({id:1000+i,source_id:10,guide_key:String(300+i),title:`News program ${i+1}`,starts_at:start,ends_at:start+21600,metadata:{categories:['News']}})), + ]; + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[{source_id:10,guide_key:'101',name:'Local News',number:'9.1',aliases:[],logo}],sources:[{id:10,name:'Antenna',kind:'hdhomerun'},{id:20,name:'Cable',kind:'nextpvr'}],preferences:{favorites:[],recent:[]}}})); + await page.route('**/api/v1/live-tv/channels/101/artwork',route=>route.fulfill({status:404})); + await page.route('**/api/v1/live-tv/channels/102/artwork',route=>route.fulfill({status:404})); + await page.route('https://zpmc.tmsimg.com/**',route=>route.fulfill({contentType:'image/svg+xml',body:'Koko Live TV'})); + await page.goto('/'); + await page.getByLabel('Username',{exact:true}).fill('playback-admin'); + await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button',{name:'Sign in',exact:true}).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/live-tv'); + await expect(page.getByRole('heading',{name:'Afternoon News',exact:true})).toBeVisible(); + await expect(page.locator('#tv-guide-hero')).toContainText('The stories that matter'); + await expect(page.locator('#tv-guide-hero.home-feature')).toHaveAttribute('style',/w=960/); + await expect(page.locator('.home-pinned-header > .home-feature')).toHaveCount(1); + await expect(page.locator('#tv-guide-hero .tv-badge-new')).toHaveText('NEW'); + await expect(page.locator('#tv-guide-hero .tv-badge-live')).toHaveCount(0); + await expect(page.locator('.tv-guide-row')).toHaveCount(22); + await expect(page.locator('#tv-filter-panel')).toBeHidden(); + await expect(page.locator('#toggle-tv-filters')).toHaveAttribute('aria-expanded','false'); + await expect(page.locator('#tv-guide-hero .home-feature-poster')).toHaveAttribute('src',/poster_b_v13_aa.jpg\?w=960/); + await expect.poll(()=>page.locator('#tv-guide-hero .tv-hero-channel-logo').evaluate(image=>image.naturalWidth)).toBeGreaterThan(0); + await expect(page.locator('#tv-guide-hero .tv-hero-channel-logo')).toHaveAttribute('src',/w=320/); + await expect(page.locator('#tv-guide-scroll')).toHaveCount(1); + await expect(page.locator('[data-tv-select-channel="102"] .tv-channel-logo')).toBeHidden(); + const textPositions=await page.locator('.tv-channel-select > span:last-child').evaluateAll(labels=>labels.map(label=>label.getBoundingClientRect().left)); + expect(new Set(textPositions).size).toBe(1); + await page.setViewportSize({width:390,height:844}); + const mobilePositions=await page.locator('.tv-channel-select > span:last-child').evaluateAll(labels=>labels.map(label=>label.getBoundingClientRect().left)); + expect(new Set(mobilePositions).size).toBe(1); + await page.setViewportSize({width:1440,height:900}); + const guideEdges = () => page.locator('#tv-guide-scroll').evaluate(scroll=>{ + const box=selector=>scroll.querySelector(selector).getBoundingClientRect(); + const corner=box('.tv-guide-corner'),tick=box('.tv-guide-tick'),header=box('.tv-guide-time-row'); + const channel=box('.tv-guide-channel'),track=box('.tv-guide-track'),first=box('#tv-program-101-1'),next=box('#tv-program-101-2'); + const style=getComputedStyle(scroll.querySelector('#tv-program-101-1')); + return {cornerEdge:Math.abs(corner.right-channel.right),timeEdge:Math.abs(tick.left-track.left), + headerBottom:Math.abs(corner.bottom-tick.bottom),overflow:Math.max(corner.bottom,tick.bottom)-header.bottom, + topGap:Math.abs(first.top-track.top),programGap:Math.abs(first.right-next.left), + borders:[style.borderTopWidth,style.borderRightWidth,style.borderBottomWidth,style.borderLeftWidth],radius:style.borderRadius}; + }); + for(const fontSize of ['16px','24px']) { + await page.evaluate(size=>{document.documentElement.style.fontSize=size;},fontSize); + const edges=await guideEdges(); + for(const gap of [edges.cornerEdge,edges.timeEdge,edges.headerBottom,edges.overflow,edges.topGap,edges.programGap]) expect(gap).toBeLessThanOrEqual(1); + expect(edges.borders).toEqual(['0px','1px','0px','0px']); + expect(edges.radius).toBe('0px'); + } + await page.evaluate(()=>{document.documentElement.style.fontSize='';}); + const alignment = await page.locator('#tv-guide-scroll').evaluate(scroll => { + const header=scroll.querySelector('.tv-guide-tick'), program=scroll.querySelector('.tv-guide-program'), channel=scroll.querySelector('.tv-guide-channel'); + const before={header:header.getBoundingClientRect().left,program:program.getBoundingClientRect().left,channel:channel.getBoundingClientRect().left}; + scroll.scrollLeft=240; + return {header:before.header-header.getBoundingClientRect().left,program:before.program-program.getBoundingClientRect().left,channel:before.channel-channel.getBoundingClientRect().left}; + }); + expect(alignment.header).toBe(240); expect(alignment.program).toBe(240); expect(alignment.channel).toBe(0); + await page.locator('#tv-guide-scroll').evaluate(scroll=>{scroll.scrollLeft=0;}); + await page.screenshot({path:testInfo.outputPath('live-tv-timeline.png'),fullPage:true}); + await page.locator('#tv-program-101-1').focus(); + for(let step=0;step<14;step++) { + await page.keyboard.press('ArrowDown'); + await expect(page.locator('.tv-guide-scroll :focus')).toBeInViewport(); + } + expect(await page.locator('#tv-guide-scroll').evaluate(scroll=>scroll.scrollTop)).toBeGreaterThan(400); + expect(await page.locator('#tv-guide-scroll').evaluate(scroll=>scroll.scrollLeft)).toBe(0); + const selectedBounds = await page.locator('.tv-guide-scroll :focus').evaluate(control=>{ + const guide=control.closest('#tv-guide-scroll'),row=control.getBoundingClientRect(),bounds=guide.getBoundingClientRect(); + return {top:row.top,bottom:row.bottom,header:bounds.top+48,visibleBottom:Math.min(bounds.bottom,innerHeight)}; + }); + expect(selectedBounds.top).toBeGreaterThanOrEqual(selectedBounds.header-2); + expect(selectedBounds.bottom).toBeLessThanOrEqual(selectedBounds.visibleBottom+2); + await page.getByRole('button',{name:'Filters',exact:true}).click(); + await expect(page.locator('#tv-filter-panel')).toBeVisible(); + await page.locator('[data-tv-category="Sports"]').click(); + await expect(page.locator('.tv-guide-row')).toHaveCount(1); + await expect(page.getByRole('heading',{name:'World of Sports',exact:true})).toBeVisible(); + await expect(page.locator('#tv-guide-hero .tv-badge-live')).toHaveText('LIVE'); + await page.locator('[data-tv-category="Movies"]').click(); + await expect(page.locator('.tv-guide-program.is-dimmed')).toHaveCount(1); + await expect(page.getByRole('heading',{name:'The Classic Film',exact:true})).toBeVisible(); + await page.getByRole('navigation',{name:'TV sources',exact:true}).getByRole('button',{name:/Cable/}).click(); + await expect(page.locator('.tv-guide-row')).toHaveCount(1); + await expect(page.getByRole('heading',{name:'Cable Movie Premiere',exact:true})).toBeVisible(); + await expect(page.locator('#tv-guide-hero .tv-badge-premiere')).toHaveText('PREMIERE'); + await page.getByRole('button',{name:/Filters/}).click(); + await expect(page.locator('#tv-filter-panel')).toBeHidden(); + await page.setViewportSize({width:390,height:844}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + await page.screenshot({path:testInfo.outputPath('live-tv-timeline-mobile.png'),fullPage:true}); +}); + +test('the guide preserves its time column across channels and advances expired slots without stealing focus',async({page})=>{ + const start=Math.floor(Date.now()/1800000)*1800; + await page.clock.install({time:start*1000+1790000}); + await page.clock.pauseAt(start*1000+1791000); + const channels=Array.from({length:4},(_,i)=>({id:501+i,source_id:50,source_key:String(i),guide_key:String(i),name:`Navigation ${i+1}`,number:String(i+1),group_name:'',enabled:true,available:true})); + const ranges=[[[0,1800],[1800,3600],[3600,21600]],[[0,21600]],[[0,900],[900,1800],[1800,2700],[2700,3600],[3600,21600]],[]]; + const programs=ranges.flatMap((row,index)=>row.map(([from,to],p)=>({id:100+index*10+p,source_id:50,guide_key:String(index),title:`Program ${index+1}.${p+1}`,starts_at:start+from,ends_at:start+to,metadata:{description:'Navigation fixture'}}))); + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs,stations:[],sources:[{id:50,name:'Navigation source',kind:'m3u'}],preferences:{favorites:[],recent:[]}}})); + await page.goto('/'); + await page.getByLabel('Username',{exact:true}).fill('playback-admin'); + await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button',{name:'Sign in',exact:true}).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + await page.goto('/live-tv'); + await page.clock.runFor(100); + await page.locator('#tv-program-501-101').focus(); + for(let repeat=0;repeat<3;repeat++) { + await page.keyboard.press('ArrowDown');await expect(page.locator('#tv-program-502-110')).toBeFocused(); + await page.keyboard.press('ArrowDown');await expect(page.locator('#tv-program-503-122')).toBeFocused(); + await page.keyboard.press('ArrowUp');await expect(page.locator('#tv-program-502-110')).toBeFocused(); + await page.keyboard.press('ArrowUp');await expect(page.locator('#tv-program-501-101')).toBeFocused(); + } + const focusedStyle=await page.locator(':focus').evaluate(control=>{const style=getComputedStyle(control);return{offset:style.outlineOffset,width:style.outlineWidth};}); + expect(focusedStyle).toEqual({offset:'-2px',width:'2px'}); + await page.evaluate(()=>{document.body.dataset.inputMode='gamepad';}); + await expect(page.locator(':focus')).toHaveCSS('outline-offset','-2px'); + await page.keyboard.press('ArrowRight');await expect(page.locator('#tv-program-501-102')).toBeFocused(); + await page.locator('#tv-guide-scroll').evaluate(scroll=>{scroll.scrollLeft=240;}); + await page.keyboard.press('ArrowDown');await expect(page.locator('#tv-program-502-110')).toBeFocused(); + expect(await page.locator('#tv-guide-scroll').evaluate(scroll=>scroll.scrollLeft)).toBe(240); + await page.keyboard.press('ArrowDown');await expect(page.locator('#tv-program-503-124')).toBeFocused(); + await page.keyboard.press('ArrowUp');await page.keyboard.press('ArrowUp'); + await expect(page.locator('#tv-program-501-102')).toBeFocused(); + await page.clock.fastForward(11000); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-window-start',String(start+1800)); + await expect(page.locator('#tv-program-501-100')).toHaveCount(0); + await expect(page.locator('#tv-program-501-102')).toBeFocused(); + expect(await page.locator('#tv-guide-scroll').evaluate(scroll=>scroll.scrollLeft)).toBe(120); + await page.locator('#tv-program-501-101').focus(); + await page.clock.fastForward(1800000); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-window-start',String(start+3600)); + await expect(page.locator('#tv-program-501-101')).toHaveCount(0); + await expect(page.locator('#tv-program-501-102')).toBeFocused(); + await expect(page.getByRole('button',{name:'Earlier programs',exact:true})).toBeDisabled(); + await page.getByRole('button',{name:'Later programs',exact:true}).click(); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-window-start',String(start+3600+21600)); + const future=await page.locator('#tv-guide-scroll').getAttribute('data-tv-window-start'); + await page.clock.fastForward(1800000); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-window-start',future); + await expect(page.getByRole('button',{name:'Earlier programs',exact:true})).toBeEnabled(); + await page.getByRole('button',{name:'Now',exact:true}).click(); + await expect(page.locator('#tv-guide-scroll')).toHaveAttribute('data-tv-window-start',String(start+5400)); +}); + +test('the guide in the player centers playback controls and reveals the playing channel',async({page},testInfo)=>{ + const channels=Array.from({length:40},(_,i)=>({id:1001+i,source_id:90,source_key:String(i),guide_key:String(i),name:`Fixture ${i+1}`,number:String(i+1),group_name:'',enabled:true,available:true})); + await page.route('**/api/v1/live-tv/channels',route=>route.fulfill({json:channels})); + await page.route('**/api/v1/live-tv/guide?**',route=>route.fulfill({json:{programs:[],stations:[],sources:[{id:90,name:'Guide layout fixture',kind:'m3u'}],preferences:{favorites:[],recent:[]}}})); + await page.goto('/'); + await page.getByLabel('Username',{exact:true}).fill('playback-admin'); + await page.getByLabel('Password',{exact:true}).fill(process.env.KOKO_E2E_PASSWORD); + await page.getByRole('button',{name:'Sign in',exact:true}).click(); + await expect(page.locator('#login-form')).toHaveCount(0); + const token=await page.evaluate(()=>localStorage.getItem('koko-client-web-auth-token')); + const headers={Authorization:`Bearer ${token}`}; + const items=await (await page.request.get('/api/v1/items',{headers})).json(); + const sample=items.find(item=>item.display_title==='Playback Sample'); + const sampleResponse=await page.request.get(`/api/v1/items/${sample.id}/stream`,{headers}); + expect(sampleResponse.ok()).toBeTruthy(); + const sampleVideo=await sampleResponse.body(); + let playing=1040; + const buffer=()=>({session_id:'layout-buffer',channel_id:playing,title:`Fixture ${playing-1000}`,start_ms:0,end_ms:6000,finished:true,can_keep:true}); + await page.route('**/api/v1/live-tv/sessions',route=>{ + playing=route.request().postDataJSON().channel_id; + return route.fulfill({json:buffer()}); + }); + await page.route('**/api/v1/live-tv/sessions/layout-buffer**',route=>{ + if(new URL(route.request().url()).pathname.endsWith('/stream')) return route.fulfill({contentType:'video/mp4',body:sampleVideo}); + return route.request().method()==='DELETE' ? route.fulfill({status:204}) : route.fulfill({json:buffer()}); + }); + await page.goto('/live-tv'); + await page.locator('[data-tv-play="1040"]').click(); + await expect.poll(()=>page.locator('#media-player').evaluate(video=>video.readyState>=2 && video.currentTime>.1)).toBeTruthy(); + const video=await page.locator('#media-player').elementHandle(); + const mainScroll=await page.locator('.main-shell').evaluate(main=>main.scrollTop); + const guide=page.getByRole('complementary',{name:'Channel guide',exact:true}); + const revealCurrent=async id=>{ + await expect(guide.locator(`[data-player-tv-channel="${id}"]`)).toBeFocused(); + const visible=await guide.locator(`[data-player-tv-channel="${id}"]`).evaluate(channel=>{ + const row=channel.getBoundingClientRect(),list=channel.closest('#player-tv-guide-channels').getBoundingClientRect(); + return row.top>=list.top-1 && row.bottom<=list.bottom+1; + }); + expect(visible).toBe(true); + }; + const centered=async()=>{ + const videoBounds=await page.locator('#media-player').boundingBox(),playBounds=await page.locator('#player-play-toggle-small').boundingBox(); + expect(Math.abs(playBounds.x+playBounds.width/2-videoBounds.x-videoBounds.width/2)).toBeLessThan(1); + }; + await page.getByRole('button',{name:'Channel guide',exact:true}).click(); + await revealCurrent(1040);await centered(); + expect(await video.evaluate(node=>node===document.querySelector('#media-player'))).toBe(true); + expect(await page.locator('.main-shell').evaluate(main=>main.scrollTop)).toBe(mainScroll); + await page.screenshot({path:testInfo.outputPath('centered-player-guide.png')}); + await page.getByRole('button',{name:'Close channel guide',exact:true}).click(); + await page.locator('#player-tv-guide-channels').evaluate(list=>{list.scrollTop=0;}); + await page.getByRole('button',{name:'Channel guide',exact:true}).click(); + await revealCurrent(1040); + await guide.getByRole('searchbox',{name:'Find a channel',exact:true}).fill('Fixture 1'); + await page.getByRole('button',{name:'Close channel guide',exact:true}).click(); + await page.getByRole('button',{name:'Channel guide',exact:true}).click(); + await revealCurrent(1040); + await expect(guide.getByRole('searchbox',{name:'Find a channel',exact:true})).toHaveValue(''); + await guide.getByRole('button',{name:'Watch Fixture 39 live',exact:true}).click(); + await expect(guide.locator('[data-player-tv-channel="1039"]')).toHaveAttribute('aria-pressed','true'); + await expect.poll(()=>guide.locator('[data-player-tv-channel="1039"]').evaluate(channel=>{ + const row=channel.getBoundingClientRect(),list=channel.closest('#player-tv-guide-channels').getBoundingClientRect(); + return row.top>=list.top-1 && row.bottom<=list.bottom+1; + })).toBeTruthy(); + await centered(); + await page.setViewportSize({width:390,height:844}); + await page.getByRole('button',{name:'Close channel guide',exact:true}).click(); + await page.getByRole('button',{name:'Channel guide',exact:true}).click(); + await expect(guide.locator('[data-player-tv-channel="1039"]')).toBeInViewport(); + await centered(); +}); diff --git a/crates/client-web/tests/e2e/playback.spec.mjs b/crates/client-web/tests/e2e/playback.spec.mjs index f95173a4..57ce530b 100644 --- a/crates/client-web/tests/e2e/playback.spec.mjs +++ b/crates/client-web/tests/e2e/playback.spec.mjs @@ -68,8 +68,10 @@ test('direct play seeks, saves on close, resumes after reload, and deletes its s await expect.poll(() => media(page).evaluate((player) => player.currentTime)).toBeGreaterThan(32); await page.locator('#close-player').click(); await sessionGone(page, session); - const progress = await page.request.get(`/api/v1/items/${item.id}`, { headers: await headers(page) }); - expect((await progress.json()).playback_position_ms).toBeGreaterThan(32000); + await expect.poll(async () => { + const progress = await page.request.get(`/api/v1/items/${item.id}`, { headers: await headers(page) }); + return (await progress.json()).playback_position_ms; + }).toBeGreaterThan(32000); await page.reload(); const resumed = await start(page); await playing(page); diff --git a/crates/client-web/tests/hold-actions.test.mjs b/crates/client-web/tests/hold-actions.test.mjs new file mode 100644 index 00000000..4d4a71df --- /dev/null +++ b/crates/client-web/tests/hold-actions.test.mjs @@ -0,0 +1,58 @@ +/** Exercises delegated guide holds, cancellation, release suppression, and virtualized controls. */ +import assert from 'node:assert/strict'; +import {after,test} from 'node:test'; +import {fileURLToPath} from 'node:url'; +import {createServer} from 'vite'; +import {JSDOM} from 'jsdom'; + +const server=await createServer({root:fileURLToPath(new URL('..',import.meta.url)),server:{middlewareMode:true,hmr:false,ws:false}}); +after(()=>server.close()); +const {bindHoldActions}=await server.ssrLoadModule('/src/app/holdActions.ts'); + +function fixture(t){ + t.mock.timers.enable({apis:['setTimeout']}); + const dom=new JSDOM(''); + const previous={document:globalThis.document,addEventListener:globalThis.addEventListener,AbortController:globalThis.AbortController}; + globalThis.document=dom.window.document;globalThis.addEventListener=dom.window.addEventListener.bind(dom.window);globalThis.AbortController=dom.window.AbortController; + const controller=new dom.window.AbortController(),plays=[],menus=[]; + bindHoldActions('[data-guide]',button=>plays.push(button.textContent),button=>{menus.push(button.textContent);document.querySelector('#option').focus();},controller.signal); + const fire=(target,type,values={})=>{const Constructor=type.startsWith('key')?dom.window.KeyboardEvent:dom.window.MouseEvent;const event=new Constructor(type,{bubbles:true,cancelable:true,...values});target.dispatchEvent(event);return event;}; + t.after(()=>{controller.abort();dom.window.close();Object.assign(globalThis,previous);}); + return {dom,controller,plays,menus,fire,button:document.querySelector('[data-guide]')}; +} + +test('short clicks and Enter activate exactly once, while a hold consumes release on the focused option',t=>{ + const f=fixture(t);f.button.click(); + f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(100);f.fire(f.button,'keyup',{key:'Enter'}); + assert.equal(f.plays.length,2); + f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(650); + assert.equal(f.menus.length,1); + assert.equal(f.fire(document.activeElement,'keydown',{key:'Enter',repeat:true}).defaultPrevented,true); + assert.equal(f.fire(document.activeElement,'keyup',{key:'Enter'}).defaultPrevented,true); + assert.equal(f.plays.length,2); +}); + +test('pointer holds suppress their click and later clicks still activate newly mounted rows',t=>{ + const f=fixture(t);f.fire(f.button,'pointerdown',{clientX:10,clientY:10});t.mock.timers.tick(650); + f.fire(f.button,'pointerup');f.button.click();assert.equal(f.menus.length,1);assert.equal(f.plays.length,0); + f.button.outerHTML=''; + const next=document.querySelector('[data-guide]');f.fire(next,'pointerdown');f.fire(next,'pointerup');next.click(); + assert.deepEqual(f.plays,['Virtualized channel']); + f.controller.abort();next.click();assert.equal(f.plays.length,1); +}); + +test('scroll gestures and focus loss cancel pending holds without starting playback',t=>{ + const f=fixture(t);f.fire(f.button,'pointerdown',{clientX:10,clientY:10});f.fire(f.button,'pointermove',{clientX:10,clientY:40});t.mock.timers.tick(650);f.button.click(); + f.button.focus();f.fire(f.button,'keydown',{key:'Enter'});document.querySelector('#option').focus();t.mock.timers.tick(650);f.fire(document.activeElement,'keyup',{key:'Enter'}); + assert.equal(f.plays.length,0);assert.equal(f.menus.length,0); + f.fire(f.button,'contextmenu');assert.deepEqual(f.menus,['Channel']); +}); + +test('a guide refresh preserves a held key and uses the refreshed callbacks',t=>{ + const f=fixture(t);f.fire(f.button,'keydown',{key:'Enter'});t.mock.timers.tick(300); + f.controller.abort(); + const next=new f.dom.window.AbortController();t.after(()=>next.abort()); + bindHoldActions('[data-guide]',()=>f.plays.push('refreshed'),()=>f.menus.push('refreshed'),next.signal); + t.mock.timers.tick(350);assert.deepEqual(f.menus,['refreshed']); + f.fire(f.button,'keyup',{key:'Enter'});assert.equal(f.plays.length,0); +}); diff --git a/crates/client-web/tests/live-tv.test.mjs b/crates/client-web/tests/live-tv.test.mjs new file mode 100644 index 00000000..00bf7eca --- /dev/null +++ b/crates/client-web/tests/live-tv.test.mjs @@ -0,0 +1,452 @@ +/** Tests account isolation, station mapping, safe metadata rendering, and guide request races. */ +import assert from 'node:assert/strict'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import { JSDOM } from 'jsdom'; + +globalThis.location = { pathname: '/live-tv', origin: 'https://koko.local' }; +globalThis.localStorage = { getItem: () => null }; +const server = await createServer({ root: fileURLToPath(new URL('..', import.meta.url)), server: { middlewareMode: true, hmr: false, ws: false } }); +after(() => server.close()); +const { channelPrograms, renderLiveTvPage, tvImage } = await server.ssrLoadModule('/src/app/liveTv.ts'); +const { loadLiveTv,sourceConfig } = await server.ssrLoadModule('/src/app/liveTvActions.ts'); +const { renderLiveTvSettings } = await server.ssrLoadModule('/src/app/liveTvSetup.ts'); +const { renderTvGuideMapping,bindTvGuideMappings } = await server.ssrLoadModule('/src/app/liveTvMapping.ts'); +const {bindTvDiscovery} = await server.ssrLoadModule('/src/app/liveTvDiscovery.ts'); +const { state } = await server.ssrLoadModule('/src/app/state.ts'); +const { parseRoute } = await server.ssrLoadModule('/src/app/routes.ts'); +const { programPosition, programBadges, matchesTvCategory, visibleGuideChannels, guideSources } = await server.ssrLoadModule('/src/app/liveTvGuideModel.ts'); +const { renderPlayerOverlay } = await server.ssrLoadModule('/src/app/playbackController.ts'); +const { TvPlaybackWatchdog } = await server.ssrLoadModule('/src/app/tvPlaybackRecovery.ts'); +const { currentGuideWindow,programAtTime } = await server.ssrLoadModule('/src/app/tvGuideTime.ts'); +const {tvGuideRowRange,tvGuideViewKey} = await server.ssrLoadModule('/src/app/tvGuideViewport.ts'); +const {guideViews,activeGuideView} = await server.ssrLoadModule('/src/app/liveTvGuideModel.ts'); +const {hydrateTvConnections,hydrateTvProviders} = await server.ssrLoadModule('/src/app/liveTvSourceActions.ts'); +const {renderTvSourceIcon,recoverTvSourceImage} = await server.ssrLoadModule('/src/app/liveTvIcons.ts'); +const {renderTvPlayerProgram} = await server.ssrLoadModule('/src/app/tvPlayer.ts'); +const now = Math.floor(Date.now() / 1000); +const channel = { id: 1, source_id: 1, source_key: 'source', name: 'Test TV', number: '7.1', group_name: 'News', guide_key: 'station', enabled: true, available: true }; + +test('pending tuner discovery survives retained bindings and ignores a different account view',async()=>{ + const previousDocument=globalThis.document,previousFetch=globalThis.fetch; + const dom=new JSDOM('

'); + globalThis.document=dom.window.document; + const controllers=Array.from({length:4},()=>new dom.window.AbortController()); + let deliver; + globalThis.fetch=()=>new Promise(resolve=>{deliver=resolve;}); + const finish=async()=>{deliver(Response.json([{name:'Living room tuner',url:'http://tuner.example/',capacity:4}]));await new Promise(resolve=>setTimeout(resolve,0));}; + try{ + bindTvDiscovery(controllers[0].signal);dom.window.document.querySelector('button').click(); + controllers[0].abort();bindTvDiscovery(controllers[1].signal); + await finish(); + controllers[1].abort();bindTvDiscovery(controllers[2].signal); + const radio=dom.window.document.querySelector('input[type="radio"]');assert.ok(radio); + radio.checked=true;radio.dispatchEvent(new dom.window.Event('change',{bubbles:true})); + assert.equal(dom.window.document.querySelector('[name="url"]').value,'http://tuner.example/'); + assert.equal(dom.window.document.querySelector('[name="capacity"]').value,'4'); + dom.window.document.querySelector('button').click(); + controllers[2].abort();dom.window.document.querySelector('form').dataset.tvFormKey='2:new:hdhomerun'; + bindTvDiscovery(controllers[3].signal);await finish(); + assert.equal(dom.window.document.querySelector('input[type="radio"]'),null); + }finally{controllers.forEach(controller=>controller.abort());globalThis.fetch=previousFetch;globalThis.document=previousDocument;dom.window.close();} +}); + +test('source artwork falls back from an uploaded icon to its application logo and then Lucide',()=>{ + const dom=new JSDOM(renderTvSourceIcon({kind:'nextpvr',image_url:'https://example.com/upload.png'})); + const image=dom.window.document.querySelector('img'); + assert.equal(image.src,'https://example.com/upload.png'); + recoverTvSourceImage(image); + assert.match(image.src,/\/assets\/providers\/nextpvr\.ico$/); + assert.equal(image.hidden,false); + recoverTvSourceImage(image); + assert.equal(image.hidden,true); + assert.ok(dom.window.document.querySelector('.tv-icon-fallback [data-lucide="database-zap"]')); + assert.doesNotMatch(renderTvSourceIcon({kind:'m3u'}),/ [channel], getTvGuide: async () => ({ programs: [program], stations: [] }), getTvRecordings: async () => ({ recordings: [], conflicts: [] }), getTvRules: async () => [], getTvSources: async () => [], ...extra }; +} + +test('live television has its own persistent route', () => { assert.deepEqual(parseRoute(), { page: 'live-tv' }); }); + +test('provider views keep categories and favorites scoped to the selected provider',()=>{ + const first={...channel,provider_id:'espn'},second={...channel,id:2,provider_id:'sling'}; + const providers=[{source_id:1,id:'espn',name:'ESPN',enabled:true,channel_count:1},{source_id:1,id:'sling',name:'Sling',enabled:true,channel_count:1}]; + const model=data({channels:[first,second],providers,preferences:{favorites:[1,2],recent:[2,1]}}); + assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[1,2]); + const original=tvGuideViewKey(model);model.providerView='1:espn';assert.notEqual(tvGuideViewKey(model),original); + assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[1]); + model.providerView='1:sling';model.channelView='favorites';assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[2]); + model.sourceId=2;model.channels.push({...second,id:3,source_id:2});assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[]); +}); + +test('IPTV provider editing offers channel icon references only from that provider',()=>{ + state.bootstrap={current_user:{id:1,admin:true}}; + const providers=[{source_id:1,id:'espn',name:'ESPN',enabled:true,channel_count:1,icon_channel_id:1,has_custom_icon:false}]; + state.liveTv=data({providerEditor:'1:espn',sources:[{id:1,kind:'m3u',name:'IPTV',capacity:3,enabled:true,providers}],channels:[{...channel,provider_id:'espn',logo:'http://logos.local/espn.png'},{...channel,id:2,provider_id:'sling',logo:'http://logos.local/sling.png'}]}); + const dom=new JSDOM(renderLiveTvSettings());const select=dom.window.document.querySelector('[name="icon_channel"]'); + assert.equal(select.value,'channel:1');assert.ok(select.querySelector('option[value="channel:1"]'));assert.equal(select.querySelector('option[value="channel:2"]'),null); + assert.ok(dom.window.document.querySelector('[aria-label="Upload icon for ESPN"]'));dom.window.close(); +}); + +test('IPTV connection edits retain their preset and can explicitly disable provider grouping',()=>{ + state.bootstrap={current_user:{id:1,admin:true}}; + const config={url:'http://fastchannels.local:5523',pin:'',xmltv_url:'',guide_kind:'automatic',iptv_mode:'fastchannels',provider_grouping:true,bridge_capacity:2}; + state.liveTv=data({sources:[{id:1,name:'FastChannels',kind:'m3u',enabled:true,capacity:10}],connectionOpen:1,connections:{1:{source:{name:'FastChannels',kind:'m3u',capacity:10,config},has_pin:false}}}); + const previous=globalThis.document,dom=new JSDOM(renderLiveTvSettings());globalThis.document=dom.window.document; + try { + hydrateTvConnections();const form=document.querySelector('[data-tv-replace-source="1"]'); + assert.equal(form.elements.namedItem('iptv_mode').value,'fastchannels');assert.equal(form.elements.namedItem('provider_grouping').checked,true);assert.equal(form.elements.namedItem('bridge_capacity').value,'2'); + const input=new FormData();input.set('url',config.url);input.set('iptv_mode','fastchannels');input.set('provider_grouping_present','true'); + assert.equal(sourceConfig(input,config).provider_grouping,false); + state.liveTv.connectionDrafts={1:{provider_grouping:'false'}};hydrateTvConnections();assert.equal(form.elements.namedItem('provider_grouping').checked,false); + }finally{globalThis.document=previous;dom.window.close();} +}); + +test('an uploaded provider icon stays selected across DOM patches without losing pending edits',()=>{ + state.bootstrap={current_user:{id:1,admin:true}}; + const provider={source_id:1,id:'espn',name:'ESPN',enabled:true,channel_count:1,has_custom_icon:true,image_url:'/api/v1/live-tv/sources/1/image?v=fixture&provider=espn'}; + state.liveTv=data({providerEditor:'1:espn',sources:[{id:1,name:'IPTV',kind:'m3u',enabled:true,capacity:1,providers:[provider]}]}); + const previous=globalThis.document,dom=new JSDOM(renderLiveTvSettings());globalThis.document=dom.window.document; + try { + const form=document.querySelector('[data-tv-provider-form]');form.elements.namedItem('icon_channel').value='default'; + hydrateTvProviders();assert.equal(form.elements.namedItem('icon_channel').value,'upload'); + state.liveTv.providerDrafts={'1:espn':{name:'My ESPN',enabled:false,icon_channel:'upload'}};hydrateTvProviders(); + assert.equal(form.elements.namedItem('name').value,'My ESPN');assert.equal(form.elements.namedItem('enabled').checked,false); + }finally{globalThis.document=previous;dom.window.close();} +}); + +test('the guide clips broadcasts to a shared timeline without closing schedule gaps', () => { + const clipped = programPosition({...program,starts_at:now-900,ends_at:now+900},now); + assert.equal(clipped.left,0); assert.ok(Math.abs(clipped.width-900/21600*100)<0.001); + const later = programPosition({...program,starts_at:now+7200,ends_at:now+10800},now); + assert.ok(Math.abs(later.left-100/3)<0.001); assert.ok(Math.abs(later.width-100/6)<0.001); + assert.equal(programPosition({...program,starts_at:now-900,ends_at:now},now),undefined); +}); + +test('live broadcasts, premieres, and new episodes retain separate flags and genre filters', () => { + assert.deepEqual(programBadges({is_new:true,is_live:false}),['NEW']); + assert.deepEqual(programBadges({is_live:true,is_premiere:true,is_finale:true}),['LIVE','PREMIERE','FINALE']); + assert.equal(matchesTvCategory({...program,metadata:{categories:['Sports event']}},'Sports'),true); + assert.equal(matchesTvCategory({...program,metadata:{categories:['Movie / drama']}},'Movies'),true); + assert.equal(matchesTvCategory({...program,metadata:{categories:['News / Current affairs']}},'News'),true); + assert.equal(matchesTvCategory({...program,metadata:{}},'Sports'),false); +}); + +test('source navigation and personal channel views retain source identity', () => { + const other = {...channel,id:2,source_id:2,name:'Other source'}; + const model = data({channels:[channel,other],guideSources:[{id:1,name:'Antenna'},{id:2,name:'Cable'}],preferences:{favorites:[2],recent:[2,1]}}); + assert.deepEqual(guideSources(model).map(s=>s.name),['Antenna','Cable']); + assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[1]); + model.sourceId=2; model.channelView='favorites'; + assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[2]); + model.sourceId=1; assert.deepEqual(visibleGuideChannels(model),[]); +}); + +test('named guide groups combine sources while retaining source-scoped listings and personal filters',()=>{ + const other={...channel,id:2,source_id:2,name:'Bridge channel'}; + const model=data({channels:[channel,other],programs:[program,{...program,id:2,source_id:2,title:'Bridge show'}],sourceGroups:[{id:'combined',name:'FastChannels',source_ids:[1,2]}],preferences:{favorites:[2],recent:[2,1]}}); + assert.deepEqual(guideViews(model).map(v=>({name:v.name,count:v.count})),[{name:'FastChannels',count:2}]); + assert.equal(activeGuideView(model).groupId,'combined'); + assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[1,2]); + assert.equal(channelPrograms(other,model.programs)[0].title,'Bridge show'); + model.channelView='favorites';assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[2]); + model.channelView='recent';assert.deepEqual(visibleGuideChannels(model).map(c=>c.id),[2,1]); +}); + +test('a 4402-channel guide indexes schedules once and mounts only a nearby row window',()=>{ + const channels=Array.from({length:4402},(_,i)=>({...channel,id:i+1,guide_key:`station-${i}`})); + const rows=channels.flatMap(c=>Array.from({length:12},(_,slot)=>({...program,id:c.id*20+slot,guide_key:c.guide_key,title:`Channel ${c.id} show ${slot}`,starts_at:now+slot*1800,ends_at:now+(slot+1)*1800}))); + let scans=0; + const indexed=new Proxy(rows,{get(target,key,receiver){if(key===Symbol.iterator){scans++;}return Reflect.get(target,key,receiver);}}); + state.bootstrap={current_user:{id:1,admin:true}}; + state.liveTv=data({channels,programs:indexed}); + let html=renderLiveTvPage(); + assert.equal(scans,1); + assert.ok((html.match(/class="tv-guide-row"/g) ?? []).length<30); + assert.match(html,/aria-rowcount="4403"/); + assert.ok(html.length<300_000); + state.liveTv.guideViewport={key:tvGuideViewKey(state.liveTv),top:2200*88,height:580}; + state.liveTv.selectedChannelId=2201;state.liveTv.selectedProgramId=2201*20; + html=renderLiveTvPage(); + assert.equal(scans,1);assert.match(html,/id="tv-channel-row-2201"/); + assert.doesNotMatch(html,/id="tv-channel-row-1"/); + const range=tvGuideRowRange(4402,4402*88,580); + assert.equal(range.end,4402);assert.ok(range.end-range.start<30); +}); + +test('source cards show counts and sizes and render only the opened source channel page',()=>{ + state.bootstrap={current_user:{id:1,admin:true}}; + state.liveTv=data({sources:[{id:1,name:'Fast',kind:'m3u',enabled:true,capacity:10,channel_count:4402,playlist_bytes:2*1024*1024,guide_bytes:150*1024*1024},{id:2,name:'Bridge',kind:'m3u',enabled:true,capacity:1}],channels:Array.from({length:4402},(_,i)=>({...channel,id:i+1,name:`Channel ${i+1}`})).concat([{...channel,id:5000,source_id:2,name:'Bridge only'}])}); + let dom=new JSDOM(renderLiveTvSettings()); + assert.equal(dom.window.document.querySelectorAll('[data-tv-channel-update]').length,0); + assert.equal(dom.window.document.querySelector('[data-tv-source-card="1"]').open,false); + assert.match(dom.window.document.querySelector('[data-tv-source-card="1"]').textContent,/4402 channels/); + assert.match(dom.window.document.querySelector('[data-tv-source-card="1"]').textContent,/M3U.*MB.*XMLTV.*MB/); + dom.window.close();state.liveTv.mappingSource=1;state.liveTv.mappingPage=1; + dom=new JSDOM(renderLiveTvSettings()); + assert.equal(dom.window.document.querySelectorAll('[data-tv-channel-update]').length,50); + assert.ok(dom.window.document.querySelector('[data-tv-source-card="1"] [data-tv-channel-update="51"]')); + assert.equal(dom.window.document.querySelector('[data-tv-channel-update="5000"]'),null); + dom.window.close(); +}); + +test('connection editors prefill saved values and preserve masked PINs and unfinished drafts',()=>{ + const config={url:'http://tv.local:8866/',pin:'',xmltv_url:'http://guide.local/listings.xml',guide_kind:'xmltv',artwork_provider:'guide',refresh_hours:12,zap2it:{country:'USA',postal_code:'14850',lineup_id:'saved-lineup',headend_id:'saved',device:'X',timezone:'',days:7}}; + state.bootstrap={current_user:{id:1,admin:true}}; + state.liveTv=data({sources:[{id:1,name:'NextPVR',kind:'nextpvr',enabled:true,capacity:3}],connectionOpen:1,connections:{1:{source:{name:'NextPVR',kind:'nextpvr',capacity:3,config},has_pin:true}}}); + const previous=globalThis.document; + const dom=new JSDOM(renderLiveTvSettings());globalThis.document=dom.window.document; + try { + hydrateTvConnections();const form=document.querySelector('[data-tv-replace-source="1"]'); + assert.equal(form.elements.namedItem('url').value,config.url); + assert.equal(form.elements.namedItem('xmltv_url').value,config.xmltv_url); + assert.equal(form.elements.namedItem('guide_kind').value,'xmltv'); + assert.equal(form.elements.namedItem('artwork_provider').value,'guide'); + assert.equal(form.elements.namedItem('refresh_hours').value,'12'); + assert.equal(form.elements.namedItem('pin').required,false); + assert.equal(form.elements.namedItem('preserve_pin').value,'true'); + state.liveTv.connectionDrafts={1:{url:'http://edited.local/'}};hydrateTvConnections(); + assert.equal(form.elements.namedItem('url').value,'http://edited.local/'); + } finally {globalThis.document=previous;dom.window.close();} +}); + +test('editing an automatic connection preserves its saved XMLTV URL and inactive lineup selection',()=>{ + const original={url:'http://tv.local/',pin:'',xmltv_url:'http://guide.local/listings.xml',guide_kind:'automatic',artwork_provider:'guide',zap2it:{country:'USA',postal_code:'14850',lineup_id:'saved-lineup',headend_id:'saved',device:'X',timezone:'',days:7}}; + const form=new FormData();for(const [name,value] of Object.entries({url:original.url,guide_kind:'automatic',refresh_hours:'18',preserve_pin:'true'}))form.set(name,value); + const config=sourceConfig(form,original); + assert.equal(config.xmltv_url,original.xmltv_url);assert.deepEqual(config.zap2it,original.zap2it); + assert.equal(config.refresh_hours,18);assert.equal(config.preserve_pin,true); +}); + +test('Zap2It logos and cover art request larger sizes while other hosts remain unchanged', () => { + assert.equal(tvImage('https://zpmc.tmsimg.com/h3/NowShowing/45738/logo.png?w=55'),'https://zpmc.tmsimg.com/h3/NowShowing/45738/logo.png?w=320'); + assert.equal(tvImage('https://zpmc.tmsimg.com/assets/cover.jpg?w=165'),'https://zpmc.tmsimg.com/assets/cover.jpg?w=960'); + assert.equal(tvImage('https://example.com/cover.jpg?w=165'),'https://example.com/cover.jpg?w=165'); +}); + +test('TV uses the shared video player in both overlay and hero modes without exposing another account', () => { + state.bootstrap={current_user:{id:1,admin:true}}; + state.activeTvPlayback={owner:1,kind:'channels',id:5,title:'Live news',logo:'/channel-logo.png',logoFallback:'https://example.com/station.png',url:'/api/v1/live-tv/sessions/test/stream',buffer:{session_id:'test',start_ms:0,end_ms:12000,can_keep:true}}; + state.isPlayerOpen=true; + let dom=new JSDOM(renderPlayerOverlay()); + assert.ok(dom.window.document.querySelector('.media-player-shell video#media-player')); + assert.ok(dom.window.document.querySelector('#player-quality')); + assert.ok(dom.window.document.querySelector('#player-keep-tv')); + assert.ok(dom.window.document.querySelector('.player-bottom-controls #player-keep-tv')); + assert.equal(dom.window.document.querySelector('.player-top-controls #player-keep-tv'),null); + assert.equal(dom.window.document.querySelector('#player-recording-menu').hidden,true); + assert.equal(dom.window.document.querySelectorAll('#player-recording-menu [role="menuitem"]').length,2); + assert.equal(dom.window.document.querySelector('.player-channel-logo').getAttribute('src'),'/channel-logo.png'); + assert.equal(dom.window.document.querySelector('.player-channel-logo').dataset.fallbackSrc,'https://example.com/station.png'); + const controls=[...dom.window.document.querySelectorAll('.player-transport-cluster button[aria-label]')].filter(button=>!button.closest('[role="menu"]')).map(button=>button.getAttribute('aria-label')); + assert.deepEqual(controls,['Keep recording','Back 10 seconds','Pause','Forward 10 seconds','Skip to end']); + assert.equal(dom.window.document.querySelector('#player-go-live'),null); + assert.equal(dom.window.document.querySelector('.player-channel-heading h2').textContent,'Live news'); + assert.equal(dom.window.document.querySelector('video').hasAttribute('controls'),false); + const source=dom.window.document.querySelector('video').getAttribute('src'); dom.window.close(); + state.isPlayerOpen=false; dom=new JSDOM(renderPlayerOverlay()); + assert.ok(dom.window.document.querySelector('.tv-preview-player #media-player')); + assert.equal(dom.window.document.querySelector('video').getAttribute('src'),source); dom.window.close(); + state.bootstrap.current_user.id=2; + assert.equal(renderPlayerOverlay(),''); + state.activeTvPlayback=undefined; +}); + +test('TV recovery detects frozen clocks, respects pauses, and caps retries across decoder replacements',()=>{ + const watchdog=new TvPlaybackWatchdog(); + const playing={position:1000,paused:false,interrupted:false,unavailable:false}; + watchdog.attach(1000,0); + assert.equal(watchdog.observe(playing,11999),undefined); + assert.equal(watchdog.observe(playing,12000),'recover'); + watchdog.attach(1000,12000); + assert.equal(watchdog.observe({...playing,interrupted:true},13000),undefined); + assert.equal(watchdog.observe(playing,27000),'recover'); + watchdog.attach(1000,27000); + assert.equal(watchdog.observe(playing,42000),'recover'); + watchdog.attach(1000,42000); + assert.equal(watchdog.observe(playing,57000),'exhausted'); + assert.equal(watchdog.observe({...playing,paused:true},90000),undefined); + assert.equal(watchdog.observe(playing,91000),undefined); + for(let second=92;second<=124;second+=2) watchdog.observe({...playing,position:1000+(second-91)*1000},second*1000); + assert.equal(watchdog.attempts,0); + assert.equal(watchdog.observe({...playing,position:34000,interrupted:true},125000),'recover'); + assert.equal(watchdog.observe({...playing,unavailable:true,interrupted:true},200000),undefined); +}); +test('guide mappings require the correct source and honor explicit overrides', () => { + const rows = [program, { ...program, id: 2, source_id: 2 }, { ...program, id: 3, guide_key: 'override' }]; + assert.deepEqual(channelPrograms(channel, rows).map(p => p.id), [1]); + assert.deepEqual(channelPrograms({ ...channel, guide_override: 'override' }, rows).map(p => p.id), [3]); +}); + +test('large guide station lists load only for the focused channel and retain edits on blur', () => { + const stations=Array.from({length:5200},(_,i)=>({source_id:1,guide_key:`station-${i}`,name:`Station ${i}`,number:String(i),aliases:[]})); + stations.push({source_id:2,guide_key:'foreign',name:'Other source',number:'1',aliases:[]}); + const channels=Array.from({length:3600},(_,i)=>({...channel,id:i+1,guide_override:i===0?'station-5':undefined})); + const html=channels.map(c=>renderTvGuideMapping(c,stations)).join(''); + assert.ok(html.length<1_000_000); + const dom=new JSDOM(html); const doc=dom.window.document; + const controller=new dom.window.AbortController(); + bindTvGuideMappings(doc,controller.signal,stations); + assert.equal(doc.querySelectorAll('option').length,3601); + const [first,second]=doc.querySelectorAll('select'); + assert.equal(first.value,'station-5'); first.focus(); + assert.equal(first.options.length,5201); + assert.equal(first.querySelector('[value="foreign"]'),null); + first.value='station-5199'; second.focus(); + assert.equal(first.options.length,2); assert.equal(first.value,'station-5199'); + assert.equal(second.options.length,5201); second.blur(); + assert.equal(second.options.length,1); + first.focus(); assert.equal(first.value,'station-5199'); first.blur(); + controller.abort(); second.focus(); assert.equal(second.options.length,1); + dom.window.close(); +}); +test('broadcast metadata is escaped and active artwork URLs never become image elements', () => { + state.bootstrap = { current_user: { id: 1, admin: false } }; + state.liveTv = data({ programs: [{ ...program, title: '', metadata: { description: '', image: 'javascript:bad()', credits: [''] } }] }); + const html = renderLiveTvPage(); + const dom = new JSDOM(html); + assert.ok(dom.window.document.querySelector('.home-pinned-header > .home-feature > .home-feature-copy')); + assert.ok(dom.window.document.querySelector('.home-navbar .browse-tabs')); + assert.equal(dom.window.document.querySelector('.tv-guide-hero'),null); + assert.equal(dom.window.document.querySelector('script, img[src="x"]'),null); + dom.window.close(); + assert.match(html, /<script>/); assert.match(html, /<ScRiPt>/); assert.match(html, /<img/); assert.doesNotMatch(html, /javascript:|